refactor(federation): ♻️ Replace WebFinger code with @lysand-org/federation logic, add new debug command

This commit is contained in:
Jesse Wierzbinski 2024-06-29 22:24:10 -10:00
parent 38c8ea24a9
commit cea9452127
No known key found for this signature in database
15 changed files with 256 additions and 99 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -2,6 +2,8 @@ import { mentionValidator } from "@/api";
import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization"; import { sanitizeHtml, sanitizeHtmlInline } from "@/sanitization";
import markdownItTaskLists from "@hackmd/markdown-it-task-lists"; import markdownItTaskLists from "@hackmd/markdown-it-task-lists";
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester";
import type { ContentFormat } from "@lysand-org/federation/types"; import type { ContentFormat } from "@lysand-org/federation/types";
import { config } from "config-manager"; import { config } from "config-manager";
import { import {
@ -41,7 +43,6 @@ import { objectToInboxRequest } from "./federation";
import { import {
type UserWithInstance, type UserWithInstance,
type UserWithRelations, type UserWithRelations,
resolveWebFinger,
transformOutputToUserWithRelations, transformOutputToUserWithRelations,
userExtrasTemplate, userExtrasTemplate,
userRelations, userRelations,
@ -255,7 +256,10 @@ export const findManyNotes = async (
* @param text The text to parse mentions from. * @param text The text to parse mentions from.
* @returns An array of users mentioned in the text. * @returns An array of users mentioned in the text.
*/ */
export const parseTextMentions = async (text: string): Promise<User[]> => { export const parseTextMentions = async (
text: string,
author: User,
): Promise<User[]> => {
const mentionedPeople = [...text.matchAll(mentionValidator)] ?? []; const mentionedPeople = [...text.matchAll(mentionValidator)] ?? [];
if (mentionedPeople.length === 0) { if (mentionedPeople.length === 0) {
return []; return [];
@ -310,10 +314,18 @@ export const parseTextMentions = async (text: string): Promise<User[]> => {
// Attempt to resolve mentions that were not found // Attempt to resolve mentions that were not found
for (const person of notFoundRemoteUsers) { for (const person of notFoundRemoteUsers) {
const user = await resolveWebFinger( const signatureConstructor = await SignatureConstructor.fromStringKey(
person?.[1] ?? "", author.data.privateKey ?? "",
person?.[2] ?? "", author.getUri(),
); );
const manager = new FederationRequester(
new URL(`https://${person?.[2] ?? ""}`),
signatureConstructor,
);
const uri = await manager.webFinger(person?.[1] ?? "");
const user = await User.resolve(uri);
if (user) { if (user) {
finalList.push(user); finalList.push(user);

View file

@ -9,12 +9,12 @@ import { type InferSelectModel, and, eq, sql } from "drizzle-orm";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { import {
Applications, Applications,
Instances, type Instances,
Notifications, Notifications,
Relationships, Relationships,
type Roles, type Roles,
Tokens, Tokens,
Users, type Users,
} from "~/drizzle/schema"; } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
import type { Application } from "./application"; import type { Application } from "./application";
@ -319,74 +319,6 @@ export const findManyUsers = async (
return output.map((user) => transformOutputToUserWithRelations(user)); return output.map((user) => transformOutputToUserWithRelations(user));
}; };
/**
* Resolves a WebFinger identifier to a user.
* @param identifier Either a UUID or a username
*/
export const resolveWebFinger = async (
identifier: string,
host: string,
): Promise<User | null> => {
// Check if user not already in database
const foundUser = await db
.select()
.from(Users)
.innerJoin(Instances, eq(Users.instanceId, Instances.id))
.where(and(eq(Users.username, identifier), eq(Instances.baseUrl, host)))
.limit(1);
if (foundUser[0]) {
return await User.fromId(foundUser[0].Users.id);
}
const hostWithProtocol = host.startsWith("http") ? host : `https://${host}`;
const response = await fetch(
new URL(
`/.well-known/webfinger?${new URLSearchParams({
resource: `acct:${identifier}@${host}`,
})}`,
hostWithProtocol,
),
{
method: "GET",
headers: {
Accept: "application/json",
},
proxy: config.http.proxy.address,
},
);
if (response.status === 404) {
return null;
}
const data = (await response.json()) as {
subject: string;
links: {
rel: string;
type: string;
href: string;
}[];
};
if (!(data.subject && data.links)) {
throw new Error(
"Invalid WebFinger data (missing subject or links from response)",
);
}
const relevantLink = data.links.find((link) => link.rel === "self");
if (!relevantLink) {
throw new Error(
"Invalid WebFinger data (missing link with rel: 'self')",
);
}
return User.resolve(relevantLink.href);
};
/** /**
* Retrieves a user from a token. * Retrieves a user from a token.
* @param access_token The access token to retrieve the user from. * @param access_token The access token to retrieve the user from.

View file

@ -0,0 +1,51 @@
import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester";
import { Args } from "@oclif/core";
import chalk from "chalk";
import ora from "ora";
import { BaseCommand } from "~/cli/base";
import { User } from "~/packages/database-interface/user";
export default class FederationUserFetch extends BaseCommand<
typeof FederationUserFetch
> {
static override args = {
address: Args.string({
description: "Address of remote user (name@host.com)",
required: true,
}),
};
static override description = "Fetch the URL of remote users via WebFinger";
static override examples = ["<%= config.bin %> <%= command.id %>"];
static override flags = {};
public async run(): Promise<void> {
const { args } = await this.parse(FederationUserFetch);
const spinner = ora("Fetching user URI").start();
const [username, host] = args.address.split("@");
const requester = await User.getServerActor();
const signatureConstructor = await SignatureConstructor.fromStringKey(
requester.data.privateKey ?? "",
requester.getUri(),
);
const manager = new FederationRequester(
new URL(`https://${host}`),
signatureConstructor,
);
const uri = await manager.webFinger(username);
spinner.succeed("Fetched user URI");
this.log(`URI: ${chalk.blueBright(uri)}`);
this.exit(0);
}
}

View file

@ -5,6 +5,7 @@ import EmojiDelete from "./commands/emoji/delete";
import EmojiImport from "./commands/emoji/import"; import EmojiImport from "./commands/emoji/import";
import EmojiList from "./commands/emoji/list"; import EmojiList from "./commands/emoji/list";
import FederationInstanceFetch from "./commands/federation/instance/fetch"; import FederationInstanceFetch from "./commands/federation/instance/fetch";
import FederationUserFetch from "./commands/federation/user/fetch";
import IndexRebuild from "./commands/index/rebuild"; import IndexRebuild from "./commands/index/rebuild";
import Start from "./commands/start"; import Start from "./commands/start";
import UserCreate from "./commands/user/create"; import UserCreate from "./commands/user/create";
@ -28,6 +29,7 @@ export const commands = {
"emoji:import": EmojiImport, "emoji:import": EmojiImport,
"index:rebuild": IndexRebuild, "index:rebuild": IndexRebuild,
"federation:instance:fetch": FederationInstanceFetch, "federation:instance:fetch": FederationInstanceFetch,
"federation:user:fetch": FederationUserFetch,
start: Start, start: Start,
}; };

View file

@ -337,6 +337,11 @@ description = "A Lysand instance"
# URL to your instance banner # URL to your instance banner
# banner = "" # banner = ""
# Used for federation. If left empty or missing, the server will generate one for you.
[instance.keys]
public = ""
private = ""
[permissions] [permissions]
# Control default permissions for users # Control default permissions for users
# Note that an anonymous user having a permission will not allow them # Note that an anonymous user having a permission will not allow them

View file

@ -104,7 +104,7 @@
"@json2csv/plainjs": "^7.0.6", "@json2csv/plainjs": "^7.0.6",
"@logtape/logtape": "npm:@jsr/logtape__logtape", "@logtape/logtape": "npm:@jsr/logtape__logtape",
"@lysand-org/client": "^0.2.3", "@lysand-org/client": "^0.2.3",
"@lysand-org/federation": "^2.0.0", "@lysand-org/federation": "^2.1.0",
"@oclif/core": "^4.0.7", "@oclif/core": "^4.0.7",
"@tufjs/canonical-json": "^2.0.0", "@tufjs/canonical-json": "^2.0.0",
"altcha-lib": "^0.3.0", "altcha-lib": "^0.3.0",

View file

@ -509,6 +509,15 @@ export const configValidator = z.object({
privacy_policy_path: z.string().optional(), privacy_policy_path: z.string().optional(),
logo: zUrl.optional(), logo: zUrl.optional(),
banner: zUrl.optional(), banner: zUrl.optional(),
keys: z
.object({
public: z.string().min(3).default("").or(z.literal("")),
private: z.string().min(3).default("").or(z.literal("")),
})
.default({
public: "",
private: "",
}),
}) })
.default({ .default({
name: "Lysand", name: "Lysand",
@ -518,6 +527,10 @@ export const configValidator = z.object({
privacy_policy_path: undefined, privacy_policy_path: undefined,
logo: undefined, logo: undefined,
banner: undefined, banner: undefined,
keys: {
public: "",
private: "",
},
}), }),
permissions: z permissions: z
.object({ .object({

View file

@ -326,7 +326,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
const parsedMentions = [ const parsedMentions = [
...(data.mentions ?? []), ...(data.mentions ?? []),
...(await parseTextMentions(plaintextContent)), ...(await parseTextMentions(plaintextContent, data.author)),
// Deduplicate by .id // Deduplicate by .id
].filter( ].filter(
(mention, index, self) => (mention, index, self) =>
@ -396,7 +396,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
* @returns The updated note * @returns The updated note
*/ */
async updateFromData(data: { async updateFromData(data: {
author?: User; author: User;
content?: ContentFormat; content?: ContentFormat;
visibility?: ApiStatus["visibility"]; visibility?: ApiStatus["visibility"];
isSensitive?: boolean; isSensitive?: boolean;
@ -418,7 +418,7 @@ export class Note extends BaseInterface<typeof Notes, StatusWithRelations> {
const parsedMentions = [ const parsedMentions = [
...(data.mentions ?? []), ...(data.mentions ?? []),
...(plaintextContent ...(plaintextContent
? await parseTextMentions(plaintextContent) ? await parseTextMentions(plaintextContent, data.author)
: []), : []),
// Deduplicate by .id // Deduplicate by .id
].filter( ].filter(

View file

@ -123,6 +123,56 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
); );
} }
static getServerActor(): User {
return new User({
id: "00000000-0000-0000-0000-000000000000",
username: "actor",
avatar: "",
createdAt: "2024-01-01T00:00:00.000Z",
displayName: "Server Actor",
note: "This is a system actor used for server-to-server communication. It is not a real user.",
updatedAt: "2024-01-01T00:00:00.000Z",
instanceId: null,
publicKey: config.instance.keys.public,
source: {
fields: [],
language: null,
note: "",
privacy: "public",
sensitive: false,
},
fields: [],
isAdmin: false,
isBot: false,
isLocked: false,
isDiscoverable: false,
endpoints: {
dislikes: "",
featured: "",
likes: "",
followers: "",
following: "",
inbox: "",
outbox: "",
},
disableAutomoderation: false,
email: "",
emailVerificationToken: "",
emojis: [],
followerCount: 0,
followingCount: 0,
header: "",
instance: null,
password: "",
passwordResetToken: "",
privateKey: config.instance.keys.private,
roles: [],
sanctions: [],
statusCount: 0,
uri: "",
});
}
static getUri(id: string, uri: string | null, baseUrl: string) { static getUri(id: string, uri: string | null, baseUrl: string) {
return uri || new URL(`/users/${id}`, baseUrl).toString(); return uri || new URL(`/users/${id}`, baseUrl).toString();
} }

View file

@ -1,7 +1,8 @@
import { applyConfig, auth, handleZodError } from "@/api"; import { applyConfig, auth, handleZodError } from "@/api";
import { errorResponse, jsonResponse } from "@/response"; import { errorResponse, jsonResponse } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape"; import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
@ -16,7 +17,6 @@ import {
oneOrMore, oneOrMore,
} from "magic-regexp"; } from "magic-regexp";
import { z } from "zod"; import { z } from "zod";
import { resolveWebFinger } from "~/classes/functions/user";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
@ -50,6 +50,7 @@ export default (app: Hono) =>
auth(meta.auth, meta.permissions), auth(meta.auth, meta.permissions),
async (context) => { async (context) => {
const { acct } = context.req.valid("query"); const { acct } = context.req.valid("query");
const { user } = context.req.valid("header");
if (!acct) { if (!acct) {
return errorResponse("Invalid acct parameter", 400); return errorResponse("Invalid acct parameter", 400);
@ -78,13 +79,22 @@ export default (app: Hono) =>
} }
const [username, domain] = accountMatches[0].split("@"); const [username, domain] = accountMatches[0].split("@");
const foundAccount = await resolveWebFinger(
username, const requester = user ?? User.getServerActor();
domain,
).catch((e) => { const signatureConstructor =
getLogger("webfinger").error`${e}`; await SignatureConstructor.fromStringKey(
return null; requester.data.privateKey ?? "",
}); requester.getUri(),
);
const manager = new FederationRequester(
new URL(`https://${domain}`),
signatureConstructor,
);
const uri = await manager.webFinger(username);
const foundAccount = await User.resolve(uri);
if (foundAccount) { if (foundAccount) {
return jsonResponse(foundAccount.toApi()); return jsonResponse(foundAccount.toApi());

View file

@ -1,6 +1,8 @@
import { applyConfig, auth, handleZodError } from "@/api"; import { applyConfig, auth, handleZodError } from "@/api";
import { errorResponse, jsonResponse } from "@/response"; import { errorResponse, jsonResponse } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester";
import { eq, like, not, or, sql } from "drizzle-orm"; import { eq, like, not, or, sql } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { import {
@ -16,7 +18,6 @@ import {
} from "magic-regexp"; } from "magic-regexp";
import stringComparison from "string-comparison"; import stringComparison from "string-comparison";
import { z } from "zod"; import { z } from "zod";
import { resolveWebFinger } from "~/classes/functions/user";
import { RolePermissions, Users } from "~/drizzle/schema"; import { RolePermissions, Users } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user"; import { User } from "~/packages/database-interface/user";
@ -90,7 +91,21 @@ export default (app: Hono) =>
const accounts: User[] = []; const accounts: User[] = [];
if (resolve && username && host) { if (resolve && username && host) {
const resolvedUser = await resolveWebFinger(username, host); const requester = self ?? User.getServerActor();
const signatureConstructor =
await SignatureConstructor.fromStringKey(
requester.data.privateKey ?? "",
requester.getUri(),
);
const manager = new FederationRequester(
new URL(`https://${host}`),
signatureConstructor,
);
const uri = await manager.webFinger(username);
const resolvedUser = await User.resolve(uri);
if (resolvedUser) { if (resolvedUser) {
accounts.push(resolvedUser); accounts.push(resolvedUser);

View file

@ -144,6 +144,14 @@ export default (app: Hono) =>
return jsonResponse(await note.toApi(user), 200); return jsonResponse(await note.toApi(user), 200);
} }
case "PUT": { case "PUT": {
if (!user) {
return errorResponse("Unauthorized", 401);
}
if (note.author.id !== user.id) {
return errorResponse("Unauthorized", 401);
}
if (media_ids.length > 0) { if (media_ids.length > 0) {
const foundAttachments = const foundAttachments =
await Attachment.fromIds(media_ids); await Attachment.fromIds(media_ids);
@ -154,6 +162,7 @@ export default (app: Hono) =>
} }
const newNote = await note.updateFromData({ const newNote = await note.updateFromData({
author: user,
content: statusText content: statusText
? { ? {
[content_type]: { [content_type]: {

View file

@ -1,11 +1,11 @@
import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api"; import { applyConfig, auth, handleZodError, userAddressValidator } from "@/api";
import { errorResponse, jsonResponse } from "@/response"; import { errorResponse, jsonResponse } from "@/response";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
import { getLogger } from "@logtape/logtape"; import { SignatureConstructor } from "@lysand-org/federation";
import { FederationRequester } from "@lysand-org/federation/requester";
import { and, eq, inArray, sql } from "drizzle-orm"; import { and, eq, inArray, sql } from "drizzle-orm";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { z } from "zod"; import { z } from "zod";
import { resolveWebFinger } from "~/classes/functions/user";
import { searchManager } from "~/classes/search/search-manager"; import { searchManager } from "~/classes/search/search-manager";
import { db } from "~/drizzle/db"; import { db } from "~/drizzle/db";
import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema"; import { Instances, Notes, RolePermissions, Users } from "~/drizzle/schema";
@ -121,13 +121,21 @@ export default (app: Hono) =>
} }
if (resolve) { if (resolve) {
const newUser = await resolveWebFinger( const requester = self ?? User.getServerActor();
username,
domain, const signatureConstructor =
).catch((e) => { await SignatureConstructor.fromStringKey(
getLogger("webfinger").error`${e}`; requester.data.privateKey ?? "",
return null; requester.getUri(),
}); );
const manager = new FederationRequester(
new URL(`https://${domain}`),
signatureConstructor,
);
const uri = await manager.webFinger(username);
const newUser = await User.resolve(uri);
if (newUser) { if (newUser) {
return jsonResponse({ return jsonResponse({

View file

@ -1,10 +1,13 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import chalk from "chalk"; import chalk from "chalk";
import type { Config } from "~/packages/config-manager"; import type { Config } from "~/packages/config-manager";
import { User } from "~/packages/database-interface/user";
export const checkConfig = async (config: Config) => { export const checkConfig = async (config: Config) => {
await checkOidcConfig(config); await checkOidcConfig(config);
await checkFederationConfig(config);
await checkHttpProxyConfig(config); await checkHttpProxyConfig(config);
await checkChallengeConfig(config); await checkChallengeConfig(config);
@ -127,3 +130,50 @@ const checkOidcConfig = async (config: Config) => {
await Bun.sleep(Number.POSITIVE_INFINITY); await Bun.sleep(Number.POSITIVE_INFINITY);
} }
}; };
const checkFederationConfig = async (config: Config) => {
const logger = getLogger("server");
if (!(config.instance.keys.public && config.instance.keys.private)) {
logger.fatal`The federation keys are not set in the config`;
logger.fatal`Below are generated keys for you to copy in the config at instance.keys.public and instance.keys.private`;
// Generate a key for them
const { public_key, private_key } = await User.generateKeys();
logger.fatal`Generated public key: ${chalk.gray(public_key)}`;
logger.fatal`Generated private key: ${chalk.gray(private_key)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
// Try and import the key
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.instance.keys.private, "base64"),
"Ed25519",
false,
["sign"],
)
.catch((e) => e as Error);
// Try and import the key
const publicKey = await crypto.subtle
.importKey(
"spki",
Buffer.from(config.instance.keys.public, "base64"),
"Ed25519",
false,
["verify"],
)
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
logger.fatal`The federation keys could not be imported! You may generate new ones by removing the old ones from the config and restarting the server.`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
};