This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

superconfig / configs / hermes-proxy.nix
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}