Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,8 +292,8 @@ the `tsdown` configuration before writing tests:
}
~~~~

Each package with tests exposes a `test` npm script that runs Node.js's
built-in test runner:
Every package except `@drfed/web` exposes a `test` npm script that runs
Node.js's built-in test runner:

~~~~ json
"scripts": {
Expand Down Expand Up @@ -362,13 +362,33 @@ The CLI parser lives in *packages/drfed/src/parser.ts*, the program metadata in

The server currently supports:

- `--root-origin`/`-r` for the origin instances are subdomains of. Required.
- `--listen`/`-l` for the host and port, defaulting to `localhost:8888`.
- `--pglite-data-path`/`--data-path`/`-d` for local PGlite storage.
- `--postgres-url`/`--database-url`/`-D` for PostgreSQL.
- `--no-migrate`/`-M` to disable automatic migrations.
- `--email-from`/`-f` for the sender of login mail, defaulting to `noreply@`
at the root origin's host name.
- `--smtp-url`/`-s` for the SMTP server to deliver mail through.

Keep CLI options explicit and documented through Optique descriptions, because
those descriptions feed the generated help output.
those descriptions feed the generated help output. Options that name a web
origin should use Optique's `origin()` value parser rather than `url()`, so
that every spelling of the same origin is normalized the same way.
*packages/drfed/src/valueparser.ts* wraps it as `rootOrigin()` to add the two
rules that are DrFed's own: the root origin may not name an IP address, since
every instance is a subdomain of it, and its host name may not run past the 253
octets DNS allows, since login mail is sent from that domain.

Requests are routed by the authority they arrive on, in
*packages/drfed/src/serving.ts*. A subdomain one label below the root origin
is an instance and serves ActivityPub only; the root origin and every other
authority serve GraphQL and never answer as an instance; anything deeper under
the root domain is answered 421, and an unusable `Host` header 400. The
classification itself lives in *packages/graphql/src/origin.ts* alongside the
functions that compose an instance's authority, so that the two can never
disagree about what an instance host looks like. Anything that changes how a
host is composed or compared belongs there, not in the server.


Quality bar
Expand All @@ -377,11 +397,16 @@ Quality bar
Before sending a pull request, run:

~~~~ sh
mise run check
mise run build
mise run check
mise run test
~~~~

Build first. `mise run check` type-checks each package against the *dist/* of
the ones it depends on, so on a fresh checkout, or after adding a subpath
export, checking before building reports missing modules that are not actually
missing. This is the order CI uses.

Run `mise run dev` for changes that affect startup, CLI parsing, migration
execution, the GraphQL server, or package build output. Manually verify the
installed CLI behavior when changing package metadata, `bin` entries, build
Expand Down
1 change: 1 addition & 0 deletions packages/drfed/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000
DRFED_ROOT_ORIGIN=http://drfed.localhost:8888
83 changes: 72 additions & 11 deletions packages/drfed/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,65 @@ Usage
-----

~~~~ sh
drfed-server --data-path .pgdata
drfed-server --database-url postgres://localhost/drfed
drfed-server --root-origin https://drfed.example.com --data-path .pgdata
drfed-server --root-origin https://drfed.example.com \
--database-url postgres://localhost/drfed
~~~~

The server listens on `localhost:8888` by default. Pass `--listen HOST:PORT`
to override. Automatic database migrations run on startup unless `--no-migrate`
is given.


Root origin
-----------

`--root-origin` is required, and everything else follows from it. Each
instance is served from its own subdomain of that origin, so with
`https://drfed.example.com` the instance `foo-bar` lives at
`https://foo-bar.drfed.example.com`. A non-default port belongs in the value
and is carried into every instance, which is what makes a development
deployment work:

~~~~ sh
drfed-server --root-origin http://drfed.localhost:8888 --data-path .pgdata
~~~~

Requests are routed by the authority they arrive on:

| Authority | Serves |
| ------------------------------------- | ------------------------------------- |
| `<slug>.<root domain>` | ActivityPub only; GraphQL answers 404 |
| the root origin | GraphQL |
| the listening address, internal names | GraphQL |
| anything deeper under the root domain | 421 Misdirected Request |
| an unusable `Host` header | 400 Bad Request |

The authority has to match in full, port included. A request for
`foo.drfed.example.com:8888` against a root origin of
`https://drfed.example.com` does not name an instance and is served the control
surface, not a 404, so do not read the table above as isolating GraphQL by host
name alone. Ports 80 and 443 both count as no port at all, since which of them
is the default depends on a scheme that is deliberately not compared.

Not comparing the scheme is what lets a deployment sit behind a TLS-terminating
reverse proxy. Such a proxy must pass two things through:

- `Host`, unchanged, because that is what names the instance. A `Host`
carrying the root zone's trailing dot is answered 400 rather than served,
because the HTTP layer refuses it before DrFed sees it.
- `X-Forwarded-Proto: https`. Without it the request looks like plain HTTP,
and every actor URI DrFed mints names `http://`, which is not where the
actor lives and not what the rest of the fediverse will accept.

Deploying this way needs a wildcard DNS record for `*.<root domain>` and, over
HTTPS, a wildcard TLS certificate to match.

Changing the root origin after instances exist does not move them. Their host
names are already part of the actor URIs the rest of the fediverse has stored,
so the server only warns at startup about instances it can no longer reach.


Environment
-----------

Expand All @@ -29,7 +79,7 @@ or HTTPS origins allowed in email login links:

~~~~ sh
DRFED_LOGIN_ORIGINS=https://drfed.example.com,http://localhost:3000 \
drfed-server --data-path .pgdata
drfed-server --root-origin https://drfed.example.com --data-path .pgdata
~~~~

For repository development, create the environment file loaded by
Expand All @@ -39,18 +89,29 @@ For repository development, create the environment file loaded by
cp packages/drfed/.env.example packages/drfed/.env
~~~~

That file also carries `DRFED_ROOT_ORIGIN`, which `mise run dev` passes as
`--root-origin`. It defaults to `http://drfed.localhost:8888`; every subdomain
of `localhost` resolves to the loopback address without any DNS or */etc/hosts*
setup, which is what makes per-instance subdomains usable locally.


Options
-------

| Option | Short | Description |
| ------------------------- | ----- | ------------------------------------------------ |
| `--listen HOST:PORT` | `-l` | Address to listen on (default: `localhost:8888)` |
| `--pglite-data-path PATH` | `-d` | Directory for PGlite storage |
| `--postgres-url URL` | `-D` | PostgreSQL connection URL |
| `--no-migrate` | `-M` | Skip automatic migrations |
| `--help` | | Show help |
| `--version` | | Show version |
| Option | Short | Description |
| ------------------------- | ----- | -------------------------------------------------------------------- |
| `--root-origin ORIGIN` | `-r` | Origin instances are subdomains of (required) |
| `--listen HOST:PORT` | `-l` | Address to listen on (default: `localhost:8888`) |
| `--pglite-data-path PATH` | `-d` | Directory for PGlite storage |
| `--postgres-url URL` | `-D` | PostgreSQL connection URL |
| `--no-migrate` | `-M` | Skip automatic migrations |
| `--email-from ADDRESS` | `-f` | Sender of login mail (default: `noreply@` at the root origin's host) |
| `--smtp-url URL` | `-s` | SMTP server to deliver mail through |
| `--help` | | Show help |
| `--version` | | Show version |

`--pglite-data-path` and `--postgres-url` are mutually exclusive. One of them
must be provided.

Without `--smtp-url`, mail is written to the log instead of being delivered,
which is why a development server can sign you in without a mail server.
22 changes: 21 additions & 1 deletion packages/drfed/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,20 @@
"type": "module",
"main": "dist/index.mjs",
"types": "dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"./valueparser": {
"types": "./dist/valueparser.d.mts",
"default": "./dist/valueparser.mjs"
},
"./serving": {
"types": "./dist/serving.d.mts",
"default": "./dist/serving.mjs"
}
},
"files": [
"bin/",
"dist/",
Expand All @@ -50,14 +64,20 @@
"drfed-server": "bin/drfed-server.mjs"
},
"tsdown": {
"entry": [
"src/index.ts",
"src/valueparser.ts",
"src/serving.ts"
],
"dts": {
"sourcemap": true,
"tsconfig": "../../tsconfig.drfed.json"
},
"sourcemap": true
},
"scripts": {
"build": "tsdown"
"build": "tsdown",
"test": "node --test"
},
"devDependencies": {
"@logtape/testing-node": "catalog:",
Expand Down
18 changes: 10 additions & 8 deletions packages/drfed/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import type {
} from "./parser.ts";
import program from "./program.ts";
import seedData from "./seed.ts";
import { createFetchHandler, warnAboutStrandedInstances } from "./serving.ts";

async function runServer(options: ServerOptions) {
const values = process.env.DRFED_LOGIN_ORIGINS?.split(",").map((value) =>
Expand Down Expand Up @@ -66,20 +67,21 @@ async function runServer(options: ServerOptions) {
? new PgliteKvStore(credentials.client)
: new PostgresKvStore(credentials.client);
const federation = await createFederation(options.drizzle.db, { kv });
const { mailer, root } = options;
const { emailFrom, mailer, rootOrigin } = options;

const yogaServer = createYogaServer(options.drizzle.db, federation, {
root,
rootOrigin,
emailFrom,
mailer,
loginOrigins,
});
await warnAboutStrandedInstances(options.drizzle.db, rootOrigin);
const server = serve({
fetch: (req) =>
federation.fetch(req, {
onNotFound: yogaServer.fetch,
onNotAcceptable: yogaServer.fetch,
contextData: undefined,
}),
fetch: createFetchHandler({
federation,
rootOrigin,
serveControlSurface: yogaServer.fetch,
}),
hostname: options.address.host,
manual: true,
port: options.address.port,
Expand Down
Loading