Skip to content

About

Routes inbound connections to different internal hosts based on the domain name the client asked for.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

glyphrouter

Routes inbound connections to different internal hosts based on the domain name the client asked for.

Built for a specific situation: one static public IP, and a consumer router that can forward ports but cannot route by hostname. Ports 80/443 get forwarded to a Raspberry Pi; glyphrouter reads the requested name off each connection and dispatches it to the right machine on the LAN.

How it routes

router forwards 80, 443, 2222  ──►  Pi running glyphrouter
                                      │
  :443  read the TLS ClientHello, extract SNI, match it exactly
          ├─ match   → splice raw bytes to that backend, never decrypting
          └─ no match → close the connection
  :80   read the Host header → 301 to https://
  :2222 static forward: the port is the routing key, no hostname involved

glyphrouter does not terminate TLS. It reads the SNI out of the handshake and then relays bytes untouched, so the backend presents its own certificate directly to the client. glyphrouter holds no private keys and is not in the trust chain. That is also why it needs no ACME, no certificate storage, and no HTTP parser on the HTTPS path.

Installing

One command on the target machine (Linux, x86_64 or aarch64) downloads the right binary from the latest release, verifies its checksum, and does the full setup below — system user, binary, config, systemd unit:

curl -fsSL https://raw.githubusercontent.com/Glyph-Software/glyph-router/main/install.sh | sudo bash

Re-running it upgrades in place; an existing config is never touched. On a fresh install it stops short of starting the service so you can edit the config first.

To uninstall — this removes the service and binary but keeps the config, state, and system user so a reinstall picks up where you left off (append -s -- --purge to remove those too):

curl -fsSL https://raw.githubusercontent.com/Glyph-Software/glyph-router/main/uninstall.sh | sudo bash

Building

Cross-compile from macOS for the Pi (aarch64). The cross image targets glibc 2.31, older than both Bookworm (2.36) and Trixie (2.41), so one binary runs on either:

cargo install cross --git https://github.com/cross-rs/cross
cross build --release --target aarch64-unknown-linux-gnu
file target/aarch64-unknown-linux-gnu/release/glyphrouter

Needs a container runtime (Docker Desktop, Colima, or OrbStack). On Apple Silicon the linux/arm64 image runs natively — if builds are unexpectedly slow, check Docker did not pull an amd64 image and start emulating.

Building on the Pi works too and avoids the toolchain entirely:

cargo build --release

Deploying

sudo useradd --system --no-create-home --shell /usr/sbin/nologin glyphrouter
sudo install -Dm755 glyphrouter /usr/local/bin/glyphrouter
sudo install -dm2770 -o root -g glyphrouter /etc/glyphrouter
sudo install -m640 -o root -g glyphrouter config.toml /etc/glyphrouter/config.toml
sudo install -Dm644 deploy/glyphrouter.service /etc/systemd/system/glyphrouter.service
sudo systemctl daemon-reload
sudo systemctl enable --now glyphrouter

The unit runs as an unprivileged user and gets CAP_NET_BIND_SERVICE as an ambient capability — the only privilege it needs. ExecStartPre runs --check, so a broken config fails the start instead of taking the service down.

Validate a config before restarting:

glyphrouter --check --config /etc/glyphrouter/config.toml

Watch it run:

journalctl -fu glyphrouter

Configuring

See glyphrouter.example.toml. The short version:

[server]
listen_https = "0.0.0.0:443"
listen_http = "0.0.0.0:80"

[[route]]
host = "nas.example.com"
backend = "192.168.1.61:443"

[[forward]]
name = "ssh"
listen_port = 2222
backend = "192.168.1.70:22"

Two things the schema deliberately cannot express:

  • A default or catch-all backend. An unrecognized hostname is always refused. Without this, a hostname router on a public IP is an open proxy into the LAN — an attacker does not need a DNS record, they just connect and set the SNI to whatever they like. nginx does the same thing with return 444, as does HAProxy by omitting default_backend.
  • A hostname as a backend. Backends are IP:port literals. This removes any path where client-influenced DNS picks the destination, and it prevents a proxy loop if the Pi also serves split-horizon DNS (below) pointing the domain back at itself.

Admin UI

An optional browser page for editing routes and forwards without touching the file by hand:

[admin]
listen = "127.0.0.1:8484"
password_hash = "$argon2id$v=19$m=19456,t=2,p=1$..."   # glyphrouter --hash-password

Open http://127.0.0.1:8484/ (or the LAN address you bound) and enter the password when the browser asks — the username does not matter. Edits are validated with exactly the checks a restart would run, applied to the live server — routes swap instantly, forward listeners are bound and released at runtime — and then written back to the config file, so a later restart comes up identically. Comments outside the [[route]]/[[forward]] blocks survive the rewrite. Server settings (listeners, timeouts, caps) are read-only in the UI and still require an edit and restart.

The page is served by the binary itself; there is nothing extra to deploy. Three deliberate restrictions, because this endpoint edits a routing table:

  • Password on every request (HTTP Basic auth). The config stores only an Argon2id hash, produced by glyphrouter --hash-password, so reading the config file does not yield the password. Changing the password means writing a new hash and restarting.
  • listen must be a loopback or private (RFC 1918 / ULA) address; a public or unspecified address is a startup error, not a warning. The password is a second lock, not a reason to face the internet — Basic auth over plain HTTP is only defensible on a link you already trust.
  • The Host header must match the admin address, which shuts down DNS rebinding: a malicious page that resolves its own hostname to this socket still sends that hostname in Host, and is refused.

To reach it from elsewhere, tunnel: ssh -L 8484:127.0.0.1:8484 pi.

There is also a JSON API under the same rules: GET /api/state returns the current tables, PUT /api/config replaces them (curl -u :password http://127.0.0.1:8484/api/state).

DNS

With a static IP there is no need for dynamic DNS. Point an A record at the apex and CNAME each service at one canonical name, so an IP change is a one-record edit:

example.com.        300  IN  A      203.0.113.10
home.example.com.   300  IN  A      203.0.113.10
nas.example.com.    300  IN  CNAME  home.example.com.
git.example.com.    300  IN  CNAME  home.example.com.

Prefer explicit records over a wildcard *.example.com. A wildcard makes every conceivable name resolve here, which promotes the fail-closed rule from a safeguard to the only thing between the internet and the LAN, and it costs you NXDOMAIN as a signal that a name is simply not deployed.

Hairpin NAT

LAN clients resolving the public IP will very likely fail, because most consumer routers apply the port-forward but not the return NAT. The symptom is characteristic: works on cellular, times out on Wi-Fi.

Fix it with split-horizon DNS on the same Pi, so internal clients go straight there. With dnsmasq:

# /etc/dnsmasq.d/glyphrouter.conf
# Matches the apex and every subdomain.
address=/example.com/192.168.1.50
# Never ask upstream about this zone.
local=/example.com/

Hand that out over DHCP. Traffic then goes client → Pi directly: one hop, no NAT, and it keeps working during a WAN outage.

TLS is unaffected. The certificate is bound to the hostname in SNI, not to the IP the client dialed, so nothing in glyphrouter changes and no certificate needs an IP SAN.

Note the loop hazard this creates — a wildcard pointing at the Pi means a hostname backend would resolve back to glyphrouter. That is why backend is an IP literal.

Devices using DoH/DoT or a hardcoded resolver bypass split-horizon DNS silently. Either block outbound 53/853 at the router or accept that a few devices hairpin.

Testing

cargo test

62 unit tests and 12 integration tests. The integration suite spins up real listeners on loopback and asserts both that traffic reaches the right backend and — more importantly — that it reaches no backend when it should be refused: unknown SNI, near-miss hostnames, absent SNI, plain HTTP on the TLS port, silent clients, and absolute-form/CONNECT requests on port 80.

No certificates are needed, because the passthrough path never decrypts: a test backend is just a socket that records what it received.

To confirm a real TLS handshake survives the proxy, run a backend holding its own certificate:

openssl req -x509 -newkey rsa:2048 -nodes -keyout key.pem -out cert.pem -days 1 -subj "/CN=nas.example.test"
openssl s_server -cert cert.pem -key key.pem -port 18443 -quiet

Point a config at 127.0.0.1:18443, then:

openssl s_client -connect 127.0.0.1:19443 -servername nas.example.test

The certificate shown should be the backend's, which is the whole point.

Status

Phase 1, which covers the original problem: SNI passthrough, port 80 redirect, static TCP forwards. Plus the admin UI: live editing of routes and forwards from a browser, persisted back to the config file.

Not implemented:

  • mode = "terminate" — terminating TLS here and speaking plain HTTP to a backend, for services that have no certificate of their own. The config parser rejects it rather than silently ignoring it. This is the large one: it brings ACME, an HTTP/1.1 + HTTP/2 proxy, hop-by-hop header handling, and request-smuggling defense.
  • PROXY protocol v2, so a passthrough backend can learn the real client IP. Currently a backend sees the Pi's address.
  • Graceful reload of [server] settings. Routes and forwards change live via the admin UI, but listeners, timeouts, and caps still need a restart, which drops in-flight connections; systemd socket activation would fix that alongside a reload signal.
  • Metrics.

Why not nginx, Caddy, HAProxy, or Traefik?

They would all solve this, and for TLS termination they would solve it better — Caddy in about six lines with automatic certificates. nginx does SNI passthrough with stream + ssl_preread, HAProxy with req.ssl_sni.

glyphrouter exists for the control and for a small auditable binary that does exactly one thing. Worth knowing what the tradeoff is rather than discovering it later.

About

Routes inbound connections to different internal hosts based on the domain name the client asked for.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages