Skip to content

Latest commit

 

History

History
432 lines (345 loc) · 19.2 KB

File metadata and controls

432 lines (345 loc) · 19.2 KB

Architecture

Status

This document describes the first production architecture for fedify.com.es. The administrative account, host packages, and firewall were bootstrapped on July 14, 2026. The authenticated private beta was deployed the same day and passed its initial end-to-end and post-reboot checks. The operator approved anonymous public access later that day so Fedify CLI can use the service. The public cutover was deployed and passed its rollback, non-HTTP rejection, selective shutdown, idempotency, and post-reboot end-to-end checks. It uses a reviewed sish fork with protocol-level HTTP-only forwarding.

Purpose

fedify.com.es provides public HTTPS URLs for HTTP services running on a developer's local machine. A client opens an SSH reverse tunnel to the VPS. The VPS assigns a subdomain, terminates TLS, and forwards each request through that SSH connection.

The service is designed for the fedify tunnel command in @fedify/cli. Fedify CLI uses @hongminhee/localtunnel to invoke the system SSH client with remote forwarding, so the server must speak the same protocol without requiring a separate client binary.

Fedify CLI will own the fedify.com.es service definition and pass it to openTunnel() at runtime through its custom services registry. The service will not be added to the built-in service registry in @hongminhee/localtunnel.

System overview

flowchart TB
    dns["Cloudflare DNS<br/>fedify.com.es and *.fedify.com.es"]
    public_client["Public client"]
    local_app["Local application<br/>SSH client with pinned sish host key"]
    operator["Operator workstation"]

    subgraph vps["Ubuntu 26.04 VPS"]
        direction TB
        sish_ssh["sish SSH endpoint<br/>:2222"]
        sish_http["sish HTTP endpoint<br/>:80, redirects to HTTPS"]
        sish_https["sish HTTPS endpoint<br/>:443, wildcard TLS"]
        openssh["OpenSSH<br/>:22"]

        sish_ssh -. "Registers route" .-> sish_https
    end

    public_client -->|"DNS lookup"| dns
    dns -. "A records resolve to VPS" .-> sish_http
    dns -. "A records resolve to VPS" .-> sish_https
    public_client -->|"HTTP request"| sish_http
    public_client -->|"HTTPS request"| sish_https
    local_app ==>|"Verified SSH reverse tunnel"| sish_ssh
    sish_https ==>|"Request and response through tunnel"| local_app
    operator -->|"Ansible over administrative SSH"| openssh
Loading

The thick arrows represent the SSH reverse tunnel and the traffic carried inside it. sish owns ports 80, 443, and 2222. The operating system's OpenSSH daemon continues to own port 22.

Component choices

sish

sish is an SSH server built for HTTP, HTTPS, WebSocket, and TCP reverse forwarding. This deployment uses only its HTTP and HTTPS path. It fits the SSH command shape that @hongminhee/localtunnel uses on behalf of Fedify CLI:

ssh -p 2222 \
  -o StrictHostKeyChecking=yes \
  -o UserKnownHostsFile=/path/to/fedify.com.es.known_hosts \
  -o GlobalKnownHostsFile=/path/to/fedify.com.es.known_hosts \
  -o KnownHostsCommand=none \
  -o VerifyHostKeyDNS=no \
  -o CheckHostIP=no \
  -o UpdateHostKeys=no \
  -R 80:localhost:8000 fedify.com.es

The public service does not authenticate tunnel clients. The client still verifies the server against the pinned sish host key. sish assigns a random 16-character subdomain and ignores user-selected names. The deployed fedify-dev/sish build adds an http-only setting that rejects TCP and alias remote forwards before creating a listener. It also lets this deployment disable HTTP access logs that would otherwise contain complete request URIs. The same reviewed fork can serve a static directory on the exact root domain, which provides the public landing page without exposing its files through tunnel subdomains.

The command uses a dedicated known_hosts file containing the pinned sish host public key for [fedify.com.es]:2222. @hongminhee/localtunnel 0.5.0 exposes the equivalent trust configuration through Service.knownHosts, whose type is Readonly<Record<string, readonly string[]>>. The Fedify-owned service definition supplies the same host pattern and one or more verified public keys. When knownHosts is present, localtunnel enables strict host-key checking, isolates the connection from other SSH trust sources, and removes its private temporary known_hosts file after startup failure or tunnel closure. Omitting knownHosts retains legacy non-strict behavior, so it is required for this service.

systemd

systemd supervises the sish process, applies its resource and filesystem restrictions, and restarts it after failures. sish runs as a dedicated user. The unit grants CAP_NET_BIND_SERVICE; it does not run as root merely to bind ports below 1024.

Ansible and mise

Ansible declares host packages, users, directories, firewall rules, artifacts, configuration, certificates, and systemd state. It connects through the administrative SSH service and requires no resident deployment agent.

mise pins the tools used from the operator workstation and presents a small command surface for checks, previews, applies, and verification. Ansible remains the deployment engine.

Certbot and Cloudflare DNS

Certbot obtains one certificate containing both names:

  • fedify.com.es
  • *.fedify.com.es

A wildcard certificate requires a DNS challenge. Certbot uses a Cloudflare API token restricted to Zone:Zone:Read and Zone:DNS:Edit for this zone. The plugin lists the zone to find its ID before it creates the short-lived ACME TXT record. The token is stored on the target with root-only permissions and encrypted at rest in the repository if Ansible Vault manages it.

DNS and ingress

The apex and wildcard A records resolve directly to the VPS during the first deployment. They remain DNS-only while the service is brought up and tested.

Changing the wildcard record to Cloudflare-proxied is a separate design change. If that happens, the sish SSH endpoint needs a specific DNS-only hostname, such as ssh.fedify.com.es, because Cloudflare's HTTP proxy must not sit in front of port 2222. Proxying also changes the client IP path, caching behavior, TLS layers, and abuse controls.

Request and tunnel flow

  1. The client obtains the pinned sish host public key through the repository or another authenticated distribution channel.
  2. The client connects to sish on TCP port 2222, verifies that host key, and requests a remote HTTP forward to a local port.
  3. sish accepts the unauthenticated SSH connection but permits only an HTTP or HTTPS proxy remote forward.
  4. sish allocates a random subdomain under fedify.com.es and prints the public URL to the SSH session.
  5. A public client resolves the subdomain through the wildcard A record and connects to port 443 on the VPS.
  6. sish terminates TLS with the wildcard certificate and selects the matching live tunnel from the HTTP Host header.
  7. The request and response travel through the existing SSH connection to the local application.
  8. When the SSH connection closes, sish removes the route.

HTTP requests on port 80 redirect to the equivalent HTTPS URL. The sish configuration sets force-all-https: true; enabling the HTTPS listener alone does not enable this redirect. The service does not forward cleartext public traffic to a tunnel.

Host layout

The intended target filesystem is:

flowchart TB
    host["Ubuntu VPS filesystem"]

    host --> opt["/opt/sish/"]
    opt --> releases["releases/&lt;version&gt;/sish<br/>Versioned, checksum-verified binary"]
    opt --> current["current<br/>Symlink to releases/&lt;version&gt;"]

    host --> etc_sish["/etc/sish/"]
    etc_sish --> config["config.yml<br/>Non-secret sish configuration"]
    etc_sish --> ssl["ssl/"]
    ssl --> tls_releases["releases/&lt;deployment&gt;/"]
    tls_releases --> cert["fedify.com.es.crt<br/>Deployed full certificate chain"]
    tls_releases --> key["fedify.com.es.key<br/>Deployed TLS private key"]
    ssl --> tls_current["current<br/>Symlink to releases/&lt;deployment&gt;"]

    host --> state["/var/lib/sish/"]
    state --> host_keys["keys/<br/>Persistent sish SSH host keys"]
    state --> authorized_keys["authorized_keys/<br/>Private-beta client public keys"]

    host --> letsencrypt["/etc/letsencrypt/<br/>Certbot account and renewal state"]
    host --> unit["/etc/systemd/system/sish.service"]
Loading

The sish https-certificate-directory setting points to /etc/sish/ssl/current. sish 2.23.0 scans that directory for *.crt files and pairs each one with a same-basename .key file, so the active release exposes fedify.com.es.crt and fedify.com.es.key. The private key is readable by root and the sish group, but not by other users. The configuration and certificate are read-only to the sish process. Persistent runtime state lives under /var/lib/sish.

Certificate lifecycle

Initial issuance should use the Let's Encrypt staging environment first, with a certificate name that cannot be mistaken for production state. Once the DNS automation succeeds, Certbot requests the production apex and wildcard certificate under the canonical fedify.com.es certificate name.

On initial issuance and renewal, a deploy hook performs these actions:

  1. Create a uniquely named directory under /etc/sish/ssl/releases. Copy Certbot's fullchain.pem to fedify.com.es.crt and privkey.pem to fedify.com.es.key with the expected owner and modes.
  2. Parse both files, confirm that the certificate covers the expected names and validity period, and verify that its public key matches the private key.
  3. Create a replacement current symlink and atomically rename that one directory entry over /etc/sish/ssl/current. Both credential paths then switch to the validated release as one unit.
  4. Restart sish through systemd and verify that it becomes active.
  5. If the restart fails during an update, atomically restore the previous symlink and restart sish with the previous credential release.
  6. Leave current and the running process untouched when staging or validation fails. Retain the previous release until the new release is active.

The standard Certbot systemd timer schedules renewals. Renewal configuration and the deploy path are verified with certbot renew --dry-run --run-deploy-hooks. Certbot uses the current active certificate, not the temporary test certificate, when it invokes a deploy hook during a dry run. This check therefore exercises the hook and service restart without promoting the test certificate.

Deployment and release model

Ansible downloads a pinned sish release, verifies its checksum, and installs it under /opt/sish/releases/<version>. Updating the current symlink and restarting systemd activates a release. Reapplying the same playbook should produce no change.

A deployment follows this path:

flowchart LR
    revision["Git revision"] --> deploy_check["mise run deploy:check"]
    deploy_check --> review["Operator review"]
    review --> deploy["mise run deploy"]
    deploy --> handlers["Ansible handlers"]
    handlers --> verify["mise run verify"]
    verify --> smoke_test["End-to-end tunnel smoke test"]
Loading

Changing the configured version back to a retained release provides the basic rollback path. Certificate and persistent SSH state are independent of the binary release.

Security boundaries

The administrative OpenSSH service and the public sish SSH service are separate listeners with separate host keys and purposes. Root SSH is used only for bootstrap. Routine deployment uses the named fedify-deploy account after its SSH and sudo paths have been tested.

Ansible manages packages, users, firewall policy, systemd units, binaries, and private certificate material, so that deployment principal is root-equivalent. Its passwordless sudo rule is limited to the dedicated account, but not to a misleading command-pattern allowlist: Ansible modules execute Python code as root, and such a list would not form a security boundary. Access is instead limited by the account's single operator-managed SSH key. A less privileged model would require a separately designed root-owned deployment interface.

The sish host key is also the client trust anchor. After the persistent key is created on the VPS, the operator derives its public key through the authenticated administrative path and compares its SHA-256 fingerprint with the VPS console or another independent channel. The verified public key is published as a [fedify.com.es]:2222 entry in this repository. ssh-keyscan may collect the presented key for comparison, but its output is never trusted without matching the pinned key.

Manual private-beta clients use that entry with StrictHostKeyChecking=yes, a dedicated file as both UserKnownHostsFile and GlobalKnownHostsFile, and disabled KnownHostsCommand, VerifyHostKeyDNS, CheckHostIP, and UpdateHostKeys settings. Fedify CLI supplies the same trust anchor through localtunnel 0.5.0's knownHosts map. In either path, the hostname-and-port entry is the only trust source and a mismatch must stop the SSH connection before a remote forward is established. Host-key rotation uses a staged client update: publish the new pin alongside the old one, wait for client adoption, switch the server key, and remove the old pin later. Clients that have not received the new pin fail closed.

Anonymous users can host phishing pages, malware, prohibited content, or traffic that harms the domain's reputation. The public design therefore uses random names, HTTP-only forwarding, per-source SSH connection rate limiting, systemd resource limits, lifecycle logging, and a documented abuse response.

sish 2.23.0 does not provide one setting that rejects every non-HTTP SSH remote forward. In particular, disabling TCP aliases does not disable an ordinary TCP forward, and a client can request an automatically allocated port with -R 0. The fedify-dev/sish fork classifies each remote-forward request and, when http-only is enabled, rejects TCP and alias requests before it creates even a Unix listener. The following containment remains as defense in depth:

  • Set force-tcp-address: true, tcp-address: 127.0.0.1, and bind-random-ports: false so any TCP listener that sish does create is not publicly bound.
  • Apply SocketBindAllow=tcp:2222, SocketBindAllow=tcp:80, SocketBindAllow=tcp:443, and SocketBindDeny=any to the sish systemd unit as defense in depth. Verify that the target kernel and systemd enforce the policy; systemd's socket-bind filter is not the sole security boundary.
  • Keep UFW limited to public TCP ports 22, 2222, 80, and 443. Rate-limit new connections to port 2222 per source address.

Leave port-bind-range unset. With port-bind-range: "0", a denied raw TCP bind made sish 2.23.0 retry port 0 recursively until the process exhausted its stack. The initial negative test found this failure. Without that setting, the same systemd denial produces a failed SSH remote-forward request and leaves the running process intact.

Negative tests must confirm that explicit TCP forwards, aliases, and -R 0 receive an SSH-level rejection without creating a listener or restarting sish. The systemd socket policy and UFW remain independent barriers if a future sish change bypasses the protocol check.

No secret crosses into the public sish session. The Cloudflare token is used only by Certbot and is never placed in the sish environment.

Availability and operations

The first architecture has one VPS and one sish process. It has no high availability or centralized coordination. DNS, certificate issuance, and existing SSH sessions are external dependencies.

The minimum operating signals are:

  • systemd service state and restart count;
  • journal errors and authentication failures;
  • listener availability on ports 2222, 80, and 443;
  • certificate expiration and renewal failures;
  • disk, memory, and connection usage;
  • an end-to-end tunnel probe from outside the VPS.

The initial beta baseline after two hours of uptime was 48 MB peak memory, eight tasks, no active tunnel connections, and no service restarts. The public unit sets MemoryHigh=512M, MemoryMax=768M, CPUQuota=150%, LimitNOFILE=4096, and TasksMax=512. UFW rate-limits new SSH tunnel connections from one source, and sish closes SSH sessions that do not create a forward within ten seconds. These limits leave room for the operating system on the 2 GiB VPS while bounding one process under abusive load.

sish writes SSH connection addresses, allocated subdomains, rejected forwards, and tunnel start and stop events to the systemd journal. HTTP access logging is disabled because its upstream format includes complete request URIs and query strings. Journald retains at most 256 MB and removes entries older than 14 days. Public abuse reports use the repository's issue form. The apex serves a small, static landing page with service limits, client documentation, source code, and the abuse-report link.

The landing page is deployed under /etc/sish/landing and served directly by the reviewed sish fork when the request host is exactly fedify.com.es. sish continues to own ports 80 and 443, so this does not add a reverse proxy, another public listener, or a second long-running service. Port 80 redirects the apex request to HTTPS before the static file is served. Random tunnel subdomains continue through the existing proxy path and cannot read files from the landing directory.

Backups must cover Ansible Vault data, Certbot account state, sish host keys, and the repository. The service can otherwise be rebuilt from a clean Ubuntu VPS by reapplying the playbooks.

Out of scope for the first deployment

  • Multiple sish nodes or automatic failover.
  • TCP and SNI tunnels.
  • User-selected subdomains or custom domains.
  • A web administration console.
  • Cloudflare proxying of public tunnel traffic.
  • Further changes to @hongminhee/localtunnel, or adding fedify.com.es to its built-in service registry. Version 0.5.0 already provides the required host-key pinning API.

Upstream references