Skip to content

Commit ef39f8e

Browse files
committed
path-routed public ingress with per-user tenant caddies
A root caddy binds the public port (8080, set-public) and path-routes /hermes/* and /nikita/* to per-user self-serve caddies. The Hermes dashboard is on 9999 behind exe.dev auth.
1 parent eb93f38 commit ef39f8e

6 files changed

Lines changed: 200 additions & 2 deletions

File tree

.github/workflows/deploy.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ jobs:
8282
--repository "${{ github.repository }}" \
8383
--attach "vm:$VM_NAME"
8484
85-
- name: Make webhook port public
85+
# 8080 = services.ingress.publicPort, the public root port.
86+
- name: Publish the public ingress port
8687
run: |
88+
ssh -i ~/.ssh/id_ed25519 exe.dev share port "$VM_NAME" 8080
8789
ssh -i ~/.ssh/id_ed25519 exe.dev share set-public "$VM_NAME"

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ A Nix-built OCI image to bootstrap [exe.dev](https://exe.dev) machine.
99
- **exe.dev image** — s6-supervised: OpenSSH, the Hermes agent (behind a caddy
1010
proxy), tailscaled (persistent tailnet node with Tailscale SSH), restic
1111
backups to B2 with restore-on-boot, and a nix daemon for runtime installs.
12+
- **Public ingress** — a root caddy binds the public port (8080, `share
13+
set-public`) and path-routes `/hermes/*` and `/nikita/*` to per-user
14+
self-serve caddies; each user edits their own `~/.caddy/Caddyfile` and `caddy
15+
reload`s. The Hermes dashboard is on 9999, gated behind exe.dev auth.
1216
- **Users**`nikita` (login user, fish shell, home-manager env, sudo) and
1317
`hermes` (the agent: own package set, no sudo, not nix-trusted).
1418
- **Mac** — nix-darwin + home-manager consuming the same `home/` modules;

hosts/exedev/default.nix

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@
2929
hostname = "exedev";
3030
};
3131

32+
# tenants registered per-user in hosts/exedev/users/*.nix.
33+
services.ingress.enable = true;
34+
3235
# fish reads no /etc/profile; wire the nix profiles for fish logins.
3336
environment.etc."fish/config.fish".text = ''
3437
fish_add_path --global --move --path \

hosts/exedev/users/hermes.nix

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
# headless: drops the gtk/pipewire/gstreamer closure.
2020
ffmpeg = pkgs.ffmpeg-headless;
2121
};
22-
ports = [ 8644 ];
2322
# exe.dev LLM integration (llm.int.exe.xyz, attached auto:all).
2423
settings =
2524
let
@@ -128,4 +127,13 @@
128127
enable = true;
129128
paths = [ "/var/lib/hermes" ];
130129
};
130+
131+
# /hermes/* on the public port. Caddyfile under /var/lib/hermes is backed up.
132+
services.ingress.tenants.hermes = {
133+
upstreamPort = 8081;
134+
routes = ''
135+
# edit, then: caddy reload --config ~/.caddy/Caddyfile
136+
reverse_proxy 127.0.0.1:8644
137+
'';
138+
};
131139
}

hosts/exedev/users/nikita.nix

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@ in
3434
# atuin shell history + sync identity (host_id/key), so a recreated VM
3535
# keeps its history instead of registering as a fresh atuin host.
3636
"${home}/.local/share/atuin"
37+
# ingress routes for /nikita/* (see services.ingress below).
38+
"${home}/.caddy"
3739
];
3840

41+
# /nikita/* on the public port.
42+
services.ingress.tenants.nikita = {
43+
upstreamPort = 8082;
44+
};
45+
3946
users.users.${name} = {
4047
uid = 1000;
4148
group = name;
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
{
2+
pkgs,
3+
config,
4+
lib,
5+
...
6+
}:
7+
let
8+
inherit (lib) mkOption types;
9+
cfg = config.services.ingress;
10+
11+
tenants = lib.mapAttrsToList (name: t: t // { inherit name; }) cfg.tenants;
12+
13+
# binds the public port; routes each /<name>/* to a tenant's loopback caddy
14+
# with the prefix stripped.
15+
rootCaddyfile = pkgs.writeText "ingress-root.Caddyfile" (
16+
lib.concatStringsSep "\n" (
17+
[
18+
"{"
19+
"\tadmin off"
20+
"\tauto_https off"
21+
"}"
22+
""
23+
":${toString cfg.publicPort} {"
24+
]
25+
++ map (
26+
t: "\thandle_path /${t.name}/* {\n\t\treverse_proxy 127.0.0.1:${toString t.upstreamPort}\n\t}"
27+
) tenants
28+
++ [
29+
"\thandle {"
30+
"\t\trespond \"not found\" 404"
31+
"\t}"
32+
"}"
33+
""
34+
]
35+
)
36+
);
37+
38+
# tab-indent each non-blank route line so the rendered seed is caddy-fmt clean.
39+
indent =
40+
text:
41+
lib.concatStringsSep "\n" (map (l: if l == "" then l else "\t${l}") (lib.splitString "\n" text));
42+
43+
# runtime dir (0700, owned by the tenant) holding the caddy admin socket.
44+
rundirOf = t: "/run/ingress-${t.name}";
45+
46+
# the tenant's Caddyfile, rendered when absent; user-owned afterward. The
47+
# admin socket in the 0700 rundir lets the user `caddy reload` in place, and
48+
# no other non-root user can reach it.
49+
seedFile =
50+
t:
51+
pkgs.writeText "ingress-${t.name}-seed.Caddyfile" (
52+
lib.concatStringsSep "\n" [
53+
"{"
54+
"\tadmin unix/${rundirOf t}/admin.sock"
55+
"\tauto_https off"
56+
"}"
57+
""
58+
":${toString t.upstreamPort} {"
59+
(indent (lib.removeSuffix "\n" t.routes))
60+
"}"
61+
""
62+
]
63+
);
64+
in
65+
{
66+
options.services.ingress = {
67+
enable = lib.mkEnableOption "the public path-routed ingress (root caddy + per-user self-serve tenant caddies)";
68+
69+
publicPort = mkOption {
70+
type = types.port;
71+
default = 8080;
72+
description = ''
73+
Port the root caddy binds and the image exposes. Meant to be exe.dev's
74+
primary (root-URL) port; the deploy pins it with `share port` and
75+
publishes it with `share set-public`.
76+
'';
77+
};
78+
79+
tenants = mkOption {
80+
default = { };
81+
description = ''
82+
Per-user subtrees under /<name>/* on the public port. Each runs a caddy
83+
as its user, reading a Caddyfile the user owns and can reload, so a user
84+
can expose new services at runtime without a rebuild or root.
85+
'';
86+
type = types.attrsOf (
87+
types.submodule (
88+
{ name, ... }:
89+
{
90+
options = {
91+
user = mkOption {
92+
type = types.str;
93+
default = name;
94+
description = "User the tenant caddy runs as (must be a declared user).";
95+
};
96+
upstreamPort = mkOption {
97+
type = types.port;
98+
description = "Loopback port the tenant caddy binds; root forwards /<name>/* here (prefix stripped).";
99+
};
100+
routes = mkOption {
101+
type = types.lines;
102+
default = "respond \"${name}: no routes configured yet\" 404";
103+
description = "Seed route body spliced into the tenant's site block on first boot; user-owned after.";
104+
};
105+
};
106+
}
107+
)
108+
);
109+
};
110+
};
111+
112+
config = lib.mkIf cfg.enable {
113+
image.exposedPorts.tcp = [ cfg.publicPort ];
114+
115+
# tenants manage their routes with `caddy reload`, so put caddy on their PATH.
116+
users.users = lib.listToAttrs (
117+
map (t: lib.nameValuePair t.user { packages = [ pkgs.caddy ]; }) tenants
118+
);
119+
120+
s6.services = lib.mkMerge (
121+
[
122+
{
123+
ingress-root = {
124+
dependencies = [ "base" ];
125+
run = ''
126+
mkdir -p /run/ingress-root
127+
exec env HOME=/run/ingress-root XDG_DATA_HOME=/run/ingress-root XDG_CONFIG_HOME=/run/ingress-root \
128+
${pkgs.caddy}/bin/caddy run --config ${rootCaddyfile} --adapter caddyfile
129+
'';
130+
};
131+
}
132+
]
133+
++ map (
134+
t:
135+
let
136+
u = config.users.users.${t.user};
137+
gid = toString config.users.groups.${u.group}.gid;
138+
caddyDir = "${u.home}/.caddy";
139+
caddyfile = "${caddyDir}/Caddyfile";
140+
rundir = rundirOf t;
141+
in
142+
{
143+
# write the Caddyfile only when absent.
144+
"ingress-${t.name}-setup" = {
145+
type = "oneshot";
146+
dependencies = [
147+
"base"
148+
]
149+
++ lib.optional config.services.backup.enable "backup-restore";
150+
run = ''
151+
mkdir -p ${caddyDir}
152+
[ -e ${caddyfile} ] || cp ${seedFile t} ${caddyfile}
153+
chown -R ${toString u.uid}:${gid} ${caddyDir}
154+
'';
155+
};
156+
"ingress-${t.name}" = {
157+
dependencies = [
158+
"base"
159+
"ingress-${t.name}-setup"
160+
];
161+
run = ''
162+
mkdir -p ${rundir}
163+
chown ${toString u.uid}:${gid} ${rundir}
164+
chmod 700 ${rundir}
165+
exec /command/s6-setuidgid ${t.user} \
166+
env HOME=${u.home} XDG_DATA_HOME=${rundir} XDG_CONFIG_HOME=${rundir} \
167+
${pkgs.caddy}/bin/caddy run --config ${caddyfile} --adapter caddyfile
168+
'';
169+
};
170+
}
171+
) tenants
172+
);
173+
};
174+
}

0 commit comments

Comments
 (0)