Complete private gateway and local website publishing UAT

This commit is contained in:
archipelago
2026-10-08 18:10:41 -04:00
parent 768e828246
commit 3ab4162a8b
38 changed files with 2232 additions and 86 deletions
+72
View File
@@ -0,0 +1,72 @@
# Owned public-web gateway admission
`policy.py` implements the frp server-plugin contract for an operator-owned
HTTPS passthrough gateway. The node-side app installs from the trusted catalogue; an operator provisions
the gateway separately and supplies the private enrollment file to Setup.
Run it bound to loopback alongside frps. Configure **all** operations `Login`,
`NewProxy`, `Ping`, `NewWorkConn`, and `NewUserConn`; omitting them weakens
revocation. Require TLS on frps and pin the gateway CA on every client. Retain
frp token authentication with `HeartBeats` and `NewWorkConns` additional scopes.
Do not publish its enrollment file, client config, or transport credentials.
The 0600 enrollment JSON maps a node name to `enabled`, the SHA-256 of a random
32-byte-or-longer `enrollment_token`, and exact lowercase `domains`. Set frpc
`user` to the node name and `metadatas.enrollment_token` to that token. Proxies
must be HTTPS with one assigned domain. TCP/UDP, wildcard subdomains, shared
proxy groups, and gateway-side content rewrites are refused. The node terminates
website TLS and owns its website keys. The gateway still observes SNI and traffic
metadata; passthrough is not an anonymity service.
Replace the policy file atomically to enroll, disable or rotate a node. Every
request reloads it; missing, malformed or nonprivate files fail closed. Disabling
an enrollment denies new connections and subsequent heartbeats. Already forwarded
bytes cannot be recalled; do not promise immediate termination of every stream.
The process never logs tokens or request bodies. Run behind a dedicated service
account with filesystem and process limits in the final deployment.
Tests: `python3 -m unittest discover -s tests/public-web-gateway -v`.
Contract references:
- https://gofrp.org/en/docs/features/common/server-plugin/
- https://gofrp.org/en/docs/features/common/network/network-tls/
- https://github.com/fatedier/frp/blob/v0.71.0/pkg/auth/token.go
The isolated Yaya qualification uses 17400 and14443, preserving existing public
sites. Its private certificate verifies TLS passthrough and ownership, not public
ACME issuance. Production ACME requires an appropriate public 443 route.
## Enroll a node
On the gateway, use the existing private frps JSON configuration and public CA
certificate. Keep the admission listener on loopback. For example:
```sh
python3 enroll.py --frps-config /etc/archy-gateway/frps.json \
--policy /etc/archy-gateway/enrollments.json \
--ca /etc/archy-gateway/gateway.crt \
--host gateway.example.com --tls-server-name gateway.example.com \
--name my-node --domain www.example.com \
--output /secure/path/my-node-enrollment.json
```
Repeat `--domain` for separately assigned website/app names. Existing enrollments
require explicit `--rotate`; use a new output filename. Transfer the file privately
to the node owner. In Setup → Allow external connections → Public web, install
Public Web Router, choose the file, review the gateway/domains, and confirm.
Then connect a published website or a guest-enabled app to an assigned domain.
Neither importing the file nor connecting an app grants a guest token.
Set each public DNS A/AAAA record to the gateway's reachable public address.
The gateway needs its frps control port and a dedicated TCP 443 passthrough
listener. Public certificate issuance cannot be tested by pointing DNS at a
private LAN address or by using our isolated 14443 test port. If 443 already
serves other sites, retain that proxy and use a separately provisioned IP/path;
do not replace the existing listener blindly. Run frps and the policy as
persistent supervised services before production use. The Yaya qualification
services are intentionally isolated test services, not a production deployment.
For revocation, atomically replace the private policy with the node's `enabled`
set to false. For local disconnect, use Setup; it removes enrollment/routes while
preserving local certificates and drafts. Removing a route does not erase copies
of content that visitors previously downloaded.
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""Create a private node enrollment for an existing operator-owned frps gateway.
Reads frps token configuration without printing credentials. The admission policy
is replaced atomically; an existing node requires --rotate to replace its token.
The exported enrollment is for Setup's file picker, never Nostr or public storage.
"""
import argparse
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import secrets
import ssl
import fcntl
import tempfile
from policy import DOMAIN, NAME
def atomic(path, value):
path.parent.mkdir(parents=True, exist_ok=True)
fd, stage = tempfile.mkstemp(prefix='.' + path.name, dir=path.parent)
try:
with os.fdopen(fd, 'w') as f:
os.fchmod(f.fileno(), 0o600)
json.dump(value, f); f.flush(); os.fsync(f.fileno())
os.replace(stage, path)
directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
try: os.fsync(directory)
finally: os.close(directory)
finally:
Path(stage).unlink(missing_ok=True)
def main():
os.umask(0o077)
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--frps-config', type=Path, required=True)
p.add_argument('--policy', type=Path, required=True)
p.add_argument('--ca', type=Path, required=True)
p.add_argument('--host', required=True)
p.add_argument('--tls-server-name', required=True)
p.add_argument('--name', required=True)
p.add_argument('--domain', action='append', required=True)
p.add_argument('--output', type=Path, required=True)
p.add_argument('--rotate', action='store_true')
args = p.parse_args()
if not NAME.fullmatch(args.name) or len(args.domain) > 32 or any(not DOMAIN.fullmatch(d) for d in args.domain):
p.error('Use a lowercase node name and exact lowercase domains')
for host in (args.host, args.tls_server_name):
try: ipaddress.ip_address(host)
except ValueError:
if not DOMAIN.fullmatch(host): p.error('Invalid gateway host or TLS name')
if args.output.exists(): p.error('Enrollment output already exists; choose a new private file')
if args.frps_config.stat().st_mode & 0o077: p.error('frps configuration must be private (0600)')
server = json.loads(args.frps_config.read_text())
auth = server.get('auth', {})
if auth.get('method') != 'token' or not isinstance(auth.get('token'), str) or len(auth['token']) < 32:
p.error('Gateway requires a strong frps transport token')
if server.get('transport', {}).get('tls', {}).get('force') is not True:
p.error('Gateway must require TLS')
required = {'Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn'}
if not any(required.issubset(plugin.get('ops', [])) for plugin in server.get('httpPlugins', [])):
p.error('Configure the admission plugin for every required operation first')
args.policy.parent.mkdir(parents=True, exist_ok=True)
policy_lock = args.policy.with_suffix(args.policy.suffix + ".lock").open("a")
os.chmod(policy_lock.name, 0o600)
fcntl.flock(policy_lock, fcntl.LOCK_EX)
existing = {}
if args.policy.exists():
if args.policy.stat().st_mode & 0o077: p.error('Admission policy must be private (0600)')
existing = json.loads(args.policy.read_text())
if not isinstance(existing, dict): p.error('Invalid admission policy')
if args.name in existing and not args.rotate: p.error('Node already enrolled; use --rotate explicitly')
for name, entry in existing.items():
if name != args.name and set(entry.get('domains', [])) & set(args.domain):
p.error('A domain is already assigned to another enrollment')
ca = args.ca.read_text()
if len(ca) > 16384 or not ca.startswith('-----BEGIN CERTIFICATE-----') or 'PRIVATE KEY' in ca:
p.error('Supply only the public gateway CA certificate')
try: ssl.create_default_context().load_verify_locations(cadata=ca)
except ssl.SSLError: p.error("Invalid gateway CA certificate")
token = secrets.token_hex(32)
enrollment = {'host': args.host, 'port': server['bindPort'], 'node_id': args.name,
'tls_server_name': args.tls_server_name, 'transport_token': auth['token'],
'enrollment_token': token, 'ca_pem': ca, 'domains': args.domain}
# Save the recoverable private output before changing admission. A failed
# policy write leaves a file that is not yet enrolled, never a lost token.
atomic(args.output, enrollment)
existing[args.name] = {'enabled': True, 'token_sha256': hashlib.sha256(token.encode()).hexdigest(), 'domains': args.domain}
atomic(args.policy, existing)
print('Private enrollment written. Import it in Setup; do not publish it.')
if __name__ == '__main__': main()
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Local frps admission plugin. Reload enrollments for every request.
The enrollment file is private operator configuration, never a public catalogue.
Transport must require TLS; the node pins the gateway CA. No bearer value is
logged. An unavailable/malformed policy rejects requests, including heartbeats.
"""
import argparse
import hashlib
import hmac
import json
import re
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlsplit
OPS = {'Login', 'NewProxy', 'Ping', 'NewWorkConn', 'NewUserConn'}
NAME = re.compile(r'[a-z0-9][a-z0-9-]{0,47}\Z')
DOMAIN = re.compile(r'(?=.{1,253}\Z)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}\Z')
def authorize(op, content, enrollments):
if op not in OPS or not isinstance(content, dict):
return False
user = content if op == 'Login' else content.get('user')
if not isinstance(user, dict):
return False
name = user.get('user')
if not isinstance(name, str) or not NAME.fullmatch(name):
return False
entry = enrollments.get(name)
if not isinstance(entry, dict) or entry.get('enabled') is not True:
return False
metas = user.get('metas', {})
token = metas.get('enrollment_token') if isinstance(metas, dict) else None
expected = entry.get('token_sha256')
if not isinstance(token, str) or not 32 <= len(token) <= 256:
return False
if not isinstance(expected, str) or not re.fullmatch('[a-f0-9]{64}', expected):
return False
if not hmac.compare_digest(hashlib.sha256(token.encode()).hexdigest(), expected):
return False
domains = entry.get('domains')
if not isinstance(domains, list) or not domains or len(domains) > 32:
return False
if any(not isinstance(d, str) or not DOMAIN.fullmatch(d) for d in domains):
return False
if op in {'NewProxy', 'NewUserConn'}:
# frpc prefixes proxy names with its configured user.
proxy = content.get('proxy_name', '')
if not isinstance(proxy, str) or not proxy.startswith(name + '.'):
return False
if not NAME.fullmatch(proxy[len(name) + 1:]):
return False
if content.get('proxy_type') != 'https':
return False
if op == 'NewProxy':
requested = content.get('custom_domains')
if not isinstance(requested, list) or len(requested) != 1 or requested[0] not in domains:
return False
# No arbitrary TCP ports, wildcard subdomains, shared groups or routing
# rewrites. TLS terminates on the node; gateway only forwards SNI.
if any(content.get(k) for k in ('remote_port', 'subdomain', 'group', 'group_key', 'locations', 'host_header_rewrite', 'headers', 'http_user', 'http_pwd', 'multiplexer')):
return False
return True
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_):
pass
def do_POST(self):
accepted = False
try:
self.connection.settimeout(3)
url = urlsplit(self.path)
query = parse_qs(url.query, strict_parsing=True)
size = int(self.headers.get('Content-Length', '0'))
if url.path != '/handler' or query.get('version') != ['0.1.0'] or len(query.get('op', [])) != 1 or not 0 < size <= 65536:
raise ValueError('Invalid request')
if self.headers.get('Transfer-Encoding'):
raise ValueError('Streaming request unsupported')
config = self.server.policy_path
if config.stat().st_mode & 0o077:
raise ValueError('Enrollment file must be private')
raw = config.read_bytes()
if len(raw) > 1024 * 1024:
raise ValueError('Oversized policy')
enrollments = json.loads(raw)
request = json.loads(self.rfile.read(size))
accepted = authorize(query['op'][0], request['content'], enrollments)
except (OSError, ValueError, TypeError, KeyError, AttributeError):
pass
body = json.dumps({'reject': not accepted, 'unchange': True, 'reject_reason': '' if accepted else 'Enrollment or route is not authorized'}).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--enrollments', type=Path, required=True)
parser.add_argument('--port', type=int, default=17700)
args = parser.parse_args()
server = ThreadingHTTPServer(('127.0.0.1', args.port), Handler)
server.policy_path = args.enrollments
server.serve_forever()
if __name__ == '__main__':
main()