diff --git a/machines/prattle/default.nix b/machines/prattle/default.nix index bfc93a8..99b53fd 100644 --- a/machines/prattle/default.nix +++ b/machines/prattle/default.nix @@ -327,6 +327,19 @@ # share the host clock) and keeps such a runtime from choking on it. virtualisation.docker.daemon.settings.features.time-namespaces = false; + # The identity kloe (on terebithia) lands as. It exists here only to hold a + # login and the docker group; the tailnet ACL is what decides who may become + # it, and docker's `dial-stdio` needs a shell to be spawned from. + users.groups.kloe = { }; + users.users.kloe = { + isSystemUser = true; + group = "kloe"; + extraGroups = [ "docker" ]; + home = "/var/lib/kloe"; + createHome = true; + shell = pkgs.bash; + }; + # ── ARM (Automatic Ripping Machine) ─────────────────────────────── atelier.services.arm = { enable = true; diff --git a/machines/terebithia/default.nix b/machines/terebithia/default.nix index bc0ff6a..accdc93 100644 --- a/machines/terebithia/default.nix +++ b/machines/terebithia/default.nix @@ -181,6 +181,10 @@ file = ../../secrets/lard.age; owner = "lard"; }; + kloe = { + file = ../../secrets/kloe.age; + owner = "kloe"; + }; paperless-oidc = { file = ../../secrets/paperless-oidc.age; owner = "paperless"; @@ -550,6 +554,86 @@ collectorClientId = "ikc_NEil8GK01UX2O9AvbcDrv"; }; + atelier.services.kloe = { + enable = true; + domain = "kloe.dunkirk.sh"; + repository = "https://github.com/taciturnaxolotl/kloe"; + secretsFile = config.age.secrets.kloe.path; + healthUrl = "https://kloe.dunkirk.sh/health"; + + settings = { + auth = { + enabled = true; + issuer = "https://indiko.dunkirk.sh"; + clientId = "ikc_cskXitSS6XFSDzvyq3NBA"; + clientSecret = "$KLOE_CLIENT_SECRET"; + allowedSubs = [ "https://dunkirk.sh/" ]; + }; + + lard = { + enabled = true; + baseUrl = "https://lard.dunkirk.sh"; + }; + + search = { + backends = [ + { + provider = "exa"; + apiKey = "$EXA_API_KEY"; + searchType = "auto"; + } + { + provider = "ceramic"; + apiKey = "$CERAMIC_API_KEY"; + } + ]; + maxResults = 5; + }; + + fetch.renderer = { + provider = "flaresolverr"; + endpoint = "https://flaresolver.dunkirk.sh/v1"; + timeoutMs = 60000; + }; + + # Runs on prattle's docker under gVisor, reached over Tailscale SSH as + # the kloe user declared there. The tailnet ACL is the thing that has to + # allow the hop; nothing here can grant it. + sandbox = { + enabled = true; + image = "buildpack-deps:bookworm-scm"; + runtime = "runsc"; + dockerHost = "ssh://kloe@prattle"; + network = true; + }; + + providers = [ + { + id = "hyper"; + apiKey = "$HYPER_API_KEY"; + apiEndpoint = "https://hyper.charm.land/v1"; + type = "hyper"; + maxConcurrency = 4; + } + { + id = "llmsolutions"; + apiKey = "$LLMSOLUTIONS_API_KEY"; + apiEndpoint = "https://llmsolutions.top/v1"; + type = "openai-compat"; + maxConcurrency = 4; + models = [ + { + id = "deepseek-v4-flash-0731"; + name = "DeepSeek V4 Flash (llmsolutions)"; + context_window = 1048576; + default_max_tokens = 32768; + } + ]; + } + ]; + }; + }; + atelier.services.tangled = { enable = true; owner = "did:plc:krxbvxvis5skq7jj6eot23ul"; diff --git a/modules/nixos/services/kloe.nix b/modules/nixos/services/kloe.nix new file mode 100644 index 0000000..e2fd32d --- /dev/null +++ b/modules/nixos/services/kloe.nix @@ -0,0 +1,121 @@ +# Kloe — server-authoritative LLM chat (bun + sqlite + SSE) +# +# Deployed the usual way: mkService scaffolds the user/dirs/unit/vhost and +# clones once, the repo's own GitHub Action pushes every later revision. +# +# The one wrinkle is config. kloe reads a single validated `kloe.json` +# (src/settings.ts) whose path comes from KLOE_CONFIG, and that file is +# gitignored — a deploy that `git reset --hard`s the app dir must not be the +# thing that carries it. So the file is generated here from `settings` and +# handed over by path, and every credential inside it stays a `$VAR` reference +# resolved at load time from the agenix EnvironmentFile. Nothing secret reaches +# the world-readable nix store. + +{ + config, + lib, + pkgs, + ... +}: + +let + mkService = import ../../lib/mkService.nix; + + # Paths are the module's business, not the operator's: they follow dataDir so + # the backup declarations below and the running service can never disagree. + configFile = + cfg: + pkgs.writeText "kloe.json" ( + builtins.toJSON ( + lib.recursiveUpdate cfg.settings { + server = { + port = cfg.port; + dbPath = "${cfg.dataDir}/data/kloe.db"; + }; + blobs.path = "${cfg.dataDir}/data/blobs"; + catwalk.cachePath = "${cfg.dataDir}/data/catwalk.json"; + auth.baseUrl = "https://${cfg.domain}"; + } + ) + ); + + baseModule = mkService { + name = "kloe"; + description = "kloe — server-authoritative LLM chat"; + defaultPort = 3011; + runtime = "bun"; + startCommand = "${pkgs.unstable.bun}/bin/bun run server.ts"; + + extraOptions = { + settings = lib.mkOption { + type = lib.types.attrsOf lib.types.anything; + default = { }; + description = '' + kloe.json, verbatim (see kloe.schema.json in the repo). Free-form on + purpose: the schema lives in kloe and re-modelling it in Nix would + only give it a second place to drift. + + `server.port`, `server.dbPath`, `blobs.path`, `catwalk.cachePath` and + `auth.baseUrl` are set from this module's options and ignored here. + Secrets belong in secretsFile and are referenced as "$VAR". + ''; + example = lib.literalExpression '' + { + auth.enabled = true; + providers = [ + { + id = "hyper"; + apiKey = "$HYPER_API_KEY"; + apiEndpoint = "https://hyper.charm.land/v1"; + type = "hyper"; + } + ]; + } + ''; + }; + }; + + extraConfig = cfg: { + atelier.services.kloe.environment.KLOE_CONFIG = toString (configFile cfg); + + # WAL-mode SQLite plus a content-addressed blob dir; both copy safely + # while the service runs, and stopping it would drop every SSE stream. + atelier.services.kloe.data = { + sqlite = "${cfg.dataDir}/data/kloe.db"; + files = [ "${cfg.dataDir}/data/blobs" ]; + stopForBackup = false; + }; + }; + }; + + cfg = config.atelier.services.kloe; +in +{ + imports = [ baseModule ]; + + config = lib.mkIf cfg.enable { + # The `run_shell` tool drives a docker daemon on another machine over the + # tailnet (sandbox.dockerHost = "ssh://kloe@prattle"), so the CLI and an ssh + # client have to be on the unit's path. + systemd.services.kloe.path = [ + pkgs.docker-client + pkgs.openssh + ]; + + # Tailscale SSH authorizes that hop by node identity, so there is no key to + # deploy — but docker spawns ssh with no terminal, and an unknown host key + # would end the connection in a prompt nobody can answer. Same + # accept-new the deploy workflow uses, with the store of accepted keys + # inside dataDir where the unit can actually write it. + systemd.tmpfiles.rules = [ + "d ${cfg.dataDir}/.ssh 0700 kloe kloe -" + "L+ ${cfg.dataDir}/.ssh/config - - - - ${ + pkgs.writeText "kloe-ssh-config" '' + Host * + StrictHostKeyChecking accept-new + UserKnownHostsFile ${cfg.dataDir}/.ssh/known_hosts + '' + }" + ]; + }; +} diff --git a/secrets/kloe.age b/secrets/kloe.age new file mode 100644 index 0000000..56d5017 --- /dev/null +++ b/secrets/kloe.age @@ -0,0 +1,13 @@ +age-encryption.org/v1 +-> ssh-rsa DqcG0Q +S6TcKf1Jz2TR/HiZ7e+cvKM/ke+QtDSA8FTEutSKL8O0WN+DZtI4KqNx+Kld/kiT +E5zqXaAy/IiMD5x8+byALI1tMIh2YNDbGWckwVdLoIvhjGbsfMA/DsVKbVF8zdWA +ey/h40nPnJYXoG7MetFz38HFgkpsGnZGl9uEsRVhJZykUzMvD3nzIBApQwR+aCFm +o6+B+PrcTV7kl6J+ldtogv8HfoCPJMz0z3c+qtVEkAEBZ+WpzEYE+RJJoXOCPlVG +ORQxOXfleukXE7O8p4x02UB1AynoJU/3J5uEO+hkCu8NREsL0PGvMdrxLIJn+id6 +SB5CY22vZgES7aBIai0cnPP6SjxH4fLlWg0ngfLDyFlue8+l/57wHCqK/pc49XwK +MHupCMnXny9hC2SeHqlrbuDgq3CcfA49CY1jrKA/feMlkLdH5jZT2bcBEECDCc+s +q4+nMv8Ch+H2gnJtFY21bB+oPQzAmiezRWFZIt6sgEELQPQYQyso2zBke/hoa83F + +--- vTlVJyjwCRNPCjZi/jCsElM/qkyU5RGLFWkETaIqCO0 +�0',9�q��^�^p �N��)v����0q� \ No newline at end of file diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 033f248..9fe0892 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -51,6 +51,9 @@ in "control.age".publicKeys = [ kierank ]; + "kloe.age".publicKeys = [ + kierank + ]; "restic/env.age".publicKeys = [ kierank ];