From 28bb03187d8c20889a0775c5a76af3d9a815b143 Mon Sep 17 00:00:00 2001 From: muon Date: Mon, 1 Jun 2026 10:28:16 +0000 Subject: [PATCH] Add auth2api --- _sources/generated.json | 21 +++ _sources/generated.nix | 12 ++ hosts/muon/configuration.nix | 5 + modules/nixos/server/auth2api.nix | 206 ++++++++++++++++++++++++++++++ modules/nixos/server/default.nix | 1 + modules/nixos/sops/secrets.yaml | 22 ++-- nvfetcher.toml | 5 + pkgs/auth2api/no-auth.patch | 30 +++++ pkgs/auth2api/package.nix | 44 +++++++ 9 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 modules/nixos/server/auth2api.nix create mode 100644 pkgs/auth2api/no-auth.patch create mode 100644 pkgs/auth2api/package.nix diff --git a/_sources/generated.json b/_sources/generated.json index 1505074..526f008 100644 --- a/_sources/generated.json +++ b/_sources/generated.json @@ -1,4 +1,25 @@ { + "auth2api": { + "cargoLock": null, + "date": "2026-05-10", + "extract": null, + "name": "auth2api", + "passthru": null, + "pinned": false, + "src": { + "deepClone": false, + "fetchSubmodules": false, + "leaveDotGit": false, + "name": null, + "owner": "AmazingAng", + "repo": "auth2api", + "rev": "840fa100e71a3562552cc7d0267f7668db0d7f86", + "sha256": "sha256-Pz+V1QKbZZKfoZz2TbC4yLtwAdaUIWdLV8FJh8LohTc=", + "sparseCheckout": [], + "type": "github" + }, + "version": "840fa100e71a3562552cc7d0267f7668db0d7f86" + }, "dcts-client-shipping": { "cargoLock": null, "date": null, diff --git a/_sources/generated.nix b/_sources/generated.nix index 63053fb..6957ab8 100644 --- a/_sources/generated.nix +++ b/_sources/generated.nix @@ -6,6 +6,18 @@ dockerTools, }: { + auth2api = { + pname = "auth2api"; + version = "840fa100e71a3562552cc7d0267f7668db0d7f86"; + src = fetchFromGitHub { + owner = "AmazingAng"; + repo = "auth2api"; + rev = "840fa100e71a3562552cc7d0267f7668db0d7f86"; + fetchSubmodules = false; + sha256 = "sha256-Pz+V1QKbZZKfoZz2TbC4yLtwAdaUIWdLV8FJh8LohTc="; + }; + date = "2026-05-10"; + }; dcts-client-shipping = { pname = "dcts-client-shipping"; version = "v3.3"; diff --git a/hosts/muon/configuration.nix b/hosts/muon/configuration.nix index 890969f..cf4d88c 100644 --- a/hosts/muon/configuration.nix +++ b/hosts/muon/configuration.nix @@ -59,6 +59,11 @@ in { mods.docker.enable = true; mods.docker.media.enable = false; + mods.server.auth2api.enable = true; + # host defaults to 127.0.0.1 (localhost only, perfect for Claude Code) + # port defaults to 8317 + # Run `auth2api --login` once after deploying to authorise your Claude account. + mods.server.sync.enable = true; mods.tailscale.enable = true; mods.openvpn.enable = false; diff --git a/modules/nixos/server/auth2api.nix b/modules/nixos/server/auth2api.nix new file mode 100644 index 0000000..22ebf80 --- /dev/null +++ b/modules/nixos/server/auth2api.nix @@ -0,0 +1,206 @@ +{ + pkgs, + lib, + config, + sources, + ... +}: +let + cfg = config.mods.server.auth2api; + + auth2api-pkg = pkgs.callPackage ../../../pkgs/auth2api/package.nix { inherit sources; }; + + # Wrap the binary so --config is always implicit, whether run by the + # service, by the user for --login, or any other invocation. + auth2api-wrapped = pkgs.symlinkJoin { + name = "auth2api-wrapped"; + paths = [ auth2api-pkg ]; + buildInputs = [ pkgs.makeWrapper ]; + postBuild = '' + wrapProgram $out/bin/auth2api \ + --add-flags "--config=${cfg.stateDir}/config.yaml" \ + --run "umask 027" + ''; + }; + + # Nix-managed config template — read-only in /nix/store. + # systemd-tmpfiles copies it into stateDir on first boot (C rule), + # so auth2api can write back to it (e.g. API key auto-generation) + # and the user can hand-edit it freely without rebuilds overwriting it. + configTemplate = pkgs.writeText "auth2api-config-template.yaml" '' + host: "${cfg.host}" + port: ${toString cfg.port} + + # Directory where OAuth token files are stored (written by --login). + auth-dir: "${cfg.stateDir}/tokens" + + # Empty = unauthenticated mode (safe when binding to 127.0.0.1). + api-keys: [] + + body-limit: "${cfg.bodyLimit}" + + timeouts: + messages-ms: ${toString cfg.timeouts.messagesMs} + stream-messages-ms: ${toString cfg.timeouts.streamMessagesMs} + count-tokens-ms: ${toString cfg.timeouts.countTokensMs} + + stats: + enabled: true + + # off | errors | verbose + debug: "${cfg.debug}" + + cloaking: + cli-version: "2.1.88" + entrypoint: "cli" + ''; +in +with lib; { + options.mods.server.auth2api = { + enable = mkEnableOption "auth2api OAuth-to-API proxy"; + + port = mkOption { + type = types.port; + default = 8317; + description = "Port auth2api listens on."; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Bind address. Defaults to localhost — safe with no API key."; + }; + + stateDir = mkOption { + type = types.str; + default = "/var/lib/auth2api"; + description = '' + State directory. Contains config.yaml (live, writable) and + tokens/ (OAuth token files written by --login). + ''; + }; + + bodyLimit = mkOption { + type = types.str; + default = "200mb"; + description = "Maximum JSON request body size."; + }; + + timeouts = { + messagesMs = mkOption { + type = types.int; + default = 120000; + description = "Non-streaming /v1/messages timeout (ms)."; + }; + streamMessagesMs = mkOption { + type = types.int; + default = 600000; + description = "Streaming /v1/messages timeout (ms)."; + }; + countTokensMs = mkOption { + type = types.int; + default = 30000; + description = "/v1/messages/count_tokens timeout (ms)."; + }; + }; + + debug = mkOption { + type = types.enum [ "off" "errors" "verbose" ]; + default = "off"; + description = "Log level: off | errors | verbose."; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = "Open the firewall for the auth2api port."; + }; + }; + + config = mkIf cfg.enable { + # tmpfiles runs as root before any service starts. + # 'd' creates the directory if missing; 'C' copies the template into + # config.yaml only if that file doesn't already exist — so hand-edits + # and auth2api's own writes are never clobbered on rebuild. + systemd.tmpfiles.rules = [ + # stateDir: world-traversable so any local user can reach files inside + "d '${cfg.stateDir}' 0755 auth2api auth2api - -" + "z '${cfg.stateDir}' 0755 auth2api auth2api - -" + # tokens: group-writable so members of the auth2api group can run --login + "d '${cfg.stateDir}/tokens' 0770 auth2api auth2api - -" + "z '${cfg.stateDir}/tokens' 0770 auth2api auth2api - -" + # config.yaml: contains no secrets (empty api-keys), safe to be world-readable + "C '${cfg.stateDir}/config.yaml' 0644 auth2api auth2api - ${configTemplate}" + "z '${cfg.stateDir}/config.yaml' 0644 auth2api auth2api - -" + ]; + + # auth2api.path watches the tokens directory and activates auth2api.service + # the moment a token file appears (i.e. after --login has been run). + # This means the service is never started — and never fails — during + # activation on a fresh machine, so nixos-rebuild always succeeds cleanly. + systemd.paths.auth2api = { + description = "Watch for auth2api OAuth tokens"; + wantedBy = [ "multi-user.target" ]; + after = [ "systemd-tmpfiles-setup.service" ]; + + pathConfig = { + # Trigger when any file is created inside the tokens directory. + DirectoryNotEmpty = "${cfg.stateDir}/tokens"; + # Re-trigger if the service stops (e.g. after a logout / token removal). + Unit = "auth2api.service"; + }; + }; + + systemd.services.auth2api = { + description = "auth2api OAuth-to-API proxy"; + # Started by the path unit, not directly by multi-user.target. + # Must wait for tmpfiles so config.yaml is always present before start. + after = [ "network.target" "systemd-tmpfiles-setup.service" ]; + requires = [ "systemd-tmpfiles-setup.service" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${auth2api-wrapped}/bin/auth2api"; + + Restart = "on-failure"; + RestartSec = "10s"; + + User = "auth2api"; + Group = "auth2api"; + + ReadWritePaths = [ cfg.stateDir ]; + + # Hardening + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectControlGroups = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ]; + RestrictNamespaces = true; + LockPersonality = true; + MemoryDenyWriteExecute = false; # Node.js JIT requires W+X pages + RestrictRealtime = true; + RestrictSUIDSGID = true; + }; + }; + + users.users.auth2api = { + isSystemUser = true; + group = "auth2api"; + home = cfg.stateDir; + description = "auth2api service user"; + }; + users.groups.auth2api = { }; + + # Allow the primary user to run --login and read/write tokens. + users.users.${config.mods.user.name}.extraGroups = [ "auth2api" ]; + + # Make the wrapped binary available in PATH for `auth2api --login` etc. + environment.systemPackages = [ auth2api-wrapped ]; + + networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall [ cfg.port ]; + }; +} diff --git a/modules/nixos/server/default.nix b/modules/nixos/server/default.nix index a291e7d..8d9d4e8 100644 --- a/modules/nixos/server/default.nix +++ b/modules/nixos/server/default.nix @@ -30,5 +30,6 @@ ./audio.nix ./atuin.nix ./murmur.nix + ./auth2api.nix ]; } diff --git a/modules/nixos/sops/secrets.yaml b/modules/nixos/sops/secrets.yaml index af4ed6a..f8ce3c2 100644 --- a/modules/nixos/sops/secrets.yaml +++ b/modules/nixos/sops/secrets.yaml @@ -5,8 +5,7 @@ lemmy-password: ENC[AES256_GCM,data:VVPbhW6l+VYSUfmlySPSwITwonKQHaIY,iv:XcwM7Sz2 sops-key: ENC[AES256_GCM,data:CT2FJnxRV0nVccCS+bofjIDqoVnJKMs63BVdmC4KEXEJAdsiyINTNJ+19aMqIkr2eosvXX1+nvV6oeBvNv1uN9xCrrzu4Qj0yRA=,iv:w9Fp68KK8hnUirlDGOYKSQwlfp3OBWU4XWqliZn/apc=,tag:XZdhC65WpcazSol1mbdp5A==,type:str] sops: age: - - recipient: age1m97a3eptxwpdd7h5kkqe9gkmhg6rquc64qjmlsfqfhfqv8q72crqrylhgc - enc: | + - enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB4bUg1Z1JBcmRldDIzN2Zt Ky9LOTVBK0IzdE1UUFBXci94R0x1bitjT2hjCjA1NC9wMzNHZkorZllIaFpNMVlm @@ -14,8 +13,8 @@ sops: eWlTRmEzYVpQdENiNUMxaWJta0NjcVEKx3togykPGYRNGgJR6fl9cDbJKiLWHjA9 XujrttnDTwNCCZENn/E4BABC4XecW8IqSsUmJW6GwZzYJu+4rNTSwA== -----END AGE ENCRYPTED FILE----- - - recipient: age1v4s4hg7u3vjjkarvrk7v6ev7w3wja2r5xm7f4t06culw3fuq7qns8sfju7 - enc: | + recipient: age1m97a3eptxwpdd7h5kkqe9gkmhg6rquc64qjmlsfqfhfqv8q72crqrylhgc + - enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWWE5tU0ltaTJscUVQSDBy WHRDb2FTRVFtZ2s2eGRjb21ncU1HNkx3RmhRClMwQ0E1cCt1SmtoYi9TWExXdVdX @@ -23,8 +22,8 @@ sops: Qm5yVjBNc1l6VFQ4OGJsWXdsWUIyNFkKksIW0x8RxTdaw9YR4y+84VrYnfVZz2js qz1RG4TXs9NRcm8fGGa/ZYZZN72h/l0WY+fayZ+ZUaHD43tHFisoYg== -----END AGE ENCRYPTED FILE----- - - recipient: age1n7qz2w3hkf7fcdv92kxw9k6uef487na2tlc87486rcjwj8lyfuws5q46gn - enc: | + recipient: age1v4s4hg7u3vjjkarvrk7v6ev7w3wja2r5xm7f4t06culw3fuq7qns8sfju7 + - enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB1L29jY3lNeU8xeE03VUFu MWJyczNxZFJHSG82c0p3OEtBOThqaE8xTFMwCm9KemZJMjBOQ0I1TU9Qd2IvMGVU @@ -32,8 +31,8 @@ sops: UXp0a3AwM0hvbG1jeEZIMlViYU9ZWTgKKJ2YL6Q2LyR9x4Oqt5qWiyL7f4wAWrqw FTY5r2unI7YdIFtzmbjIAqv/4qqy62Th8EEsqAZUcL/YBcuNIiyg6Q== -----END AGE ENCRYPTED FILE----- - - recipient: age1mgjhkqy9x27gv2t2xvq46dxcajkr9c8zes7rr3dj0ac7md2j6vas43dftp - enc: | + recipient: age1n7qz2w3hkf7fcdv92kxw9k6uef487na2tlc87486rcjwj8lyfuws5q46gn + - enc: | -----BEGIN AGE ENCRYPTED FILE----- YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBJOUhRY0RtaUhNaHZZTHk3 QVF4cXV2Lzc3d1RRM2pzMXBBQU95endLRFFZCkdMVVlkV3VzSnRyRHpROHlReUdJ @@ -41,7 +40,8 @@ sops: VFdIbUg1WjlldFFNbGx3dytQNXBsMDgKuU/86fojKVJ5X8+9OIf3k7ud6bujjyFI HQoONJgXGoQJtkPsmJbMUuMjo/znK+tdCd/uAwxK1Nk670NVxGmJYA== -----END AGE ENCRYPTED FILE----- - lastmodified: "2025-08-04T09:14:07Z" - mac: ENC[AES256_GCM,data:Qu5kuhV2c31S9l01e7IWCrjLKU8eBepK42eR1nEvPpoHqXxbIT3vcDbxJdcn2Ay6Z4pARYqmHctVDOCiilxFyYfzF8mP91u6NhsZC5kHMdP7GI5Pl5FXSCMxQbbBWgXxJXruq/NkrlrLnFTWyzBRLa4wTBZdDMZ2CGo6jLi7G0o=,iv:q3WG536FkLpYEp8AAcW0agYq6rDIhzzt47l7grDvGyo=,tag:T5msy2cSZ/bZ9HvbxTw0Rg==,type:str] + recipient: age1mgjhkqy9x27gv2t2xvq46dxcajkr9c8zes7rr3dj0ac7md2j6vas43dftp + lastmodified: "2026-06-01T08:59:15Z" + mac: ENC[AES256_GCM,data:DtHxyzG5d+USGmfFg6vYzk5NIfHPSq/5CKTBAXuipW1pD4WCFs85cugzs8fctS0+yaBL1It72YxSTzMw43kGGzSjn9Uy5AGoZnhLAw6E7CO+4D6FCkV4Ui83Ku+UMQ/klMDN3KFl7aL0NpAcsjsvyQeSHwBQvXF7oSXDc/wbc6E=,iv:bpbKsNEu+jrKMz6gIBTEEyH8GuVRKvVSS/vJJ4l/npI=,tag:/bb34cXzy/k957K1X6PLHw==,type:str] unencrypted_suffix: _unencrypted - version: 3.10.2 + version: 3.13.0 diff --git a/nvfetcher.toml b/nvfetcher.toml index a10a48d..2fb66ab 100644 --- a/nvfetcher.toml +++ b/nvfetcher.toml @@ -165,3 +165,8 @@ fetch.github = "mendersoftware/mender-cli" src.github = "hackthedev/dcts-client-shipping" fetch.github = "hackthedev/dcts-client-shipping" +["auth2api"] +src.git = "https://github.com/AmazingAng/auth2api" +src.branch = "main" +fetch.github = "AmazingAng/auth2api" + diff --git a/pkgs/auth2api/no-auth.patch b/pkgs/auth2api/no-auth.patch new file mode 100644 index 0000000..e0b16bc --- /dev/null +++ b/pkgs/auth2api/no-auth.patch @@ -0,0 +1,30 @@ +--- a/src/config.ts ++++ b/src/config.ts +@@ -137,15 +137,7 @@ + + raw.debug = normalizeDebugMode(raw.debug); + +- // Auto-generate API key if none configured +- if (!raw["api-keys"] || raw["api-keys"].length === 0) { +- const key = generateApiKey(); +- raw["api-keys"] = [key]; +- fs.writeFileSync(filePath, yaml.dump(raw, { lineWidth: -1 }), { +- mode: 0o600, +- }); +- console.log(`\nGenerated API key (saved to ${filePath}):\n\n ${key}\n`); +- } ++ // Empty api-keys = unauthenticated mode (safe when binding to localhost). + + return { ...raw, "api-keys": new Set(raw["api-keys"]) }; + } +--- a/src/server.ts ++++ b/src/server.ts +@@ -94,6 +94,8 @@ + // API key auth middleware — accepts both OpenAI style (Authorization: Bearer) + // and Anthropic style (x-api-key), so Claude Code and OpenAI clients both work + const requireApiKey: express.RequestHandler = (req, res, next) => { ++ // No keys configured — unauthenticated mode (localhost-only). ++ if (config["api-keys"].size === 0) { next(); return; } + const key = extractApiKey(req.headers); + if (!key) { + res.status(401).json({ error: { message: "Missing API key" } }); diff --git a/pkgs/auth2api/package.nix b/pkgs/auth2api/package.nix new file mode 100644 index 0000000..71abbe0 --- /dev/null +++ b/pkgs/auth2api/package.nix @@ -0,0 +1,44 @@ +{ + lib, + buildNpmPackage, + nodejs_20, + makeWrapper, + sources, +}: + +buildNpmPackage { + pname = "auth2api"; + version = sources.auth2api.version; + src = sources.auth2api.src; + + nodejs = nodejs_20; + + npmDepsHash = "sha256-lHwY5MQ0nRoOPcURzmJCiXiUxEx9ZwZJSWKbkD4ZuIA="; + + # Patch to allow running with an empty api-keys list (unauthenticated). + # Safe because the service binds to 127.0.0.1 by default. + patches = [ ./no-auth.patch ]; + + # auth2api's build script is `tsc` (TypeScript compile → dist/) + # devDeps (typescript, tsx) are needed for the build; buildNpmPackage + # prunes them automatically after the build step completes. + npmBuildScript = "build"; + + nativeBuildInputs = [ makeWrapper ]; + + # buildNpmPackage installs the package under $out/lib/node_modules/auth2api/. + # Wire up a $out/bin/auth2api wrapper pointing at the compiled entry-point. + postInstall = '' + makeWrapper ${nodejs_20}/bin/node $out/bin/auth2api \ + --add-flags "$out/lib/node_modules/auth2api/dist/index.js" + ''; + + meta = { + description = "Lightweight Claude OAuth to OpenAI-compatible API proxy"; + homepage = "https://github.com/AmazingAng/auth2api"; + license = lib.licenses.mit; + maintainers = [ ]; + platforms = lib.platforms.linux; + mainProgram = "auth2api"; + }; +}