Caddy module: long-lived edge server — TLS admission, dynamic host routing, registry-driven upstreams, heartbeats, on-demand TLS asks, edge-terminated WebSocket fan-out, zero-config LAN presence over mDNS, an edge authentication wall, registered static files and directory browsing, X-Sendfile offload, and bounded app-scoped access observation, driven by a JSON control API.
Module names: janus (app) · http.handlers.janus (HTTP handler) · tls.permission.janus (on-demand TLS permission) · caddy.logging.encoders.janus (access encoder)
Janus is a Caddy module. Caddy provides listeners, HTTP/1–3, TLS, and ACME. Janus provides the inward face: a memory-resident registry and engines driven by the /1.0 JSON API. Cold Caddyfile config sets capabilities (such as control reachability) and which sites admit traffic into Janus; hot /1.0 calls decide how admitted hosts map to upstreams, health, certificate allowlisting, and realtime fan-out.
{
janus {
ping
control local
hub
mdns
files {
precompressed
}
}
}
app.example.com {
log {
output file /var/log/janus/access.json
format janus
}
janus
}Registry, data plane, and hub state live in pooled process state: Caddy config reloads preserve registrations and eligible hub WebSockets. A committed policy change can close connections on hosts where hub admission was disabled. Janus control state is memory-only — a restart empties the registry and tenants re-register. See Caddyfile.minimal for the operator-facing starting point, Caddyfile.example for the full capability walkthrough, and docs/ for the contracts.
This repository is a Go module. Caddy is a dependency, not a git submodule. The janus binary is built from cmd/janus, which compiles stock Caddy, this module, and the Route 53 DNS provider into one executable; the module also loads into any custom Caddy build like any other plugin.
License: Apache License 2.0 (same family as Caddy’s source).
Every capability in Janus has a famous neighbor; the mix has none. The
novel contract is admission itself: the app announces itself to its
own edge. A tenant POSTs its name and hosts to /1.0/apps,
heartbeats, and publishes worker unix sockets — and with that one
registration it has TLS and ACME, HTTP/1–3, host routing, health-aware
least-conn balancing, edge-terminated WebSocket fan-out, and LAN
presence, with zero per-app edge
configuration. That is the router contract of a PaaS — the shape of
Fly's proxy or Heroku's router — in one self-hosted binary, with the
running app as the source of truth and heartbeat reaping as the
garbage collector: a heartbeat-leased app that stops heartbeating ceases to
exist at the edge. The nearest historical relative is Phusion
Passenger, the app-aware web server — but Passenger manages processes
for its supported languages and learns about apps from the web
server's own config; Janus speaks a JSON API and learns about apps
from the apps.
Each neighbor is better at being itself. The honest comparison:
| Neighbor | What it does better | What Janus does instead |
|---|---|---|
| Traefik | Provider ecosystem — routing derived from Docker labels, Kubernetes Ingress/CRD/Gateway API, Consul, Nomad, ECS — plus a deep middleware catalog and a community that dwarfs this module | Apps register themselves over plain HTTP; no container runtime, orchestrator, or label convention required — a bare process on a unix socket is a first-class tenant |
| Pushpin | Protocol range for realtime: HTTP streaming, long-polling, SSE, SockJS, WebSocket-over-HTTP against a stateless backend | The same architectural instinct — connections held at the proxy, tenant on plain HTTP — plus registry integration (an app's hub lives and dies with its registration) and a validated per-frame directive grammar executed at the edge |
| Caddy | Everything it already is: listeners, ACME, HTTP/1–3, the Caddyfile, the admin API, the module ecosystem — all of it remains available beside Janus in the same process | A second axis of dynamism: Caddy's admin API pushes operator config; the Janus registry pulls state from running apps, and a registration never touches the config |
Traefik answers "what is my orchestrator running?"; Janus answers "what is announcing itself to me right now?" — the second question needs no infrastructure underneath the app. The performance ledger holds every number with raw provenance — sustained hub fan-out is ~0.4M deliveries/s, roughly independent of room size, with zero socket drops across a config reload. Pushpin proved the edge-held-connection pattern at Fastly scale; the hub is that pattern folded into the registry. And Caddy is not a competitor at all: Janus is a Caddy module, and every stock directive works unchanged next to it.
Janus is not:
- a reverse-proxy configuration language. There is no parallel grammar — capabilities are normal Caddyfile directives with legal values, defaults, and hard errors, same as stock Caddy.
- a persistent store. Janus does not persist registry, session, or hub state; tenants re-register after a restart. Caddy still writes configured certificate storage and log outputs.
- a container orchestrator. Tenants supervise their own worker processes; Janus routes to them. Janus's service commands manage the edge itself, and browse may launch bounded renderer subprocesses.
- a service mesh. One edge, inward-facing unix sockets — no sidecars, no inter-service mTLS fabric, no traffic policy between tenants.
The same binary spans the whole distance: janus.local answering a
phone on a bare LAN with no DNS and no client install, and a
production edge with ACME certificates and HTTP/3 — the difference is
only Caddyfile. And with rip-server as the tenant, the same app file
that runs standalone on a laptop registers, heartbeats, and pools
behind Janus in production, unchanged.
- A supported prebuilt platform, or Go 1.26 or later
(go.dev/dl) for source builds;
go.modpins thego1.27.2toolchain, which Go fetches on its own - A Caddyfile that loads Janus; start with
Caddyfile.minimal
brew update
brew install go # or: brew upgrade go
go version # confirm current stableFollow go.dev/doc/install. On macOS Apple Silicon, that is typically the darwin-arm64 archive or .pkg from go.dev/dl. Ensure $(go env GOPATH)/bin is on your PATH so tools installed with go install are available.
Cold capabilities land in order. Each step stands alone before the next is added.
| # | Capability | What it does | Doc |
|---|---|---|---|
| 1 | ping | Proves module load, TLS, site admission, cascade | capability-ping |
| 2 | control | Opens the /1.0 listeners (internal/local/public) |
capability-control |
| 3 | hub | Per-app WebSocket fan-out terminated at the edge; tenants observe and steer over HTTP | capability-hub |
| 4 | mdns | LAN presence: janus.local + per-app .local names over multicast DNS, and the read-only status front door |
capability-mdns |
| 5 | auth | URL-prefix gates for auth-less apps: shared users, per-gate allow lists, host-wide session, Remote-User strip-and-inject |
capability-auth |
| 6 | files | Registered ordered roots, transparent precompressed sidecars, SPA shells, and directory-gated site hosts | capability-files, precompressed extension |
| 7 | sendfile | Always-on final upstream X-Sendfile transformation with validators, ranges, and streaming |
capability-sendfile |
| 8 | browse | Navigable hot and cold roots, content-addressed themes, bounded extension renderers, and process leases | capability-browse |
| 9 | access log | JSON-compatible durable access log plus bounded app-scoped NDJSON streams on /1.0 |
capability-access-log |
| 10 | webtransport | WebTransport (HTTP/3 over QUIC) on the edge's own UDP 443, relaying datagrams byte-exact to a config-fixed UDP target; auth-gate tickets | capability-webtransport |
| 11 | vista | VistA's RPC Broker over HTTPS: one em process per session behind POST /rpc, its stream held by Janus; sign-on-aware caps and token rotation |
capability-vista |
make janus # go build ./cmd/janus -> bin/janus
go test ./...
./test.sh # capability-ordered acceptance groups, ending with vistacmd/janus is the binary's main package: stock Caddy, the Janus module,
and the Route 53 DNS provider (DNS-01 wildcard certificates), compiled as
one executable. go.mod pins all dependencies, including explicit
security-sensitive overrides.
janus version, -v, -V, and --version all report the Janus and Caddy
versions; the service verbs (autostart, start, stop, restart,
status) are Janus's, as are apps, logs, serve, mode, firewall,
and passhash. Janus wraps Caddy's config and trust commands with service
defaults; trust --export exports the local CA. Other available commands
retain Caddy's behavior, except file-server defaults to :8080.
upgrade, add-package, and remove-package are unavailable; rebuild
Janus to change its compiled modules.
*.janus.localhost names resolve to the loopback with no DNS; Caddy's internal CA issues each site's certificate (./bin/janus trust installs its root here, or pass curl --cacert); SNI picks the site. No control plane required.
./bin/janus runcurl -s https://foo.janus.localhost/ping # catchall → pong
curl -s https://on.janus.localhost/ping # explicit on → pong
curl -s -o /dev/null -w '%{http_code}\n' https://off.janus.localhost/ping
# → 404On some systems binding :443 needs elevated privileges (sudo ./bin/janus run …). On current macOS it often works without sudo.
Same process. Loopback HTTP and a unix socket serve the control API.
curl -s http://127.0.0.1:7600/1.0
curl -s http://127.0.0.1:7600/1.0/health
curl -s --unix-socket run/janus.sock http://janus/1.0WebSocket upgrades on hub-enabled sites terminate at Janus; JSON directive frames fan out per app at the edge, so app reloads never drop a socket. The tenant registers a bridge to observe frames and steer, and publishes through the control plane.
curl -s http://127.0.0.1:7600/1.0/hub # fan-out / bridge counters
curl -s -X POST -H 'Content-Type: application/json' \
--data '{"@":["/lobby"],"news":{"v":1}}' \
http://127.0.0.1:7600/1.0/apps/$APP_ID/hub/publishOpt-in LAN presence: janus.local (and every registered single-label .local host) answers over multicast DNS with no DNS server or client install, and a plain-HTTP front door serves a read-only, self-contained status page — registry, worker health, heartbeat freshness, and hub counters, with socket paths redacted — and the trust page, http://janus.local/trust: the one-time setup that has a phone or another machine trust the edge's own CA, so every .local name it serves gets a real lock. The page detects the device, hands iOS a configuration profile (/trust/ca.mobileconfig) and everything else the root certificate (/trust/ca.crt), and watches for the moment trust lands (/trust/check over HTTPS) to move the device on. The front door's own name also answers over HTTPS. An optional canonical origin turns the page into a hand-off ramp to real HTTPS, with a built-in diagnostic for router DNS-rebinding filters. The announced name must answer on the HTTP port; where a janus site covers it, that site serves the front door, and where a site of your own covers it, your site owns the name.
curl -s --unix-socket ~/.local/state/janus/run/janus.sock http://janus/1.0/mdns # advertiser state
curl -s --unix-socket ~/.local/state/janus/run/janus.sock http://janus/1.0/mdns/status # the front door's snapshot
curl -s -H 'Host: janus.local' http://127.0.0.1/status.json
curl -s -H 'Host: janus.local' http://127.0.0.1/trust/ca.crt # the CA, as a phone fetches itService discovery follows janus mode: LAN mode advertises only the selected
interface's selected private IPv4 address; localhost and WAN modes advertise
no LAN names. Changing that selection withdraws the old records and announces
the new address. Standalone Caddy configurations retain their configured
interfaces and IPv4/IPv6 discovery.
URL-prefix gates in front of tenant apps that have no login story of their own: define a shared users table and one or more gate <path> { … } allow lists (credentials minted by janus passhash). Each gate's login door is exact {prefix}auth. One host-wide session — sign in once, sign out once; a request under a gate proceeds only if the session user is on that gate's allow list. Longest prefix wins; paths outside every gate stay open. What passes a gate carries Remote-User: <name>; cookies and client Remote-User are stripped on every fall-through. Sessions live in memory: unchanged reloads keep eligible sessions, removing a user or host revokes its sessions after commit, and a restart signs everyone out. Admins observe and revoke over /1.0/auth.
./bin/janus passhash # mint a version-a passhash (password prompted, never argv)
curl -s http://127.0.0.1:7600/1.0/auth # wall counters + session count
curl -s http://127.0.0.1:7600/1.0/auth/sessionsApps register ordered static roots. Janus serves files and same-root precompressed sidecars, can fall back to an SPA shell, and can admit a host only when its requested site directory exists.
An upstream can return X-Sendfile after authorizing a download. Janus
validates the path and serves the file with range and conditional-request
support; this response transformation is always available and has no
configuration toggle.
Hot registered roots and cold configured roots can expose navigable directory listings with embedded or custom themes, bounded renderers, and process leases.
Each Janus site opts into Caddy access logging with format janus. Durable output is byte-equivalent to Caddy's JSON encoder with the same options. Registered-app requests also publish bounded NDJSON to operator streams; Caddy policy remains authoritative, so entries excluded before encoder invocation publish nothing.
curl -s http://127.0.0.1:7600/1.0/access
curl -sN "http://127.0.0.1:7600/1.0/apps/$APP_ID/access?after=0"A web page cannot send UDP. webtransport terminates WebTransport (HTTP/3 over QUIC) on a UDP 443 listener the edge owns, at the same addresses as the HTTPS servers, and relays each datagram, opaque and byte-exact, between a browser session and one UDP address fixed in the Caddyfile: one connected UDP socket per session, one datagram in and one out, drop rather than queue. The global block enables the listener; a route on an exact-host site names the path and the target. GET <path> over TCP answers the descriptor the page dials from; on a site with auth, the descriptor carries a 60 s single-use ticket for the signed-in user, which the relay requires. HTTP/3 stays off on the servers sharing the port (servers { protocols h1 h2 }).
{
janus {
webtransport
}
}
lyte.trusthealth.com {
janus {
auth {
user steve <passhash>
gate / { steve }
}
browse { root /srv/lyte/www }
webtransport /lyte udp/127.0.0.1:41151
}
}curl -s http://127.0.0.1:7600/1.0/webtransportOpenVista's clients (CPRS through its workstation relay, and a browser through Rip's rip/vista package) reach VistA's RPC Broker over HTTPS. Each POST /rpc carries one broker message, byte for byte, to the session's own em process, and returns its reply as it is read. A TCPConnect starts a session: Janus dials the route's Unix socket (a service manager starts one em per connection), writes the PROXY line with the client's true address, and answers with a __Host-ov cookie. Requests run one at a time in order; the session's token rotates when the user signs on; caps on sessions not yet signed on keep unauthenticated clients from piling up em processes. The route is exempt from a covering auth gate only when it says auth exempt: VistA's sign-on is the authentication. The contract is capability-vista; OpenVista's side is its RPC-HTTPS contract.
vista.example.org {
janus {
vista /rpc unix//run/openvista/rpc.sock
}
}curl -s http://127.0.0.1:7600/1.0/vistaFrom this repository:
make janus # go build ./cmd/janus -> bin/janus
./bin/janus runFrom anywhere, against a published version:
go install github.com/shreeve/janus/cmd/janus@v1.19.0Janus also remains a plain Caddy module: builders that assemble their own
Caddy (xcaddy or a custom main) add github.com/shreeve/janus like any
other plugin.
janus help lists every command. Most are Caddy's, under the janus
name: janus trust, janus adapt, janus fmt, and the rest behave as
Caddy's reference documents them, and there is no separate caddy command
on a Janus host. The service verbs below are Janus's own, and run,
start, stop, reload, and validate default to the service Caddyfile.
Confirm the modules are linked:
./bin/janus list-modules | grep -E '^janus$|route53'One Janus per host is the model, and the binary manages it with the same verbs as Harbor:
| Verb | What it does |
|---|---|
janus autostart |
Installs the edge as a service and starts it: running now, at every login (as root: every boot), and again after a crash. |
janus autostart off [stop] |
Removes the service; a running edge is left alone unless stop is given. |
janus start / janus stop |
Start or stop that edge. A stop is clean, so it stays stopped until the next login or boot. Stopping a stopped edge is a quiet no-op. |
janus restart |
Stop and start again, and always finish: under the autostart item it is the manager's restart (a bounded stop, SIGKILL on its timeout, then the start boot performs); a bare edge that ignores its stop is ended after 10 s. This is how an installed upgrade takes effect. The Caddyfile is validated first, so a broken one leaves the edge as it is. |
janus reload |
Apply an edited Caddyfile in place, keeping every connection. |
janus status [--json] |
Running or not, under what, which binary and version, whether the binary is newer than the running edge, how many apps are registered, whether this machine trusts the edge's CA and where a phone trusts it, and the exposure mode: scope, bind, firewall, and each front-door address with how its reach is enforced. Exit 3 when stopped; exit 1 when a process is there but no control plane answers, the state a stop that never finished leaves behind, which janus restart replaces. |
janus serve [dir] [--name N] |
Open a directory through the running edge with the browse capability, at https://<name>.localhost/ here and https://<name>.local/ on the LAN in lan mode (mdns). One registration, heartbeats while it runs, removed on Ctrl-C; nothing new listens. |
janus apps [--json] |
What is registered with the running edge: each app's hosts, what serves them (workers, a files root, a site directory), and its lease. Exit 3 when no edge answers. |
janus logs [-n N] [-f] |
The edge's log, live: the last lines, then each line as it arrives, across roll-overs, until Ctrl-C. Piped, it prints the last lines and exits (-f follows anyway). --supervisor reads the supervisor's capture, where a start that failed before the log opened left its reason; that is also what prints when the edge has no log yet. |
janus validate |
Check the service Caddyfile without touching the running edge. |
janus trust [--export FILE] / janus untrust |
Install (or remove) the edge's own CA in this machine's trust stores, through the service edge's admin socket. --export writes the root certificate (PEM) instead, for another machine's trust store; phones take it from http://janus.local/trust. |
janus mode [localhost|lan|wan] |
Show the exposure mode, or set it (below) and put the edge on it; the same mode again re-resolves it after the lan address or default route moved. |
janus firewall |
Re-apply the host firewall rule the mode needs (root); after a macOS update rewrites /etc/pf.conf. |
Run it as yourself on a machine you log in to. Run it as root (sudo janus autostart, with janus installed where root finds it: the installer puts
it in /usr/local/bin when run as root) for a server: ports 80 and 443
with no setcap, up before anyone logs in, unaffected by logouts. One or
the other on a host, never both, since two edges would contend for the
same ports.
The seeded Caddyfile serves nothing of its own except the LAN's local
names, *.local (announced by mdns), with the edge's own CA, which janus trust installs here and http://janus.local/trust installs on a phone,
one certificate per registered name at its first handshake. This machine's
own names, *.localhost, are a drop-in janus writes into the sites
directory (sites/localhost.caddy) and leaves to you afterwards; being a
drop-in, it survives a Caddyfile rendered by another tool, so janus serve
works on any host. Both sites carry the seed's defaults for an app: the
hub (bridge mode, same-origin pages), precompressed files, and an access
log in the janus format beside the process log. Apps register hosts under
both, and everything else is a drop-in *.caddy file of your own.
On macOS a user's janus is installed as an application bundle,
~/Applications/Janus.app, and the janus command on PATH is a symlink
into it. The bundle is what macOS identifies: its row under Local Network
reads "Janus" with the logo, and the service item runs the executable
inside it. Upgrades swap in a complete new bundle and keep the same
identifier and path, so the one row and its grant survive; janus restart
re-registers the service item when an install moved the edge. Root keeps
the bare binary in /usr/local/bin (a daemon is exempt from Local
Network privacy). install.sh --uninstall removes the command and the
bundle once janus autostart off stop has taken the item down. Linux
installs the bare binary as before.
The supervisor is launchd on macOS and systemd on Linux. As a user the
service is a login item (~/Library/LaunchAgents/janus.edge.plist, or a
systemd user unit, for which autostart also enables lingering so the
edge survives logout); as root it is a system service
(/Library/LaunchDaemons, or /etc/systemd/system/janus.service). It runs
janus run --config <Caddyfile> in the foreground, restarts it after a
crash, and never after a clean exit.
On macOS the user-level service is subject to Local Network privacy
(macOS 15 and later). A launchd agent is judged on its own, so a denied
edge announces janus.local while every query for it, from this machine
or a phone, goes unanswered, with nothing in its log: the kernel drops
inbound multicast for that one process. The fix is System Settings →
Privacy & Security → Local Network: turn on the row for the janus
executable, then janus restart. The row carries the executable's
code-signing identifier. install.sh, the release archives, and make install sign every janus binary as com.github.shreeve.janus, so the
row keeps its name and its grant across upgrades (an unnamed Go binary
would appear as a.out). Never re-sign an installed binary under another
name: macOS then holds several identities for one build UUID and can
strand the edge deaf under all of them. Replace the binary the way
install.sh does, a new file renamed over the old one; a cp onto the
existing file keeps an inode whose signature macOS has cached, and every
later launch of it is killed. The full account and the one-pass
setup for a new Mac are in
docs/20260919-225500-macos-local-network-privacy.md. A root service (a
launchd daemon) and a tool run from Terminal are exempt, so janus run
from a shell always hears the LAN even when the agent does not. tccutil
does not cover this list, and sudo cannot remove the policy file. Linux
has no equivalent. A phone that opens http://janus.local/trust then
takes three steps of its own: download the profile, install it under
General → VPN & Device Management, and enable it under General → About →
Certificate Trust Settings.
The service's files:
| User | Root | |
|---|---|---|
| Caddyfile | ~/.config/janus/Caddyfile |
/etc/janus/Caddyfile |
| Drop-in sites | ~/.config/janus/sites/*.caddy |
/etc/janus/sites/*.caddy |
| Environment (optional) | ~/.config/janus/env |
/etc/janus/env |
| Control and admin sockets, pidfile | ~/.local/state/janus/run/ |
/var/lib/janus/run/ |
| Process log | ~/.local/state/janus/log/janus.log |
/var/log/janus/janus.log |
$XDG_CONFIG_HOME and $XDG_STATE_HOME move the user's Caddyfile and
state. autostart seeds the Caddyfile when there is none: the control
plane on its unix socket, Caddy's admin API on
a socket only this edge's user can reach, ping on every site, the
process log on a rolling file, and an import of the drop-in sites
directory. It validates the Caddyfile before installing anything, so a bad
config never becomes a restart loop, and refuses a state directory deep
enough to push the socket paths past the unix limit. A tool that owns a
site writes one *.caddy file into the sites directory and runs janus reload; the Caddyfile itself stays the operator's. KEY=value lines in
the env file are loaded by run, adapt, validate, and reload when
they use the service config. Explicit --envfile paths replace that default.
Caddy's syntax applies, including quoted multiline values. Existing process
environment values win, even if empty; with multiple files the first value
loaded wins. JANUS_BIND is reserved for the stored mode and is rejected in
every env file. A reload changes configuration placeholders; changes needed
by the running process itself require a restart.
start, stop, reload, and validate default to the service Caddyfile
when it exists; run does too when the current directory has no Caddyfile
of its own. stop, reload, and restart reach the edge through its
admin socket, so they can never touch another Caddy on the host.
See the operator contract for the command and reload-survival matrices, environment precedence, and older-config migration.
Upgrading is the installer followed by a restart:
curl -fsSL https://raw.githubusercontent.com/shreeve/janus/main/install.sh | bash && janus restartjanus status says when the installed binary is newer than the edge that
is running. On Linux a user's edge needs cap_net_bind_service on the
binary to bind ports 80 and 443; autostart says so when it is missing,
and the installer restores it across upgrades.
JANUS_SERVICE_LABEL renames the launchd item or systemd unit (default
janus.edge and janus.service). With XDG_CONFIG_HOME,
XDG_STATE_HOME, and XDG_DATA_HOME pointed elsewhere it lets a test
suite run an edge beside the host's real one, with its own Caddy storage;
that edge needs its own ports in its own Caddyfile.
Rip's rip sites registers apps with this edge and drops its site files
into the sites directory; the edge itself is Janus's to run.
The edge listens where its exposure mode says, on ports 80 and 443, IPv4 and IPv6 as separate sockets:
| Mode | Listens on | Reachable by |
|---|---|---|
localhost |
127.0.0.1 and ::1 |
this machine only — the default |
lan |
localhost plus one interface's private IPv4 address | this machine and its local network; a private address is unreachable from the internet by addressing alone |
wan |
every interface | anyone the network lets through |
janus autostart --scope lan installs with a mode, janus mode <scope>
changes it later, and janus status reports it. lan uses the interface
the default route leaves through, the one this host reaches its network
by; --interface <name> pins another (--interface auto returns to the
default route), and when the interface has several private addresses
--v4 names the one to use — the address is never guessed among
several. lan is IPv4 only: a public address is wan, and IPv6 (whose
interface addresses are globally routable) stays on the loopback and on
wan. Link-local addresses and tunnel, bridge, and VM interfaces are
never candidates.
The mode is stored in scope.json under the state directory, and the
service Caddyfile binds through default_bind {$JANUS_BIND}, which run,
reload, validate, and adapt fill in from the stored mode; the mode
is the one source of that value. The running edge checks its own sockets
against the mode every few seconds and stops, loudly, rather than serve
wider than the mode allows. The webtransport relay's UDP 443 is the one UDP
listener the mode covers, at the same addresses as HTTPS; HTTP/3 stays off
in the seed because it would bind the relay's port.
In wan the local name families are off: a request whose Host is
<name>.local, <name>.localhost, localhost, via.rip, or
<name>.via.rip (public DNS pins the via.rip names to 127.0.0.1) is
refused (421) by every janus site, the front door included, no
certificate is minted for such a name, mdns announces nothing, and
janus serve says why it has nothing to open. Those names belong to a
machine you sit at and a network you are on; a public address has
neither, and neither the seed's local sites nor a via.rip drop-in need
editing to be safe there. The mode governs the service
edge; a Caddyfile run some other way is localhost by default and keeps
its local names.
What enforces a mode differs by platform, and status says which:
- macOS cannot bind an exact low port without root, so the socket is
always the wildcard and a pf anchor (
/etc/pf.anchors/janus, loaded from/etc/pf.confahead of Apple's anchors) scopes it to the loopbacks, plus the on-link subnet forlan. The rules are stateless, so narrowing the mode cuts the connections the wider mode admitted, and the block covers every destination, so an address the host gains later is covered too; the cost is that inlocalhostandlanthe host also stops forwarding ports 80 and 443 for others (Internet Sharing, VMs on shared networking). pf is enabled by reference token, now and at boot by a small LaunchDaemon, since macOS loads pf rules at boot but leaves pf off. Loading the anchor is the one step that needs root, and it is the only thing root does:janus modeandautostartwrite their files as you and runsudo janus firewallwhen a terminal can prompt (without one they refuse, exit 4, before changing anything);sudo janus firewallandsudo janus statusact on your edge when root has none of its own. The edge refuses to servelocalhostorlanuntil the anchor is in place, so a wildcard socket is never open unscoped. - Linux binds the exact addresses (the low ports need
cap_net_bind_servicefor a user's edge) and needs no firewall in any mode, sincelan's address is private. The system unit never stops retrying: a lan edge whose DHCP address moved comes back the momentjanus mode lanstores the new one. - Windows: exposure modes are not supported yet; the verbs refuse.
janus status never claims more than it verified. On macOS without root
the firewall shows as missing when the anchor files are not in place and
otherwise unverified (run: sudo janus status); with root it verifies
that pf is enabled, its main ruleset evaluates the janus anchor ahead of
Apple's, and the anchor holds exactly its rules. Each address says
what enforces its reach: the loopbacks are host-enforced (loopback), a
private address host-enforced (RFC1918) by addressing alone, and wan
is network-gated (wildcard).
On macOS and Linux, one command installs the latest release — it picks the
right archive for the platform, verifies its sha256 against the published
checksums, and installs janus into ~/.local/bin (as root:
/usr/local/bin, so a system deploy keeps its path; override either with
BIN=...; sudo only if the destination is root-owned):
curl -fsSL https://raw.githubusercontent.com/shreeve/janus/main/install.sh | bashThen janus autostart makes it the host's edge
(Running as a service).
Pin a version with ... | bash -s v1.19.0. Uninstall with
... | bash -s -- --uninstall — the binary goes; your Caddyfile, service
units, and certificates stay.
The tagged release workflow publishes five self-contained archives:
| Platform | Archive |
|---|---|
| macOS Apple Silicon | janus-<tag>-osx-arm64.tar.gz |
| Linux x86-64 | janus-<tag>-linux-amd64.tar.gz |
| Linux ARM64 | janus-<tag>-linux-arm64.tar.gz |
| Windows x86-64 | janus-<tag>-windows-amd64.zip |
| Windows ARM64 | janus-<tag>-windows-arm64.zip |
Download the matching archive from the
releases page and extract it.
On macOS and Linux, run ./install.sh to install janus into
~/.local/bin (as root: /usr/local/bin), or choose another destination
with BIN="$HOME/bin" ./install.sh. The extracted binary also runs in place.
On Windows, run janus.exe directly. Each archive also contains
Caddyfile.minimal, Caddyfile.example, the README, and the license; the installer deliberately
leaves configuration in the archive rather than overwriting a live Caddyfile.
The release's janus-<tag>-checksums.txt verifies every archive.
(Debian packages the unrelated WebRTC gateway janus-gateway as janus;
on a host running both, install this binary under a different BIN.)
What changed in each release is in CHANGELOG.md.
Release builds run on native GitHub runners and compile from the pushed tag,
so janus version on a downloaded binary reports the exact Janus and Caddy
versions, and janus build-info records the tagged module version. Pushing
a v* tag runs the release workflow automatically.
For a local development build:
make janus # working tree -> bin/janus
make unit # fast Go test suite
make test # build + unit + acceptance suite
make install # build + install: ~/.local/bin as a user, /usr/local/bin as root
# (macOS: ~/Applications/Janus.app, with janus linked into it)
# make install BIN="$HOME/bin"The Caddyfile adapts to the following partial JSON shape. Site-scoped
capabilities cascade global → site → built-in default. control and mdns
are process-wide; access logging belongs to each HTTP site's log config;
sendfile is always on and has no config key.
heartbeat_ttl defaults to 15s; JANUS_HEARTBEAT_TTL supplies the fallback
when it is unset. The pooled registry captures this value when it is first
provisioned. Changing it requires a restart: a config reload currently
rejects a changed effective value with an error requiring a restart.
GET /1.0 reports the running heartbeat_ttl; janus serve sends
heartbeats every third of that interval (5s for older edges without it).
{
"apps": {
"janus": {
"control": [{ "mode": "local" }],
"ping": true,
"hub": { "enabled": true, "path": "/hub", "max_conns": 4096 },
"mdns": { "name": "janus.local" },
"auth": { "enabled": true, "replace": true, "users": [{ "name": "alice", "credential": "a…" }], "gates": [{ "prefix": "/", "allow": ["alice"] }], "ttl": "8h" },
"heartbeat_ttl": "15s"
},
"http": {
"servers": {
"srv0": {
"listen": [":443"],
"routes": [{
"match": [{ "host": ["app.example.com"] }],
"handle": [{ "handler": "janus" }]
}]
}
}
}
}
}| Path | Role |
|---|---|
app.go |
Process-wide janus app (control, global defaults, pooled state) |
handler.go |
Site http.handlers.janus (admission + site overrides) |
caddyfile.go |
Caddyfile wiring: global janus block + site directive parsing, directive order |
doc.go |
Package overview (the go doc face of the module) |
state.go |
Pooled process state (registry, data plane, hubs survive reloads) |
version.go |
The running version, published by the binary or read from build info |
cascade.go |
Cascade helpers shared by every site-scoped capability |
control.go |
Control listener config (control internal/local/public, token:…) |
control_api.go |
Control listeners + /1.0 mux (meta, health, tls/ask) |
control_hub.go |
Hub control surface (publish, snapshot, counters) |
apps.go |
Hot apps registry (CRUD, upstreams, bridge, heartbeats, TTL sweep) |
dataplane.go |
Host → worker-socket proxying (least-conn, health, marked 503s) |
ring.go |
Doorbell ring: single-flight wake-up for dirty apps |
hub.go |
Hub state and executor (membership, delivery, counters) |
hub_frame.go |
Hub wire grammar (sigils, events, whole-frame validation) |
hub_conn.go |
Hub connection lifecycle (writer, backpressure, close paths) |
hub_ws.go |
Hub WebSocket edge (admission, upgrade, reader) |
hub_bridge.go |
Hub tenant bridge (per-connection FIFO, open/text/close POSTs) |
hub_config.go |
hub directive: parse, cascade, site table, floors |
mdns.go |
mDNS advertiser (pooled, reconcile goroutine) + status front door |
mdns_config.go |
mdns directive: parse, provision, validation |
mdns.html |
Embedded status page (self-contained; zero external resources) |
control_mdns.go |
mDNS control surface (GET /1.0/mdns) |
auth.go |
Auth wall: gates, pooled sessions, throttle ladder, CSRF, login doors |
auth_config.go |
auth directive: users, gates, parse, cascade, passhash codec, site table |
auth_cmd.go |
janus passhash credential minter |
auth.html |
Embedded login/status page (self-contained; zero external resources) |
control_auth.go |
Auth control surface (GET /1.0/auth, session list + revocation) |
access.go |
Pooled access bridge, registration sequence state, bounded event schema |
access_encoder.go |
caddy.logging.encoders.janus, wrapping durable JSON |
access_stream.go |
Access status and app-scoped NDJSON control streams |
access_writer.go |
Response outcome observation with optional interfaces preserved |
testkit/ |
Go test-support program: fixtures + WS driver for test.sh |
bench/ |
Committed bench harness (baseline, leak probe, hub arm) |
Caddyfile |
Working cold config (multi-site cascade demos) |
Caddyfile.minimal |
Operator-facing starting point: one app site, one browsable root (validates standalone) |
Caddyfile.example |
Production-shaped walkthrough of every capability and knob (validates standalone) |
test.sh |
High-level acceptance suite (self-contained; not a substitute for go test) |
docs/ |
Contracts, capability pages, measurements (YYYYMMDD-HHMMSS- prefixed; see docs/README.md) |
See docs/ for the control-plane sketch and related material. The /1.0 API follows an Incus-inspired style (envelopes, resource paths) while remaining Janus’s own protocol; writes carry no fencing fields — the tenant serializes its own writes (see the pool protocol).
In Roman myth, Janus is the god of doorways and thresholds — beginnings, passages, and the space between inside and outside. He is shown with two faces: one looking out, one looking in. That is the shape of this module. One face serves the public world over TLS; the other coordinates private upstreams, registry, and control-plane state so that serving is possible. The passage between them is the product.
