Compare commits
2 Commits
c5ca6860d7
...
be15c78121
| Author | SHA1 | Date | |
|---|---|---|---|
| be15c78121 | |||
| fb62ed2bf2 |
Executable
+71
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
Reference in New Issue
Block a user