This repository has no description
7.9 kB
226 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 and verify the Android app needs a
92 # login on id.lassul.us (oauth2-proxy below, via auth_request); logged-in
93 # requests get hermes-voice's access token added. /delegate streams NDJSON
94 # for the length of a Hermes turn, so the streaming settings apply.
95 services.nginx.virtualHosts.${assist} = {
96 enableACME = true;
97 forceSSL = true;
98 extraConfig = ''
99 client_max_body_size 256k;
100 auth_request /oauth2/auth;
101 error_page 401 = @login;
102 '';
103 locations =
104 lib.genAttrs
105 [
106 "= /.well-known/assetlinks.json"
107 "= /manifest.webmanifest"
108 "= /icon-192.png"
109 "= /icon-512.png"
110 ]
111 (_: {
112 proxyPass = voiceUpstream;
113 extraConfig = ''
114 auth_request off;
115 '';
116 })
117 // {
118 "/" = {
119 recommendedProxySettings = true;
120 proxyPass = voiceUpstream;
121 extraConfig = streamingProxy + ''
122 include ${voiceAuthHeader};
123 '';
124 };
125 "/oauth2/" = {
126 recommendedProxySettings = true;
127 proxyPass = oauth2Proxy;
128 extraConfig = ''
129 auth_request off;
130 '';
131 };
132 "= /oauth2/auth" = {
133 recommendedProxySettings = true;
134 proxyPass = oauth2Proxy;
135 extraConfig = ''
136 auth_request off;
137 proxy_pass_request_body off;
138 proxy_set_header Content-Length "";
139 '';
140 };
141 "@login" = {
142 return = "307 /oauth2/start?rd=$request_uri";
143 extraConfig = ''
144 auth_request off;
145 '';
146 };
147 };
148 };
149
150 # Login for assist.lassul.us: only users with a grant on the Zitadel
151 # project, and of those only this verified address. Sessions are keyed by
152 # `sub`. zitadel-reconcile writes the client id/secret to the keyFile.
153 lass.zitadel.projects.assist = {
154 access = "declarative";
155 users = [ "lass@lassul.us" ];
156 apps.web = {
157 redirectUris = [ "https://${assist}/oauth2/callback" ];
158 envFile = {
159 clientIdVariable = "OAUTH2_PROXY_CLIENT_ID";
160 clientSecretVariable = "OAUTH2_PROXY_CLIENT_SECRET";
161 };
162 };
163 };
164 services.oauth2-proxy = {
165 enable = true;
166 provider = "oidc";
167 oidcIssuerUrl = "https://id.lassul.us";
168 keyFile = config.lass.zitadel.projects.assist.apps.web.envFile.path;
169 redirectURL = "https://${assist}/oauth2/callback";
170 email.addresses = "lass@lassul.us";
171 cookie.secretFile = config.clan.core.vars.generators.oauth2-proxy.files."cookie-secret".path;
172 extraConfig.skip-provider-button = true;
173 };
174 systemd.services.oauth2-proxy = {
175 wants = [ "zitadel-reconcile.service" ];
176 after = [ "zitadel-reconcile.service" ];
177 };
178 clan.core.vars.generators.oauth2-proxy = {
179 files."cookie-secret" = { };
180 runtimeInputs = [
181 pkgs.coreutils
182 pkgs.openssl
183 ];
184 script = ''
185 openssl rand -hex 16 | tr -d '\n' > "$out/cookie-secret"
186 '';
187 };
188
189 # The Authorization header with hermes-voice's access token, as an nginx
190 # include readable only by nginx. A switch reloads nginx through
191 # nginx-config-reload instead of starting nginx, so the include has to be in
192 # place before either, and multi-user.target starts it on a switch that
193 # newly adds this unit.
194 systemd.services.hermes-voice-auth-header = {
195 wantedBy = [ "multi-user.target" ];
196 before = [
197 "nginx.service"
198 "nginx-config-reload.service"
199 ];
200 requiredBy = [
201 "nginx.service"
202 "nginx-config-reload.service"
203 ];
204 serviceConfig = {
205 Type = "oneshot";
206 RemainAfterExit = true;
207 RuntimeDirectory = "hermes-voice-auth";
208 RuntimeDirectoryPreserve = true;
209 UMask = "0077";
210 };
211 script = ''
212 token=$(cat ${config.clan.core.vars.generators.hermes-voice.files."access-token".path})
213 printf 'proxy_set_header Authorization "Bearer %s";\n' "$token" > ${voiceAuthHeader}.tmp
214 chown ${config.services.nginx.user} ${voiceAuthHeader}.tmp
215 mv ${voiceAuthHeader}.tmp ${voiceAuthHeader}
216 '';
217 };
218
219 # One phone driving one agent needs a trickle of requests; a turn is a single
220 # long-lived request, not a stream of them. This is sized to absorb the
221 # settings screens refreshing (hence the burst) while making credential
222 # stuffing against the bearer token pointless.
223 services.nginx.appendHttpConfig = ''
224 limit_req_zone $binary_remote_addr zone=hermes_api:1m rate=60r/m;
225 '';
226}