This repository has no description
8.2 kB
234 lines
1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 upstream = "http://coaxmetal.r:8642";
9 assist = "assist.lassul.us";
10 voiceUpstream = "http://coaxmetal.r:8790";
11 oauth2Proxy = config.services.oauth2-proxy.httpAddress;
12 voiceAuthHeader = "/run/hermes-voice-auth/authorization.conf";
13
14 # Agent turns run for minutes and /v1/chat/completions streams SSE chunks the
15 # whole time. nginx's default 60s proxy_read_timeout would cut a long turn off
16 # mid-answer, and its response buffering would hold the stream back until the
17 # turn ended, so the phone would sit silent and then dump everything at once.
18 # coaxmetal is a laptop — asleep or off the mesh most of the day — so connect
19 # fails fast, to report "unreachable" instead of hanging on the assistant
20 # button.
21 streamingProxy = ''
22 proxy_read_timeout 1h;
23 proxy_send_timeout 1h;
24 proxy_buffering off;
25 proxy_cache off;
26 proxy_connect_timeout 5s;
27 limit_req zone=hermes_api burst=20 nodelay;
28 '';
29
30 proxied = {
31 recommendedProxySettings = true;
32 proxyPass = upstream;
33 extraConfig = streamingProxy;
34 };
35
36 # Endpoints WakeHermesClaw actually calls, taken from its sources
37 # (backend/HermesApiServerClient.kt and friends): the chat/runs lanes plus the
38 # read-only lanes its settings screens populate from. Everything else — most
39 # of the adapter's route table — is unreachable from outside.
40 allowed = [
41 "= /health"
42 "/v1/"
43 "= /api/config"
44 "= /api/available-models"
45 "= /api/model/options"
46 "= /api/skills"
47 "= /api/jobs"
48 "/api/jobs/"
49 "/api/profiles"
50 ];
51in
52{
53 imports = [ ./hermes-voice-vars.nix ];
54
55 # Public door to the Hermes API server on coaxmetal (machines/coaxmetal/hermes.nix).
56 # This is what the WakeHermesClaw app on massulus points at when it is not on
57 # retiolum: the assistant button posts to /v1/chat/completions here.
58 #
59 # SECURITY: behind this vhost sits an agent with terminal tools. The
60 # gateway's only check on this
61 # lane is the API_SERVER_KEY bearer token — there is no per-user allowlist like
62 # MATRIX_ALLOWED_USERS on the Matrix lane, and the app cannot do mTLS (it
63 # builds a bare OkHttpClient with no KeyManager), so a client certificate is
64 # not an option without patching it. That token is therefore a
65 # root-equivalent credential for that laptop: rotate it by regenerating the
66 # hermes-api var. The hardening below is damage control, not authentication.
67 services.nginx.virtualHosts."hermes.lassul.us" = {
68 enableACME = true;
69 forceSSL = true;
70
71 # Attachments: the app can send images with a prompt. 25M is generous for a
72 # phone photo and still bounds what an unauthenticated request can push
73 # into the proxy before hermes rejects it.
74 extraConfig = ''
75 client_max_body_size 25m;
76 '';
77
78 # Unlisted paths never reach hermes. Notably /api/pty and /api/ws — the
79 # Hermes Desktop terminal lane — are only served with backend.mode
80 # "serve"/"dashboard", which this host does not enable; 404ing them here
81 # means enabling it later cannot quietly publish a PTY.
82 locations = lib.genAttrs allowed (_: proxied) // {
83 "/".extraConfig = ''
84 return 404;
85 '';
86 };
87 };
88
89 # Public door to hermes-voice on coaxmetal (machines/coaxmetal/hermes-voice.nix):
90 # the voice page for the phone. Everything but the files Chrome fetches
91 # without cookies to install the page needs a login on id.lassul.us
92 # (oauth2-proxy below, via auth_request); logged-in requests get
93 # hermes-voice's access token added. /app/ is the Android app's door: no
94 # web login, the app's own bearer token goes through to hermes-voice, which
95 # checks it. /delegate streams NDJSON for the length of a Hermes turn, so
96 # the streaming settings apply.
97 services.nginx.virtualHosts.${assist} = {
98 enableACME = true;
99 forceSSL = true;
100 extraConfig = ''
101 client_max_body_size 256k;
102 auth_request /oauth2/auth;
103 error_page 401 = @login;
104 '';
105 locations =
106 lib.genAttrs
107 [
108 "= /manifest.webmanifest"
109 "= /icon-192.png"
110 "= /icon-512.png"
111 ]
112 (_: {
113 proxyPass = voiceUpstream;
114 extraConfig = ''
115 auth_request off;
116 '';
117 })
118 // {
119 "/" = {
120 recommendedProxySettings = true;
121 proxyPass = voiceUpstream;
122 extraConfig = streamingProxy + ''
123 include ${voiceAuthHeader};
124 '';
125 };
126 "/app/" = {
127 recommendedProxySettings = true;
128 proxyPass = "${voiceUpstream}/";
129 extraConfig = streamingProxy + ''
130 auth_request off;
131 '';
132 };
133 "/oauth2/" = {
134 recommendedProxySettings = true;
135 proxyPass = oauth2Proxy;
136 extraConfig = ''
137 auth_request off;
138 '';
139 };
140 "= /oauth2/auth" = {
141 recommendedProxySettings = true;
142 proxyPass = oauth2Proxy;
143 extraConfig = ''
144 auth_request off;
145 proxy_pass_request_body off;
146 proxy_set_header Content-Length "";
147 '';
148 };
149 "@login" = {
150 return = "307 /oauth2/start?rd=$request_uri";
151 extraConfig = ''
152 auth_request off;
153 '';
154 };
155 };
156 };
157
158 # Login for assist.lassul.us: only users with a grant on the Zitadel
159 # project, and of those only this verified address. Sessions are keyed by
160 # `sub`. zitadel-reconcile writes the client id/secret to the keyFile.
161 lass.zitadel.projects.assist = {
162 access = "declarative";
163 users = [ "lass@lassul.us" ];
164 apps.web = {
165 redirectUris = [ "https://${assist}/oauth2/callback" ];
166 envFile = {
167 clientIdVariable = "OAUTH2_PROXY_CLIENT_ID";
168 clientSecretVariable = "OAUTH2_PROXY_CLIENT_SECRET";
169 };
170 };
171 };
172 services.oauth2-proxy = {
173 enable = true;
174 provider = "oidc";
175 oidcIssuerUrl = "https://id.lassul.us";
176 keyFile = config.lass.zitadel.projects.assist.apps.web.envFile.path;
177 redirectURL = "https://${assist}/oauth2/callback";
178 email.addresses = "lass@lassul.us";
179 cookie.secretFile = config.clan.core.vars.generators.oauth2-proxy.files."cookie-secret".path;
180 extraConfig.skip-provider-button = true;
181 };
182 systemd.services.oauth2-proxy = {
183 wants = [ "zitadel-reconcile.service" ];
184 after = [ "zitadel-reconcile.service" ];
185 };
186 clan.core.vars.generators.oauth2-proxy = {
187 files."cookie-secret" = { };
188 runtimeInputs = [
189 pkgs.coreutils
190 pkgs.openssl
191 ];
192 script = ''
193 openssl rand -hex 16 | tr -d '\n' > "$out/cookie-secret"
194 '';
195 };
196
197 # The Authorization header with hermes-voice's access token, as an nginx
198 # include readable only by nginx. A switch reloads nginx through
199 # nginx-config-reload instead of starting nginx, so the include has to be in
200 # place before either, and multi-user.target starts it on a switch that
201 # newly adds this unit.
202 systemd.services.hermes-voice-auth-header = {
203 wantedBy = [ "multi-user.target" ];
204 before = [
205 "nginx.service"
206 "nginx-config-reload.service"
207 ];
208 requiredBy = [
209 "nginx.service"
210 "nginx-config-reload.service"
211 ];
212 serviceConfig = {
213 Type = "oneshot";
214 RemainAfterExit = true;
215 RuntimeDirectory = "hermes-voice-auth";
216 RuntimeDirectoryPreserve = true;
217 UMask = "0077";
218 };
219 script = ''
220 token=$(cat ${config.clan.core.vars.generators.hermes-voice.files."access-token".path})
221 printf 'proxy_set_header Authorization "Bearer %s";\n' "$token" > ${voiceAuthHeader}.tmp
222 chown ${config.services.nginx.user} ${voiceAuthHeader}.tmp
223 mv ${voiceAuthHeader}.tmp ${voiceAuthHeader}
224 '';
225 };
226
227 # One phone driving one agent needs a trickle of requests; a turn is a single
228 # long-lived request, not a stream of them. This is sized to absorb the
229 # settings screens refreshing (hence the burst) while making credential
230 # stuffing against the bearer token pointless.
231 services.nginx.appendHttpConfig = ''
232 limit_req_zone $binary_remote_addr zone=hermes_api:1m rate=60r/m;
233 '';
234}