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
27 changes: 17 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,24 @@ FMSG_DOMAIN=example.com
# Email address for Let's Encrypt certificate registration
CERTBOT_EMAIL=

# HMAC secret used to validate JWT tokens for fmsg-webapi
# Prefix with base64: to supply a base64-encoded key (e.g. base64:c2VjcmV0)
FMSG_API_JWT_SECRET=changeme

# Per-service database passwords (used by application services)
FMSGD_WRITER_PGPASSWORD=changeme
FMSGID_WRITER_PGPASSWORD=changeme
# Base64-encoded Ed25519 seed or private key used by fmsg-webapi to mint
# short-lived first-party JWTs from API keys. Generate with:
# openssl rand -base64 32
FMSG_API_TOKEN_ED25519_PRIVATE_KEY=changeme

# Per-service database passwords (used by application services)
FMSGD_WRITER_PGPASSWORD=changeme
FMSGID_WRITER_PGPASSWORD=changeme

# ── Optional (defaults shown) ────────────────────────────

# FMSG_PORT=4930
# FMSGID_PORT=8080
# GIN_MODE=release
# FMSG_SKIP_DOMAIN_IP_CHECK=false
# FMSGID_PORT=8080
# GIN_MODE=release
# FMSG_SKIP_DOMAIN_IP_CHECK=false

# External user JWT login (optional; set all four to enable JWKS auth)
# FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
# FMSG_JWT_ISSUER=https://idp.example.com/
# FMSG_JWT_AUDIENCE=fmsg-web-client
# FMSG_JWT_ADDRESS_CLAIM=fmsg_address
20 changes: 17 additions & 3 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,15 @@ Edit `compose/.env` and set at least:
```env
FMSG_DOMAIN=example.com
CERTBOT_EMAIL=
FMSG_API_JWT_SECRET=<secret>
FMSG_API_TOKEN_ED25519_PRIVATE_KEY=<base64-ed25519-seed>
FMSGD_WRITER_PGPASSWORD=<strong-password>
FMSGID_WRITER_PGPASSWORD=<strong-password>
```

_NOTE_
* FMSG_DOMAIN is the domain part of fmsg addresses e.g. in `@user@example.com` would be `example.com`. This server you are setting up is located at the subdomain `fmsg.<your-domain>` but addresses will be at `<your-domain>`, you should only specify `<your-domain>` for FMSG_DOMAIN here.
* CERTBOT_EMAIL is an email address supplied to [Let's Encrypt](https://letsencrypt.org/) for e.g. TLS expiry warnings.
* Generate `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` with `openssl rand -base64 32`.
* For all secrets and passwords env vars create your own.

Start the stack for the first time from `compose/` and pass the one-time init passwords on the command line (keep these secret, keep them safe):
Expand Down Expand Up @@ -92,6 +93,19 @@ docker compose cp addresses.csv fmsgid:/opt/fmsgid/data/addresses.csv

### Connect a Client

* Connect a client such as [fmsg-cli](https://github.com/markmnl/fmsg-cli) to `fmsgapi.<your-domain>` configured with your `FMSG_API_JWT_SECRET` to send and retrieve messages.
Create or rotate an API key with the fmsg-webapi operator command, then use it with [fmsg-cli](https://github.com/markmnl/fmsg-cli):

_NOTE_ Anyone with `FMSG_API_JWT_SECRET` can mint tokens for your `fmsgapi.<your-domain>` for any user e.g. `@alice@<your-domain>`.
```sh
docker compose exec fmsg-webapi /opt/fmsg-webapi/fmsg-webapi api-key create-delegation \
-owner @alice@example.com \
-agent cli \
-addr @alice@example.com \
-cidr 203.0.113.0/24 \
-expires 2026-12-31T00:00:00Z

FMSG_API_URL=https://fmsgapi.example.com \
FMSG_API_KEY=fmsgk_<key_id>_<secret> \
fmsg list
```

The API key plaintext is printed once when created or rotated. Store it securely and pass it to automated clients through `FMSG_API_KEY`.
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ The compose stack uses Docker named volumes:
```
FMSG_DOMAIN=example.com
CERTBOT_EMAIL=admin@example.com
FMSG_API_JWT_SECRET=<secret>
FMSG_API_TOKEN_ED25519_PRIVATE_KEY=<base64-ed25519-seed>
FMSGD_WRITER_PGPASSWORD=<strong random password>
FMSGID_WRITER_PGPASSWORD=<strong random password>
```
Expand Down Expand Up @@ -113,7 +113,7 @@ The compose stack uses Docker named volumes:

## Integration Tests

End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com`) on a shared Docker network and exchange messages between them using [fmsg-cli](https://github.com/markmnl/fmsg-cli).
End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com`) on a shared Docker network and exchange messages between them using [fmsg-cli](https://github.com/markmnl/fmsg-cli). The test runner enables fmsg-webapi API-key auth, creates delegated API keys for the test actors during setup, and passes them to fmsg-cli with `FMSG_API_KEY`.

**Prerequisites:** Docker, docker compose, Go 1.24+, curl.

Expand All @@ -128,7 +128,7 @@ End-to-end tests that spin up two full stacks (`hairpin.local` and `example.com`
./test/run-tests.sh cleanup

# Refresh local database DD scripts from component branches
FMSGD_REF=main FMSGID_REF=main ./scripts/update-dd.sh
FMSGD_REF=main FMSGID_REF=main FMSG_WEBAPI_REF=main ./scripts/update-dd.sh

# CI drift check for database DD scripts
./scripts/update-dd.sh --check
Expand All @@ -146,12 +146,18 @@ Configure these in `compose/.env`. Variables marked **required** have no default
|------------------------------|----------|-----------|----------------------------------------------------------|
| `FMSG_DOMAIN` | yes | | The domain name for your fmsg host |
| `CERTBOT_EMAIL` | yes | | Email address for Let's Encrypt certificate registration |
| `FMSG_API_JWT_SECRET` | yes | | HMAC secret for fmsg-webapi JWT validation |
| `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` | auth | | Base64 Ed25519 seed/private key used to mint first-party JWTs from API keys |
| `FMSG_JWT_JWKS_URL` | auth | | JWKS endpoint for external RS256 user JWT login |
| `FMSG_JWT_ISSUER` | JWKS | | Expected issuer for external user JWTs |
| `FMSG_JWT_AUDIENCE` | JWKS | | Expected audience for external user JWTs |
| `FMSG_JWT_ADDRESS_CLAIM` | JWKS | | Claim containing the fmsg address |
| `FMSG_PORT` | no | `4930` | Host port fmsgd listens on |
| `FMSGID_PORT` | no | `8080` | Internal port for the fmsgid API |
| `GIN_MODE` | no | `release` | Gin framework mode for fmsgid (`release` or `debug`) |
| `FMSG_SKIP_DOMAIN_IP_CHECK` | no | `false` | Skip domain-to-IP validation in fmsgd (useful for dev) |

At least one auth mode is required for fmsg-webapi: API-key auth with `FMSG_API_TOKEN_ED25519_PRIVATE_KEY`, external user JWT auth with the JWKS variables, or both. API keys can be created or rotated with the fmsg-webapi operator command and used by fmsg-cli through `FMSG_API_KEY`.

### Database

The PostgreSQL instance hosts two separate databases (`fmsgd` and `fmsgid`) with dedicated roles per service.
Expand All @@ -176,6 +182,7 @@ On first startup (empty data volume), PostgreSQL runs the scripts in `docker/pos
| `001-init.sh` | Creates roles (with passwords from env) and databases |
| `002-fmsgd-dd.sql` | Creates tables and other database objects for fmsgd |
| `002-fmsgid-dd.sql` | Creates tables and other database objects for fmsgid |
| `003-fmsg-webapi-dd.sql` | Creates fmsg-webapi API-key grant tables |
| `999-permissions.sql`| Grants permissions after all objects exist |

> **WARNING:** To re-run initialisation you must remove the `postgres_data` volume.
Expand Down
14 changes: 9 additions & 5 deletions compose/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,11 +101,15 @@ services:
CACHEBUST: ${CACHEBUST:-}
restart: unless-stopped
environment:
FMSG_DOMAIN: ${FMSG_DOMAIN}
FMSG_ID_URL: http://fmsgid:${FMSGID_PORT:-8080}
FMSG_API_JWT_SECRET: ${FMSG_API_JWT_SECRET}
FMSG_API_PORT: ${FMSG_API_PORT:-8000}
PGHOST: postgres
FMSG_DOMAIN: ${FMSG_DOMAIN}
FMSG_ID_URL: http://fmsgid:${FMSGID_PORT:-8080}
FMSG_API_TOKEN_ED25519_PRIVATE_KEY: ${FMSG_API_TOKEN_ED25519_PRIVATE_KEY:-}
FMSG_JWT_JWKS_URL: ${FMSG_JWT_JWKS_URL:-}
FMSG_JWT_ISSUER: ${FMSG_JWT_ISSUER:-}
FMSG_JWT_AUDIENCE: ${FMSG_JWT_AUDIENCE:-}
FMSG_JWT_ADDRESS_CLAIM: ${FMSG_JWT_ADDRESS_CLAIM:-}
FMSG_API_PORT: ${FMSG_API_PORT:-8000}
PGHOST: postgres
PGPORT: 5432
PGDATABASE: fmsgd
PGUSER: fmsgd_writer
Expand Down
11 changes: 5 additions & 6 deletions docker/fmsg-webapi/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,16 @@ ARG CACHEBUST

WORKDIR /build

RUN git clone --branch "$FMSG_WEBAPI_REF" --depth 1 https://github.com/markmnl/fmsg-webapi.git . && \
cd src && \
go build -o fmsg-webapi .

FROM debian:bookworm-slim
RUN git clone --branch "$FMSG_WEBAPI_REF" --depth 1 https://github.com/markmnl/fmsg-webapi.git . && \
go build -o fmsg-webapi ./cmd/fmsg-webapi

FROM debian:bookworm-slim

RUN useradd -r -s /bin/false fmsg

WORKDIR /opt/fmsg-webapi

COPY --from=builder /build/src/fmsg-webapi /opt/fmsg-webapi/fmsg-webapi
COPY --from=builder /build/fmsg-webapi /opt/fmsg-webapi/fmsg-webapi

RUN chown -R fmsg:fmsg /opt/fmsg-webapi

Expand Down
66 changes: 66 additions & 0 deletions docker/postgres/init/003-fmsg-webapi-dd.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
\connect fmsgd

-- fmsg-webapi database extensions.
--
-- This file extends the fmsgd schema at:
-- https://github.com/markmnl/fmsgd/blob/main/dd.sql
--
-- fmsg-webapi assumes fmsgd is the fmsg host implementation and uses the same
-- PostgreSQL database.

CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
owner_addr varchar(255) NOT NULL,
agent varchar(64) NOT NULL,
sub_addr varchar(255),
grant_type text NOT NULL DEFAULT 'derived_sub_account',
display_name text,
key_id varchar(64),
key_hash bytea,
allowed_cidrs cidr[],
key_expires_at timestamptz,
max_sub_accounts int NOT NULL DEFAULT 5,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_addr, agent),
UNIQUE (key_id),
CHECK (max_sub_accounts > 0),
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')),
CHECK (
(agent = '' AND sub_addr IS NULL AND display_name IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL)
OR
(agent <> '' AND sub_addr IS NOT NULL AND key_id IS NOT NULL AND key_hash IS NOT NULL AND allowed_cidrs IS NOT NULL AND cardinality(allowed_cidrs) > 0 AND key_expires_at IS NOT NULL)
),
CHECK (agent = '' OR agent NOT LIKE '%\_%' ESCAPE '\')
);

ALTER TABLE fmsg_api_sub_account
ADD COLUMN IF NOT EXISTS grant_type text NOT NULL DEFAULT 'derived_sub_account';

ALTER TABLE fmsg_api_sub_account
ADD COLUMN IF NOT EXISTS display_name text;

ALTER TABLE fmsg_api_sub_account
DROP CONSTRAINT IF EXISTS fmsg_api_sub_account_sub_addr_key;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fmsg_api_sub_account_grant_type_check'
) THEN
ALTER TABLE fmsg_api_sub_account
ADD CONSTRAINT fmsg_api_sub_account_grant_type_check
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity'));
END IF;
END $$;

CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx
ON fmsg_api_sub_account ((lower(owner_addr)));

CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx
ON fmsg_api_sub_account ((lower(sub_addr)));

CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique
ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr)))
WHERE agent <> '';
11 changes: 7 additions & 4 deletions scripts/update-dd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ usage() {
cat <<'EOF'
Usage: scripts/update-dd.sh [--check]

Updates local PostgreSQL DD scripts from the fmsgd and fmsgid repositories.
Updates local PostgreSQL DD scripts from the fmsgd, fmsgid and fmsg-webapi repositories.

Environment variables:
FMSGD_REF fmsgd branch to fetch from (default: main)
FMSGID_REF fmsgid branch to fetch from (default: main)
FMSGD_REF fmsgd branch to fetch from (default: main)
FMSGID_REF fmsgid branch to fetch from (default: main)
FMSG_WEBAPI_REF fmsg-webapi branch to fetch from (default: main)

Options:
--check report drift without modifying files; exits non-zero on drift
Expand Down Expand Up @@ -45,6 +46,7 @@ fi

FMSGD_REF="${FMSGD_REF:-main}"
FMSGID_REF="${FMSGID_REF:-main}"
FMSG_WEBAPI_REF="${FMSG_WEBAPI_REF:-main}"

TMP_DIR="$(mktemp -d)"
cleanup() {
Expand Down Expand Up @@ -84,9 +86,10 @@ STATUS=0

write_dd "fmsgd" "$FMSGD_REF" "fmsgd" "$REPO_ROOT/docker/postgres/init/002-fmsgd-dd.sql" || STATUS=1
write_dd "fmsgid" "$FMSGID_REF" "fmsgid" "$REPO_ROOT/docker/postgres/init/002-fmsgid-dd.sql" || STATUS=1
write_dd "fmsg-webapi" "$FMSG_WEBAPI_REF" "fmsgd" "$REPO_ROOT/docker/postgres/init/003-fmsg-webapi-dd.sql" || STATUS=1

if [ "$MODE" = "check" ] && [ "$STATUS" -ne 0 ]; then
echo "DD scripts are out of date. Run scripts/update-dd.sh with matching refs." >&2
fi

exit "$STATUS"
exit "$STATUS"
61 changes: 59 additions & 2 deletions test/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,7 @@ export FMSGID_WRITER_PGPASSWORD=testfmsgidwriter
export FMSGID_READER_PGPASSWORD=testfmsgidreader
export FMSG_SKIP_DOMAIN_IP_CHECK=true
export FMSG_SKIP_AUTHORISED_IPS=true
export FMSG_API_JWT_SECRET=test-jwt-secret
export FMSG_JWT_SECRET=test-jwt-secret
export FMSG_API_TOKEN_ED25519_PRIVATE_KEY="${FMSG_API_TOKEN_ED25519_PRIVATE_KEY:-$(openssl rand -base64 32)}"
export FMSG_TLS_INSECURE_SKIP_VERIFY=true

# ── Pass through ref overrides for Docker build args ─────────
Expand All @@ -124,6 +123,62 @@ export FMSGID_REF=${FMSGID_REF:-main}
export FMSG_WEBAPI_REF=${FMSG_WEBAPI_REF:-main}
FMSG_CLI_REF=${FMSG_CLI_REF:-main}

export ALICE_ADDR="@alice@hairpin.local"
export BOB_ADDR="@bob@example.com"
export CAROL_ADDR="@carol@example.com"

mask_secret() {
if [ -n "${GITHUB_ACTIONS:-}" ] && [ -n "$1" ]; then
echo "::add-mask::$1"
fi
}

create_or_rotate_api_key() {
local container="$1"
local owner="$2"
local addr="$3"
local output
local api_key

if ! output=$(docker exec "$container" /opt/fmsg-webapi/fmsg-webapi api-key create-delegation \
-owner "$owner" \
-agent test \
-addr "$addr" \
-cidr "0.0.0.0/0,::/0" \
-expires "2099-01-01T00:00:00Z" 2>&1); then
if ! output=$(docker exec "$container" /opt/fmsg-webapi/fmsg-webapi api-key rotate-delegation \
-owner "$owner" \
-agent test \
-cidr "0.0.0.0/0,::/0" \
-expires "2099-01-01T00:00:00Z" 2>&1); then
echo "Failed to create API key for $addr" >&2
echo "$output" | sed 's/^api_key=.*/api_key=<redacted>/' >&2
exit 1
fi
fi

api_key=$(echo "$output" | sed -n 's/^api_key=//p' | head -1)
if [ -z "$api_key" ]; then
echo "Failed to create API key for $addr" >&2
echo "$output" | sed 's/^api_key=.*/api_key=<redacted>/' >&2
exit 1
fi

echo "$api_key"
}

setup_api_keys() {
echo "==> Creating integration-test API keys..."
ALICE_API_KEY="$(create_or_rotate_api_key hairpin-fmsg-webapi-1 "$ALICE_ADDR" "$ALICE_ADDR")"
BOB_API_KEY="$(create_or_rotate_api_key example-fmsg-webapi-1 "$BOB_ADDR" "$BOB_ADDR")"
CAROL_API_KEY="$(create_or_rotate_api_key example-fmsg-webapi-1 "$CAROL_ADDR" "$CAROL_ADDR")"
export ALICE_API_KEY BOB_API_KEY CAROL_API_KEY
mask_secret "$ALICE_API_KEY"
mask_secret "$BOB_API_KEY"
mask_secret "$CAROL_API_KEY"
echo " ready"
}

# ── Ensure Go is on PATH ──────────────────────────────────────
if ! command -v go &>/dev/null && [ -x /usr/local/go/bin/go ]; then
export PATH="/usr/local/go/bin:$PATH"
Expand Down Expand Up @@ -246,6 +301,8 @@ fi
export HAIRPIN_API_URL=http://localhost:8181
export EXAMPLE_API_URL=http://localhost:8182

setup_api_keys

# ── Run test scripts ─────────────────────────────────────────
TESTS_DIR="$SCRIPT_DIR/tests"
PASSED=0
Expand Down
10 changes: 8 additions & 2 deletions test/seed-example.sql
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
\connect fmsgid

INSERT INTO address (address_lower, address, display_name)
VALUES ('@bob@example.com', '@Bob@example.com', 'Bob');
VALUES ('@bob@example.com', '@Bob@example.com', 'Bob')
ON CONFLICT (address_lower) DO UPDATE
SET address = EXCLUDED.address,
display_name = EXCLUDED.display_name;

INSERT INTO address (address_lower, address, display_name)
VALUES ('@carol@example.com', '@Carol@example.com', 'Carol');
VALUES ('@carol@example.com', '@Carol@example.com', 'Carol')
ON CONFLICT (address_lower) DO UPDATE
SET address = EXCLUDED.address,
display_name = EXCLUDED.display_name;
11 changes: 7 additions & 4 deletions test/seed-hairpin.sql
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
\connect fmsgid

INSERT INTO address (address_lower, address, display_name)
VALUES ('@alice@hairpin.local', '@ALICE@hairpin.local', 'Alice');
\connect fmsgid

INSERT INTO address (address_lower, address, display_name)
VALUES ('@alice@hairpin.local', '@ALICE@hairpin.local', 'Alice')
ON CONFLICT (address_lower) DO UPDATE
SET address = EXCLUDED.address,
display_name = EXCLUDED.display_name;
Loading
Loading