Compare commits

...

2 Commits

Author SHA1 Message Date
2ro be15c78121 floonet-strfry: us-east production deploy bundle for the nm.floonet.dev name authority
Bundle that stands the bundled name authority up behind nginx at
https://nm.floonet.dev in paid-name mode wired to the on-box GoblinPay:

  * nm.floonet.dev.conf         nginx vhost mirroring the relay.floonet.dev
                                pattern (same listen IP + certbot webroot),
                                TLS -> 127.0.0.1:8193, sets X-Real-IP (which
                                the per-IP rate limiter keys off).
  * floonet-authority.service.d/10-us-east.conf
                                drop-in over the generic hardened unit: swaps
                                DynamicUser for the stable unprivileged goblin
                                account and relocates the DB into the
                                /opt/goblin tree (one backup root), inheriting
                                every other sandbox directive.
  * floonet-authority.env.example
                                FLOONET_PAY_MODE=name, GOBLINPAY_URL at the
                                loopback GoblinPay; the real GP token is filled
                                from goblinpay.env at deploy time, never here.
  * deploy.sh                   idempotent runbook: build on-box, install,
                                two-phase certbot (acme :80 -> cert -> :443),
                                start. Never touches goblin-nip05d or firewalld.
2026-07-03 03:15:38 -04:00
2ro fb62ed2bf2 floonet-strfry: add a NIP-98 header-minting example for the name authority
Operating a NIP-98-gated endpoint (register / unregister / quote) needs signed
kind-27235 Authorization headers, and there is no nak on the target hosts. This
example reuses the crate's existing nostr/base64/sha2 deps to mint a
"Nostr <base64-event>" header for curl/CI: generate a throwaway identity or
reuse one via NIP98_SK, sign over the method/path/body, print the header to
stdout. The u-tag is built from FLOONET_BASE_URL to match server verification.
2026-07-03 03:15:38 -04:00
5 changed files with 243 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Deploy / re-deploy the Floonet name authority on the us-east box behind nginx
# at https://nm.floonet.dev, in paid-name mode wired to the local GoblinPay.
#
# Idempotent: safe to re-run. Run as root on the box. It does NOT touch the
# goblin.st production name service (goblin-nip05d) and never toggles firewalld.
#
# Prereqs already satisfied on us-east (documented for reproducibility):
# * DNS: nm.floonet.dev A -> 167.17.77.8 (pdnsutil add-record floonet.dev nm A 14400 167.17.77.8)
# * Rust 1.95 toolchain; GoblinPay live on 127.0.0.1:8192 (GP_API_TOKEN in goblinpay.env)
set -euo pipefail
REPO_DIR="${REPO_DIR:-/opt/goblin/gpbuild/floonet-name-authority}" # crate checkout
DATA_DIR="/opt/goblin/floonet-authority"
BIN=/usr/local/bin/floonet-name-authority
ENV_FILE=/etc/floonet-authority.env
UNIT_SRC_DIR="$(cd "$(dirname "$0")/.." && pwd)" # deploy/
GP_ENV=/opt/goblin/goblinpay/goblinpay.env
DOMAIN=nm.floonet.dev
PORT=8193
echo "==> build (on-box, glibc-matched)"
( cd "$REPO_DIR" && . "$HOME/.cargo/env" 2>/dev/null || true; cargo build --release --locked )
install -m0755 "$REPO_DIR/target/release/floonet-name-authority" "$BIN"
echo "==> data dir"
install -d -m0750 -o goblin -g goblin "$DATA_DIR"
echo "==> env file (token pulled from GoblinPay's env, never echoed)"
if [ ! -f "$ENV_FILE" ]; then
GP_TOKEN="$(sed -n 's/^GP_API_TOKEN=//p' "$GP_ENV" | tr -d '\r\n')"
sed "s#__REPLACE_WITH_GP_API_TOKEN__#${GP_TOKEN}#" \
"$UNIT_SRC_DIR/us-east/floonet-authority.env.example" > "$ENV_FILE"
chown root:goblin "$ENV_FILE"; chmod 0640 "$ENV_FILE"
fi
echo "==> systemd unit + us-east drop-in"
install -m0644 "$UNIT_SRC_DIR/systemd/floonet-authority.service" /etc/systemd/system/
install -d -m0755 /etc/systemd/system/floonet-authority.service.d
install -m0644 "$UNIT_SRC_DIR/us-east/floonet-authority.service.d/10-us-east.conf" \
/etc/systemd/system/floonet-authority.service.d/
systemctl daemon-reload
echo "==> nginx: acme (:80) first, then certbot, then TLS (:443)"
VHOST=/etc/nginx/sites-available/$DOMAIN.conf
if [ ! -f /etc/letsencrypt/live/$DOMAIN/fullchain.pem ]; then
# Stand up a temporary :80-only vhost so the HTTP-01 webroot resolves.
cat > "$VHOST" <<EOF
server {
listen 167.17.77.8:80;
server_name $DOMAIN;
location /.well-known/acme-challenge/ { root /var/www/acme-challenge; }
location / { return 301 https://\$host\$request_uri; }
}
EOF
ln -sf ../sites-available/$DOMAIN.conf /etc/nginx/sites-enabled/$DOMAIN.conf
nginx -t && nginx -s reload
certbot certonly --webroot -w /var/www/acme-challenge -d $DOMAIN \
--key-type ecdsa --non-interactive --agree-tos -m hostmaster@floonet.dev
fi
# Full vhost (:80 redirect + :443 proxy).
install -m0644 "$UNIT_SRC_DIR/us-east/$DOMAIN.conf" "$VHOST"
ln -sf ../sites-available/$DOMAIN.conf /etc/nginx/sites-enabled/$DOMAIN.conf
nginx -t && nginx -s reload
echo "==> start the authority (paid-name mode)"
systemctl enable --now floonet-authority
sleep 1
systemctl --no-pager --full status floonet-authority | head -5
curl -fsS "http://127.0.0.1:$PORT/api/v1/health" && echo " <- local health ok"
echo "==> done: https://$DOMAIN"
@@ -0,0 +1,40 @@
# /etc/floonet-authority.env — us-east production template (nm.floonet.dev).
#
# Copy to /etc/floonet-authority.env, fill GOBLINPAY_TOKEN from
# /opt/goblin/goblinpay/goblinpay.env (GP_API_TOKEN), then:
# install -m0640 -o root -g goblin floonet-authority.env /etc/floonet-authority.env
# Keep the real file (with the token) OUT of git — this is only the template.
# --- Identity ---
# Names are name@nm.floonet.dev; the well-known is served at this host, so the
# @domain and BASE_URL host must both equal nm.floonet.dev (the authority
# refuses to start otherwise). BASE_URL is load-bearing: NIP-98 `u`-tags are
# verified against <BASE_URL><path>.
FLOONET_DOMAIN=nm.floonet.dev
FLOONET_BASE_URL=https://nm.floonet.dev
FLOONET_RELAYS=wss://relay.floonet.dev
FLOONET_NAMES_BIND=127.0.0.1:8193
# FLOONET_NAMES_DB is set by the systemd drop-in:
# /opt/goblin/floonet-authority/names.db
# --- Paid names via GoblinPay (Grin), admin-priced ---
# off | name | write. `name` = claiming name@domain costs FLOONET_NAME_PRICE_GRIN.
FLOONET_PAY_MODE=name
FLOONET_NAME_PRICE_GRIN=1
# GoblinPay runs on the same box (goblin-pay.service, 127.0.0.1:8192). The
# authority calls {GOBLINPAY_URL}/invoice and GET /invoice/{id} server-side
# with a Bearer token; payers still land on GoblinPay's public hosted pay_url
# (GP_PUBLIC_URL). Loopback avoids a needless public round-trip.
GOBLINPAY_URL=http://127.0.0.1:8192
GOBLINPAY_TOKEN=__REPLACE_WITH_GP_API_TOKEN__
# Optional: instant settlement instead of polling. If set, point a GoblinPay
# webhook at https://nm.floonet.dev/api/v1/goblinpay/webhook.
#GOBLINPAY_WEBHOOK_SECRET=
# --- Rate-limit ceilings (per X-Real-IP; nginx sets it from $remote_addr) ---
FLOONET_READ_RATE_MAX=120
FLOONET_READ_RATE_WINDOW_SECS=60
FLOONET_WRITE_RATE_MAX=10
FLOONET_WRITE_RATE_WINDOW_SECS=3600
RUST_LOG=info
@@ -0,0 +1,27 @@
# us-east production overrides for the generic hardened unit
# (deploy/systemd/floonet-authority.service).
#
# The box keeps every Goblin service's data under /opt/goblin (a single backup
# root) and runs them as the unprivileged `goblin` account, so we swap the
# generic unit's DynamicUser for a stable owner and relocate the writable path.
# The base unit's own comment sanctions exactly this ("If you need a stable
# owner for the data dir ... set User="). Every other hardening directive from
# the base unit (ProtectSystem=strict, NoNewPrivileges, ProtectHome, the
# @system-service syscall filter, RestrictAddressFamilies, ...) is inherited
# unchanged — goblin's home is /opt/goblin/nip05d, not under /home, so
# ProtectHome=yes stays safe.
[Service]
DynamicUser=no
User=goblin
Group=goblin
# The base unit ships StateDirectory=floonet-authority plus a matching
# /var/lib writable path. Clear both (empty assignment resets the list) and
# point everything at the /opt/goblin tree instead.
StateDirectory=
ReadWritePaths=
ReadWritePaths=/opt/goblin/floonet-authority
WorkingDirectory=/opt/goblin/floonet-authority
# Applied after the base unit, so this wins over the base Environment= line.
Environment=FLOONET_NAMES_DB=/opt/goblin/floonet-authority/names.db
+38
View File
@@ -0,0 +1,38 @@
# Floonet name authority — nm.floonet.dev
#
# TLS terminates here; the authority listens on 127.0.0.1:8193 and keys ALL
# per-IP rate limiting off X-Real-IP, so setting it from $remote_addr is
# SECURITY-CRITICAL: a missing value collapses every client into one bucket
# and defeats the limiter. Mirrors the relay.floonet.dev vhost (same listen
# IP, same certbot webroot, same header set) minus the WebSocket upgrade,
# since the authority is a plain JSON/REST service.
server {
listen 167.17.77.8:80;
server_name nm.floonet.dev;
location /.well-known/acme-challenge/ { root /var/www/acme-challenge; }
location / { return 301 https://$host$request_uri; }
}
server {
listen 167.17.77.8:443 ssl http2;
server_name nm.floonet.dev;
ssl_certificate /etc/letsencrypt/live/nm.floonet.dev/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/nm.floonet.dev/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
add_header X-Content-Type-Options "nosniff" always;
access_log /var/log/nginx/nm.floonet.dev.access.log;
error_log /var/log/nginx/nm.floonet.dev.error.log;
# Registration / quote bodies are tiny JSON; cap to keep abuse cheap.
client_max_body_size 16k;
location / {
proxy_pass http://127.0.0.1:8193;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
}
+67
View File
@@ -0,0 +1,67 @@
//! Mint a NIP-98 `Authorization: Nostr <base64-event>` header for calling this
//! authority's authenticated endpoints (register / unregister / quote) with a
//! plain HTTP client like `curl`. Handy for operators and CI: no external
//! nostr tooling required, since the crate already depends on `nostr`.
//!
//! Usage:
//! # generate a throwaway identity (prints its secret+pubkey to stderr)
//! cargo run --example nip98 -- GET /api/v1/name/alice
//!
//! # reuse an identity and sign over a request body
//! FLOONET_BASE_URL=https://nm.floonet.dev NIP98_SK=<64-hex-secret> \
//! cargo run --example nip98 -- POST /api/v1/register '{"name":"alice","pubkey":"<hex>"}'
//!
//! The header value is printed to stdout (nothing else), so it can be captured
//! straight into a curl invocation:
//!
//! AUTH=$(NIP98_SK=$SK cargo run -q --example nip98 -- POST /api/v1/register "$BODY")
//! curl -H "Authorization: $AUTH" -d "$BODY" https://nm.floonet.dev/api/v1/register
//!
//! The `u` tag is built from FLOONET_BASE_URL (default https://nm.floonet.dev),
//! which MUST equal the authority's configured base URL — that is what the
//! server verifies the signature's `u` tag against.
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp};
use sha2::{Digest, Sha256};
fn main() {
let mut args = std::env::args().skip(1);
let method = args
.next()
.expect("usage: nip98 <METHOD> <PATH> [BODY] (e.g. POST /api/v1/register '{...}')");
let path = args
.next()
.expect("usage: nip98 <METHOD> <PATH> [BODY] (e.g. POST /api/v1/register '{...}')");
let body = args.next().unwrap_or_default();
let base_url =
std::env::var("FLOONET_BASE_URL").unwrap_or_else(|_| "https://nm.floonet.dev".to_string());
let keys = match std::env::var("NIP98_SK") {
Ok(sk) if !sk.trim().is_empty() => Keys::parse(sk.trim()).expect("invalid NIP98_SK"),
_ => {
let k = Keys::generate();
eprintln!("generated secret (hex): {}", k.secret_key().to_secret_hex());
k
}
};
eprintln!("pubkey (hex): {}", keys.public_key().to_hex());
let url = format!("{base_url}{path}");
let mut tags = vec![
Tag::parse(["u", &url]).unwrap(),
Tag::parse(["method", &method]).unwrap(),
];
if !body.is_empty() {
let payload = hex::encode(Sha256::digest(body.as_bytes()));
tags.push(Tag::parse(["payload", &payload]).unwrap());
}
let event = EventBuilder::new(Kind::HttpAuth, "")
.tags(tags)
.custom_created_at(Timestamp::now())
.sign_with_keys(&keys)
.expect("sign NIP-98 event");
let b64 = base64::engine::general_purpose::STANDARD.encode(event.as_json());
println!("Nostr {b64}");
}