This repository has no description
0

Configure Feed

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

configs.hermes-proxy: log in to assist.lassul.us via id.lassul.us

assist.lassul.us now sits behind oauth2-proxy on neoprism (provider
oidc, issuer https://id.lassul.us), wired in with nginx auth_request on
that vhost only. The nixpkgs nginx helper (oauth2-proxy.nginx) is not
used because it turns on services.nginx.recommendedProxySettings for
every vhost on the host; the same few locations are written out here.
Unauthenticated requests are redirected (307) to /oauth2/start with a
relative rd, which oauth2-proxy accepts without a whitelist-domain;
the provider button is skipped so it goes straight to Zitadel.

Access: the Zitadel project "assist" is declarative with a grant for
lass@lassul.us only, and its projectRoleCheck blocks everyone else at
the IdP. oauth2-proxy additionally allows only that address
(authenticated-emails-file, no email domains); its OIDC provider rejects
emails whose email_verified is false. The session user is the `sub`
claim. The client id/secret come from zitadel-reconcile's env file
(/var/lib/zitadel-apps/assist-web.env, OAUTH2_PROXY_CLIENT_ID/SECRET),
used as the unit's EnvironmentFile; the cookie secret from a new
neoprism vars generator (oauth2-proxy/cookie-secret, via LoadCredential).

Public without login, as Chrome fetches them without cookies to install
the page and verify the TWA: /.well-known/assetlinks.json,
/manifest.webmanifest, /icon-192.png, /icon-512.png.

After the login, nginx sets Authorization: Bearer <access token> towards
hermes-voice, from an include in /run written at boot by a root oneshot
(hermes-voice-auth-header, owned by nginx, mode 0600), so the token
never enters the store. For that the hermes-voice generator is now a
shared var (configs/hermes-voice-vars.nix, imported by coaxmetal and
neoprism); a shared generator gets a new value, so the token is rotated.
hermes-voice keeps checking the token, which still guards its retiolum
port; the gateway plugin keeps calling /approve on localhost with
API_SERVER_KEY.

The page no longer asks for or stores the token. Its fetches use
redirect: "manual", so an expired login (redirect to id.lassul.us,
which fetch cannot follow cross-origin) shows up as an opaqueredirect
and the page reloads into the login.

+167 -94
+133 -9
configs/hermes-proxy.nix
··· 1 - { lib, ... }: 1 + { 2 + config, 3 + lib, 4 + pkgs, 5 + ... 6 + }: 2 7 let 3 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"; 4 13 5 14 # Agent turns run for minutes and /v1/chat/completions streams SSE chunks the 6 15 # whole time. nginx's default 60s proxy_read_timeout would cut a long turn off ··· 41 50 ]; 42 51 in 43 52 { 53 + imports = [ ./hermes-voice-vars.nix ]; 54 + 44 55 # Public door to the Hermes API server on coaxmetal (machines/coaxmetal/hermes.nix). 45 56 # This is what the WakeHermesClaw app on massulus points at when it is not on 46 57 # retiolum: the assistant button posts to /v1/chat/completions here. ··· 76 87 }; 77 88 78 89 # Public door to hermes-voice on coaxmetal (machines/coaxmetal/hermes-voice.nix): 79 - # the voice page for the phone. hermes-voice gates every API route with its 80 - # own access token, so nothing is filtered by path here. /delegate streams 81 - # NDJSON for the length of a Hermes turn, so the streaming settings apply. 82 - services.nginx.virtualHosts."assist.lassul.us" = { 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} = { 83 96 enableACME = true; 84 97 forceSSL = true; 85 98 extraConfig = '' 86 99 client_max_body_size 256k; 100 + auth_request /oauth2/auth; 101 + error_page 401 = @login; 87 102 ''; 88 - locations."/" = { 89 - recommendedProxySettings = true; 90 - proxyPass = "http://coaxmetal.r:8790"; 91 - extraConfig = streamingProxy; 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 + }; 92 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 + ''; 93 217 }; 94 218 95 219 # One phone driving one agent needs a trickle of requests; a turn is a single
+20
configs/hermes-voice-vars.nix
··· 1 + { pkgs, ... }: 2 + { 3 + # Bearer token of hermes-voice's API routes. Shared: coaxmetal 4 + # (machines/coaxmetal/hermes-voice.nix) checks it, neoprism 5 + # (configs/hermes-proxy.nix) adds it to logged-in requests to 6 + # assist.lassul.us. Both machines import this file so the shared generator 7 + # definition stays identical. Read it out for clients on retiolum with 8 + # clan vars get coaxmetal hermes-voice/access-token 9 + clan.core.vars.generators.hermes-voice = { 10 + share = true; 11 + files."access-token" = { }; 12 + runtimeInputs = [ 13 + pkgs.coreutils 14 + pkgs.openssl 15 + ]; 16 + script = '' 17 + openssl rand -hex 32 | tr -d '\n' > "$out/access-token" 18 + ''; 19 + }; 20 + }
+5 -15
machines/coaxmetal/hermes-voice.nix
··· 11 11 webhookPort = config.services.hermes-agent.settings.platforms.webhook.extra.port; 12 12 in 13 13 { 14 + imports = [ ../../configs/hermes-voice-vars.nix ]; 15 + 14 16 # hermes-voice (tools/hermes-voice): live voice conversations with Hermes 15 17 # through OpenAI's gpt-live-1. Runs next to Hermes so its API key never 16 18 # leaves this host; reachable over retiolum and publicly as 17 19 # https://assist.lassul.us through neoprism (configs/hermes-proxy.nix). 18 20 # 19 21 # SECURITY: the page drives Hermes, which has terminal tools and code 20 - # execution as lass (machines/coaxmetal/hermes.nix). The access token below 21 - # is the gate on every API route; treat it like the hermes-api key. 22 + # execution as lass (machines/coaxmetal/hermes.nix). The access token 23 + # (configs/hermes-voice-vars.nix) is the gate on every API route; treat it 24 + # like the hermes-api key. 22 25 systemd.services.hermes-voice = { 23 26 description = "hermes-voice: live voice conversations with Hermes"; 24 27 wantedBy = [ "multi-user.target" ]; ··· 102 105 description = "OpenAI API key for hermes-voice (gpt-live-1)"; 103 106 type = "hidden"; 104 107 persist = true; 105 - }; 106 - 107 - # What the page sends as its bearer token. Read it out for the phone with 108 - # clan vars get coaxmetal hermes-voice/access-token 109 - clan.core.vars.generators.hermes-voice = { 110 - files."access-token" = { }; 111 - runtimeInputs = [ 112 - pkgs.coreutils 113 - pkgs.openssl 114 - ]; 115 - script = '' 116 - openssl rand -hex 32 | tr -d '\n' > "$out/access-token" 117 - ''; 118 108 }; 119 109 120 110 # Signing key of the Android wrapper (packages.hermes-voice-android); only
+9 -30
tools/hermes-voice/static/app.js
··· 23 23 const approvalText = document.getElementById("approval-text"); 24 24 const voiceSelect = document.getElementById("voice-select"); 25 25 const VOICE_KEY = "hermes-voice-voice"; 26 - const TOKEN_KEY = "hermes-voice-token"; 27 - const loginForm = document.getElementById("login"); 28 - const tokenInput = document.getElementById("token"); 29 26 30 - // Every API route needs the page's access token; a 401 asks for it again. 27 + // The proxy in front adds the access token to logged-in requests. Once the 28 + // login expired it redirects to the login, which a reload follows. 31 29 async function api(path, options = {}) { 32 - const response = await fetch(path, { 33 - ...options, 34 - headers: { 35 - ...options.headers, 36 - Authorization: `Bearer ${localStorage.getItem(TOKEN_KEY) || ""}`, 37 - }, 38 - }); 39 - if (response.status === 401) { 40 - showLogin(); 41 - throw new Error("Sign in with the access token first"); 30 + const response = await fetch(path, { ...options, redirect: "manual" }); 31 + if (response.type === "opaqueredirect") { 32 + location.reload(); 33 + throw new Error("Login expired"); 42 34 } 43 35 return response; 44 36 } ··· 640 632 .getElementById("deny") 641 633 .addEventListener("click", () => call?.respondApproval("deny", null)); 642 634 643 - function showLogin() { 644 - loginForm.hidden = false; 645 - talk.disabled = true; 646 - setStatus("Sign in with the access token"); 647 - } 648 - 649 - loginForm.addEventListener("submit", (event) => { 650 - event.preventDefault(); 651 - localStorage.setItem(TOKEN_KEY, tokenInput.value.trim()); 652 - tokenInput.value = ""; 653 - loadConfig(); 654 - }); 655 - 656 635 voiceSelect.addEventListener("change", () => 657 636 localStorage.setItem(VOICE_KEY, voiceSelect.value), 658 637 ); ··· 662 641 try { 663 642 const response = await api("/config"); 664 643 config = await response.json(); 665 - } catch (_) { 666 - return; // 401 already showed the login form 644 + } catch (error) { 645 + setStatus(`Settings not loaded: ${error.message}`); 646 + return; 667 647 } 668 648 settings = config; 669 - loginForm.hidden = true; 670 649 talk.disabled = false; 671 650 setStatus("Idle"); 672 651 const saved = localStorage.getItem(VOICE_KEY);
-10
tools/hermes-voice/static/index.html
··· 18 18 <select id="voice-select" aria-label="Voice"></select> 19 19 <button id="new" type="button" class="link">New conversation</button> 20 20 </header> 21 - <form id="login" class="login" hidden> 22 - <input 23 - id="token" 24 - type="password" 25 - autocomplete="current-password" 26 - placeholder="Access token" 27 - required 28 - /> 29 - <button type="submit">Sign in</button> 30 - </form> 31 21 <button id="talk" type="button" class="talk">Talk</button> 32 22 <p id="status" class="status">Idle</p> 33 23 <p id="cost" class="cost"></p>
-30
tools/hermes-voice/static/style.css
··· 126 126 color: var(--live); 127 127 } 128 128 129 - /* .login sets display:flex, which would override the hidden attribute. */ 130 - [hidden] { 131 - display: none !important; 132 - } 133 - 134 - .login { 135 - display: flex; 136 - gap: 0.5rem; 137 - margin: 1rem 0; 138 - } 139 - 140 - .login input { 141 - flex: 1; 142 - padding: 0.5rem; 143 - background: #222; 144 - color: var(--fg); 145 - border: 1px solid #444; 146 - border-radius: 0.3rem; 147 - font: inherit; 148 - } 149 - 150 - .login button { 151 - padding: 0.5rem 1rem; 152 - border: none; 153 - border-radius: 0.3rem; 154 - background: var(--accent); 155 - color: #fff; 156 - font: inherit; 157 - } 158 - 159 129 .approval { 160 130 margin: 1rem 0; 161 131 padding: 0.8rem;