diff --git a/CLAUDE.md b/CLAUDE.md index f70d69a..6711ee8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,9 @@ nix-shell -p sops --run 'sops hosts//secrets.yaml' ### Deployment The Git runner handles deployment automatically when changes merge to main. A successful `./scripts/test-configuration ` dry-build is the gate before pushing. +### Host Access +When connecting to a host via SSH or any network tool, always use the fully-qualified `.cloonar.com` form (e.g. `web-arm.cloonar.com`, `mail.cloonar.com`). Bare hostnames do not resolve. + ## Custom Packages When creating a new package in `utils/pkgs/`, always include an `update.sh` script to automate version updates. See `utils/pkgs/claude-code/update.sh` for the pattern: diff --git a/hosts/nb/users/configs/project_history b/hosts/nb/users/configs/project_history index effdfe0..a8eb34c 100644 --- a/hosts/nb/users/configs/project_history +++ b/hosts/nb/users/configs/project_history @@ -19,6 +19,7 @@ /home/dominik/projects/cloonar/ai-image-alt /home/dominik/projects/cloonar/bookmap /home/dominik/projects/cloonar/iso-bot +/home/dominik/projects/macher.solutions/mengenkauf-backend /home/dominik/projects/home-automation/lego-hetzner-bridge /home/dominik/projects/home-automation/ghetto-nixos diff --git a/hosts/nb/users/dominik.nix b/hosts/nb/users/dominik.nix index 42bc68b..3f5a46b 100644 --- a/hosts/nb/users/dominik.nix +++ b/hosts/nb/users/dominik.nix @@ -614,6 +614,11 @@ in home.activation.projects = lib.hm.dag.entryAfter ["writeBoundary"] '' PATH="${pkgs.git}/bin:${pkgs.openssh}/bin:$PATH" set +eu + + mkdir -p ${persistHome}/projects/infrastructure + mkdir -p ${persistHome}/projects/cloonar + mkdir -p ${persistHome}/projects/macher.solutions + ssh-keygen -R git.cloonar.com ssh-keyscan git.cloonar.com >> ~/.ssh/known_hosts git clone forgejo@git.cloonar.com/infrastructure/ci-templates.git ${persistHome}/projects/infrastructure/ci-templates 2>/dev/null @@ -638,6 +643,7 @@ in git clone forgejo@git.cloonar.com:Cloonar/ai-image-alt.git ${persistHome}/projects/cloonar/ai-image-alt 2>/dev/null git clone forgejo@git.cloonar.com:Cloonar/bookmap.git ${persistHome}/projects/cloonar/bookmap 2>/dev/null git clone forgejo@git.cloonar.com/openclawd/iso-bot.git ${persistHome}/projects/cloonar/iso-bot 2>/dev/null + git clone forgejo@git.cloonar.com/macher.solutions/mengenkauf-backend.git ${persistHome}/projects/macher.solutions/mengenkauf-backend git clone forgejo@git.cloonar.com:dominik.polakovics/typo3-basic.git ${persistHome}/cloonar/typo3-basic 2>/dev/null diff --git a/hosts/web-arm/configuration.nix b/hosts/web-arm/configuration.nix index 6d5849a..17927db 100644 --- a/hosts/web-arm/configuration.nix +++ b/hosts/web-arm/configuration.nix @@ -11,7 +11,6 @@ ./modules/bitwarden ./modules/authelia.nix ./modules/collabora.nix - ./modules/ocis.nix ./modules/nextcloud ./modules/rustdesk.nix ./modules/postgresql.nix @@ -55,11 +54,6 @@ "openssl-1.1.1w" ]; - # oCIS (ownCloud Infinite Scale) has an unfree license - nixpkgs.config.allowUnfreePredicate = pkg: builtins.elem (lib.getName pkg) [ - "ocis_5-bin" - ]; - environment.systemPackages = with pkgs; [ vim davfs2 diff --git a/hosts/web-arm/modules/authelia.nix b/hosts/web-arm/modules/authelia.nix index ca73718..f16d583 100644 --- a/hosts/web-arm/modules/authelia.nix +++ b/hosts/web-arm/modules/authelia.nix @@ -169,14 +169,6 @@ in { oidc = { ## The other portions of the mandatory OpenID Connect 1.0 configuration go here. ## See: https://www.authelia.com/c/oidc - lifespans = { - custom = { - ocis = { - access_token = "2 days"; - refresh_token = "3 days"; - }; - }; - }; cors = { endpoints = [ "authorization" @@ -297,79 +289,6 @@ in { ]; userinfo_signing_algorithm = "none"; } - # oCIS (ownCloud Infinite Scale) - web client (public, PKCE) - { - id = "ocis"; - description = "ownCloud Infinite Scale"; - lifespan = "ocis"; - public = true; - authorization_policy = "internal"; - require_pkce = true; - pkce_challenge_method = "S256"; - redirect_uris = [ - "https://files.cloonar.com/" - "https://files.cloonar.com/oidc-callback.html" - "https://files.cloonar.com/oidc-silent-redirect.html" - "https://files.cloonar.com/apps/openidconnect/redirect" - ]; - scopes = [ "openid" "offline_access" "groups" "profile" "email" ]; - response_types = [ "code" ]; - grant_types = [ "authorization_code" "refresh_token" ]; - access_token_signed_response_alg = "none"; - userinfo_signing_algorithm = "none"; - token_endpoint_auth_method = "none"; - } - # oCIS Desktop - static credentials hardcoded in the oCIS desktop app - { - id = "xdXOt13JKxym1B1QcEncf2XDkLAexMBFwiT9j6EfhhHFJhs2KM9jbjTmf8JBXE69"; - description = "ownCloud Infinite Scale (Desktop)"; - secret = "$pbkdf2-sha512$310000$NR4tztBecptj1ZiITK/Ktw$GkFNBfq1B3T1lDTKMci1aO8iulQFNlEtfydLwTrNTKIfrQFjM7EiOBaHGOBC7ohPaNfYCRAYYzcP2fDQf5XRGQ"; - public = false; - authorization_policy = "internal"; - require_pkce = true; - pkce_challenge_method = "S256"; - redirect_uris = [ "http://127.0.0.1" "http://localhost" ]; - scopes = [ "openid" "offline_access" "groups" "profile" "email" ]; - response_types = [ "code" ]; - grant_types = [ "authorization_code" "refresh_token" ]; - access_token_signed_response_alg = "none"; - userinfo_signing_algorithm = "none"; - token_endpoint_auth_method = "client_secret_basic"; - } - # oCIS Android - static credentials hardcoded in the oCIS Android app - { - id = "e4rAsNUSIUs0lF4nbv9FmCeUkTlV9GdgTLDH1b5uie7syb90SzEVrbN7HIpmWJeD"; - description = "ownCloud Infinite Scale (Android)"; - secret = "$pbkdf2-sha512$310000$NjEumkph77Gql.CH0Oq3zg$I9ubOZ3VRCXPbHpW1U4bQmvLgP5DdiFeGgple2nIjtUJsFgkdiV/hcCt1h6adr1uvJSJAtHDRnMhYf3Zp2BpcQ"; - public = false; - authorization_policy = "internal"; - require_pkce = true; - pkce_challenge_method = "S256"; - redirect_uris = [ "oc://android.owncloud.com" ]; - scopes = [ "openid" "offline_access" "groups" "profile" "email" ]; - response_types = [ "code" ]; - grant_types = [ "authorization_code" "refresh_token" ]; - access_token_signed_response_alg = "none"; - userinfo_signing_algorithm = "none"; - token_endpoint_auth_method = "client_secret_basic"; - } - # oCIS iOS - static credentials hardcoded in the oCIS iOS app - { - id = "mxd5OQDk6es5LzOzRvidJNfXLUZS2oN3oUFeXPP8LpPrhx3UroJFduGEYIBOxkY1"; - description = "ownCloud Infinite Scale (iOS)"; - secret = "$pbkdf2-sha512$310000$.nIk0IUua7n8VAUoR85yyA$6UhT/gi7spH/0PRqTa6clz7QMRSmP/FZ0BDIumJupM4V2Ai6MgGKdzlEaNTc2IDqpGL3NxF626g4zAHFRgD7Zg"; - public = false; - authorization_policy = "internal"; - require_pkce = true; - pkce_challenge_method = "S256"; - redirect_uris = [ "oc://ios.owncloud.com" "oc.ios://ios.owncloud.com" ]; - scopes = [ "openid" "offline_access" "groups" "profile" "email" ]; - response_types = [ "code" ]; - grant_types = [ "authorization_code" "refresh_token" ]; - access_token_signed_response_alg = "none"; - userinfo_signing_algorithm = "none"; - token_endpoint_auth_method = "client_secret_basic"; - } ]; }; }; diff --git a/hosts/web-arm/modules/nextcloud/default.nix b/hosts/web-arm/modules/nextcloud/default.nix index ef2b59f..5492839 100644 --- a/hosts/web-arm/modules/nextcloud/default.nix +++ b/hosts/web-arm/modules/nextcloud/default.nix @@ -10,14 +10,14 @@ in enable = true; hostName = "nextcloud.cloonar.com"; https = true; - package = pkgs.nextcloud32; + package = pkgs.nextcloud33; # Instead of using pkgs.nextcloud27Packages.apps, # we'll reference the package version specified above extraApps = { inherit (config.services.nextcloud.package.packages.apps) calendar contacts deck groupfolders mail richdocuments tasks; oidc_login = pkgs.fetchNextcloudApp rec { - url = "https://github.com/pulsejet/nextcloud-oidc-login/releases/download/v3.2.5/oidc_login.tar.gz"; - sha256 = "sha256-Qtqcw1OspTHg0QRIgDMxNru6ZGL8y5XhJ5gdgqn6/Wc="; + url = "https://github.com/pulsejet/nextcloud-oidc-login/releases/download/v3.3.1/oidc_login.tar.gz"; + sha256 = "sha256-KBa8A7aC0uS6FQoOSa7nIkaaYe+A2KeAtzfqoKw0Gn4="; license = "gpl3"; }; }; diff --git a/hosts/web-arm/modules/ocis.nix b/hosts/web-arm/modules/ocis.nix deleted file mode 100644 index 90c0253..0000000 --- a/hosts/web-arm/modules/ocis.nix +++ /dev/null @@ -1,93 +0,0 @@ -{ config, lib, pkgs, ... }: - -{ - sops.secrets.ocis-admin-password = { - owner = "ocis"; - }; - - # Upstream services.ocis module adds ReadOnlyPaths = [ configDir ] to the - # systemd unit, which makes systemd fail the namespace setup if the path - # does not exist, and it never runs `ocis init` to populate ocis.yaml with - # the service's internal secrets. Run init in a separate oneshot so the - # sandbox restrictions of ocis.service don't block writes to configDir. - systemd.services.ocis-init = { - description = "Initialize oCIS config (one-shot)"; - before = [ "ocis.service" ]; - requiredBy = [ "ocis.service" ]; - wantedBy = [ "multi-user.target" ]; - - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - User = "ocis"; - Group = "ocis"; - StateDirectory = "ocis"; - LoadCredential = "admin-password:${config.sops.secrets.ocis-admin-password.path}"; - }; - - script = '' - install -d -m 0700 /var/lib/ocis/config - if [ ! -f /var/lib/ocis/config/ocis.yaml ]; then - ${lib.getExe pkgs.ocis_5-bin} init \ - --config-path /var/lib/ocis/config \ - --admin-password "$(cat "$CREDENTIALS_DIRECTORY/admin-password")" \ - --insecure true - fi - ''; - }; - - services.ocis = { - enable = true; - url = "https://files.cloonar.com"; - address = "127.0.0.1"; - port = 9200; - stateDir = "/var/lib/ocis"; - configDir = "/var/lib/ocis/config"; - environment = { - # Proxy - SSL terminated at nginx - PROXY_TLS = "false"; - OCIS_INSECURE = "false"; - - # OIDC - Authelia - PROXY_OIDC_ISSUER = "https://auth.cloonar.com"; - PROXY_OIDC_REWRITE_WELLKNOWN = "true"; - PROXY_OIDC_ACCESS_TOKEN_VERIFY_METHOD = "none"; - PROXY_OIDC_SKIP_USER_INFO = "false"; - WEB_OIDC_CLIENT_ID = "ocis"; - - # Auto-provision user accounts from OIDC claims - PROXY_AUTOPROVISION_ACCOUNTS = "true"; - PROXY_AUTOPROVISION_CLAIM_USERNAME = "preferred_username"; - PROXY_AUTOPROVISION_CLAIM_EMAIL = "email"; - PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME = "name"; - PROXY_AUTOPROVISION_CLAIM_GROUPS = "groups"; - - # Disable demo users - IDM_CREATE_DEMO_USERS = "false"; - - # Move internal services off their defaults where Prometheus exporters - # already bind on this host: - # - node-exporter owns 9100 (oCIS web default) - # - blackbox-exporter owns 9115 (oCIS webdav default) - WEB_HTTP_ADDR = "127.0.0.1:19100"; - WEBDAV_HTTP_ADDR = "127.0.0.1:19115"; - }; - }; - - # Nginx reverse proxy - services.nginx.virtualHosts."files.cloonar.com" = { - forceSSL = true; - enableACME = true; - acmeRoot = null; - - locations."/" = { - proxyPass = "http://127.0.0.1:9200"; - proxyWebsockets = true; - extraConfig = '' - client_max_body_size 10G; - proxy_read_timeout 600s; - proxy_send_timeout 600s; - ''; - }; - }; -} diff --git a/hosts/web-arm/modules/supabase/default.nix b/hosts/web-arm/modules/supabase/default.nix index 19df8bb..4519e7e 100644 --- a/hosts/web-arm/modules/supabase/default.nix +++ b/hosts/web-arm/modules/supabase/default.nix @@ -74,6 +74,27 @@ in ExecStart = "${envGenerateScript} ${config.sops.secrets.supabase-env.path}"; }; }; + # Seed the edge-runtime's bootstrap `main` function. The container's + # entrypoint requires `/home/deno/functions/main/index.ts` to exist; + # without it edge-runtime fails with "could not find an appropriate + # entrypoint". Re-seed on every activation so updates to the bootstrap + # are picked up, while leaving user-authored functions untouched. + supabase-functions-seed = { + description = "Seed Supabase edge-functions main bootstrap"; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + }; + script = '' + install -d -m 0755 /var/lib/supabase/functions/main + install -m 0644 ${./functions/main/index.ts} /var/lib/supabase/functions/main/index.ts + ''; + }; + podman-supabase-functions = { + after = [ "supabase-functions-seed.service" ]; + requires = [ "supabase-functions-seed.service" ]; + }; } ]); diff --git a/hosts/web-arm/modules/supabase/functions/main/index.ts b/hosts/web-arm/modules/supabase/functions/main/index.ts new file mode 100644 index 0000000..05b6ad6 --- /dev/null +++ b/hosts/web-arm/modules/supabase/functions/main/index.ts @@ -0,0 +1,144 @@ +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const SUPABASE_URL = Deno.env.get('SUPABASE_URL') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +// Create JWKS for ES256/RS256 tokens (newer tokens) +let SUPABASE_JWT_KEYS: ReturnType | null = null +if (SUPABASE_URL) { + try { + SUPABASE_JWT_KEYS = jose.createRemoteJWKSet( + new URL('/auth/v1/.well-known/jwks.json', SUPABASE_URL) + ) + } catch (e) { + console.error('Failed to fetch JWKS from SUPABASE_URL:', e) + } +} + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function isValidLegacyJWT(jwt: string): Promise { + if (!JWT_SECRET) { + console.error('JWT_SECRET not available for HS256 token verification') + return false + } + + const encoder = new TextEncoder(); + const secretKey = encoder.encode(JWT_SECRET) + + try { + await jose.jwtVerify(jwt, secretKey); + } catch (e) { + console.error('Symmetric Legacy JWT verification error', e); + return false; + } + return true; +} + +async function isValidJWT(jwt: string): Promise { + if (!SUPABASE_JWT_KEYS) { + console.error('JWKS not available for ES256/RS256 token verification') + return false + } + + try { + await jose.jwtVerify(jwt, SUPABASE_JWT_KEYS) + } catch (e) { + console.error('Asymmetric JWT verification error', e); + return false + } + + return true; +} + +async function isValidHybridJWT(jwt: string): Promise { + const { alg: jwtAlgorithm } = jose.decodeProtectedHeader(jwt) + + if (jwtAlgorithm === 'HS256') { + console.log(`Legacy token type detected, attempting ${jwtAlgorithm} verification.`) + + return await isValidLegacyJWT(jwt) + } + + if (jwtAlgorithm === 'ES256' || jwtAlgorithm === 'RS256') { + return await isValidJWT(jwt) + } + + return false; +} + +Deno.serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await isValidHybridJWT(token); + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/hosts/web-arm/sites/fueltide.io.nix b/hosts/web-arm/sites/fueltide.io.nix index 9239883..2186673 100644 --- a/hosts/web-arm/sites/fueltide.io.nix +++ b/hosts/web-arm/sites/fueltide.io.nix @@ -1,4 +1,19 @@ { pkgs, lib, config, ... }: +let + # Universal Links / Associated Domains for the iOS workout app + appleAppSiteAssociation = { + applinks = { + details = [ + { + appIDs = [ "XWJ4DC7TBH.io.fueltide.workout" ]; + components = [ + { "/" = "/auth/*"; } + ]; + } + ]; + }; + }; +in { # SOPS secret for fueltide.io DNS credentials (separate Hetzner API token) sops.secrets.fueltide-lego-credentials = { }; @@ -17,6 +32,10 @@ credentialsFile = config.sops.secrets.fueltide-lego-credentials.path; }; + security.acme.certs."link.fueltide.io" = { + credentialsFile = config.sops.secrets.fueltide-lego-credentials.path; + }; + security.acme.certs."stage.fueltide.io" = { credentialsFile = config.sops.secrets.fueltide-lego-credentials.path; }; @@ -25,6 +44,16 @@ credentialsFile = config.sops.secrets.fueltide-lego-credentials.path; }; + services.nginx.virtualHosts."link.fueltide.io" = { + enableACME = true; + forceSSL = true; + + locations."= /.well-known/apple-app-site-association".extraConfig = '' + default_type application/json; + return 200 '${builtins.toJSON appleAppSiteAssociation}'; + ''; + }; + services.webstack.instances."stage.fueltide.io" = { enablePhp = false; enableDefaultLocations = false; diff --git a/utils/pkgs/claude-code/default.nix b/utils/pkgs/claude-code/default.nix index 352cf7a..109c5b3 100644 --- a/utils/pkgs/claude-code/default.nix +++ b/utils/pkgs/claude-code/default.nix @@ -1,11 +1,11 @@ { lib, pkgs, runCommand, claude-code }: let - version = "2.1.75"; + version = "2.1.111"; src = pkgs.fetchzip { url = "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-${version}.tgz"; - hash = "sha256-EgwxqiCl7c8PoRYyHDvcgvK8txDd0XJeZD1vybZyp4E="; + hash = "sha256-K3qhZXVJ2DIKv7YL9f/CHkuUYnK0lkIR1wjEa+xeSCk="; }; # Create a modified source with our package-lock.json @@ -22,7 +22,7 @@ in npmDeps = pkgs.fetchNpmDeps { src = srcWithLock; - hash = "sha256-DIyV2ZyrEI+iCOb4VcKbdd6NWyFqRUpH/rKv/HvCcG8="; + hash = "sha256-EFh1nVImvToqY+scUcodg50emRpF6Rzi+AJNPi59AVY="; }; # Remove the old postPatch since srcWithLock already includes package-lock.json diff --git a/utils/pkgs/claude-code/package-lock.json b/utils/pkgs/claude-code/package-lock.json index 8a99d45..7aeec21 100644 --- a/utils/pkgs/claude-code/package-lock.json +++ b/utils/pkgs/claude-code/package-lock.json @@ -5,13 +5,13 @@ "packages": { "": { "dependencies": { - "@anthropic-ai/claude-code": "^2.1.75" + "@anthropic-ai/claude-code": "^2.1.111" } }, "node_modules/@anthropic-ai/claude-code": { - "version": "2.1.75", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.75.tgz", - "integrity": "sha512-K5xwpR53WfZcKXhQedgoxcRbi6arH7S9YrhVXifA3klZ/5L4zcv+wENzITISygm1MwbxX5w2c2N1Qd/oUSY42w==", + "version": "2.1.111", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-2.1.111.tgz", + "integrity": "sha512-zNZcINqvtMpDM4lZqnOZcrou57k9rUBfCZziH47nMG9FNks7I6azN7+SMU3zhwqBwYrvx6o4i7Ecu7mDCi0AmA==", "license": "SEE LICENSE IN README.md", "bin": { "claude": "cli.js"