diff --git a/home/modules/opencode.nix b/home/modules/opencode.nix index 3e03d85..b49bb44 100644 --- a/home/modules/opencode.nix +++ b/home/modules/opencode.nix @@ -34,7 +34,8 @@ let # an empty omo.jsonc that drops every agent override — see the journal entry # below). ohMyOpenagentConfig = { - "$schema" = "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"; + "$schema" = + "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/omo.schema.json"; _migrations = [ "2026-07-opencode-config-unification" "2026-08-reasoning-unification" @@ -404,7 +405,7 @@ in # The versioned symlink (`home-manager-NN-link`) is found by following # `home-manager` one hop rather than hardcoding `home-manager-24-link`, # so this keeps working across HM major-version bumps. - home.activation.relinkHomeManager = lib.hm.dag.entryAfter [] '' + home.activation.relinkHomeManager = lib.hm.dag.entryAfter [ ] '' hmVersioned="$(readlink "$HOME/.local/state/nix/profiles/home-manager" 2>/dev/null || true)" target="$HOME/.local/state/nix/profiles/$hmVersioned" newGen="$(readlink -e "''${XDG_STATE_HOME:-$HOME/.local/state}/home-manager/gcroots/new-home" 2>/dev/null || true)" @@ -413,4 +414,4 @@ in ln -sfn "$newGen" "$target" fi ''; -} \ No newline at end of file +} diff --git a/modules/containers/open-webui.nix b/modules/containers/open-webui.nix index 0a1c86a..db96987 100644 --- a/modules/containers/open-webui.nix +++ b/modules/containers/open-webui.nix @@ -92,8 +92,7 @@ in # is the only source — and the container will refuse to start with # WEBUI_SECRET_KEY="" (env.py:762 — SystemExit). The error message is # the clear signal that the secret needs to be created. - environmentFiles = lib.optional (builtins.pathExists ./secrets/open-webui.env) - "/run/secrets/open-webui-env"; + environmentFiles = lib.optional (builtins.pathExists ./secrets/open-webui.env) "/run/secrets/open-webui-env"; volumes = [ "${panel}/data:/app/backend/data:rw" ]; @@ -180,4 +179,4 @@ in mode = "0400"; }; }; -} \ No newline at end of file +} diff --git a/modules/server/authelia.nix b/modules/server/authelia.nix index 5f46b62..15d5d20 100644 --- a/modules/server/authelia.nix +++ b/modules/server/authelia.nix @@ -223,4 +223,4 @@ in }; }; }; -} \ No newline at end of file +} diff --git a/modules/server/builder.nix b/modules/server/builder.nix index d59fd0f..230ee6f 100644 --- a/modules/server/builder.nix +++ b/modules/server/builder.nix @@ -32,7 +32,8 @@ let # of relying on `@/etc/nix/machines`, which Nix 2.34 parses but does # not act on (the daemon's `external-builders` list stays empty and the # client reports "configure remote builders via 'builders'" forever). - formatBuilder = b: + formatBuilder = + b: let # Nix 2.34 refuses to dispatch derivations to a builder whose protocol # is `ssh` (the NixOS default): the daemon leaves `external-builders` @@ -42,19 +43,19 @@ let proto = "ssh-ng://"; user = if b.sshUser != null && b.sshUser != "" then "${b.sshUser}@" else ""; systems = - if b.system != null then b.system - else if b.systems != [ ] then lib.concatStringsSep "," b.systems - else "-"; + if b.system != null then + b.system + else if b.systems != [ ] then + lib.concatStringsSep "," b.systems + else + "-"; sshKey = if b.sshKey != null && b.sshKey != "" then b.sshKey else "-"; maxJobs = toString b.maxJobs; speedFactor = toString b.speedFactor; allFeats = b.supportedFeatures ++ b.mandatoryFeatures; - supported = - if allFeats == [ ] then "-" - else lib.concatStringsSep "," allFeats; + supported = if allFeats == [ ] then "-" else lib.concatStringsSep "," allFeats; mandatory = - if b.mandatoryFeatures == [ ] then "-" - else lib.concatStringsSep "," b.mandatoryFeatures; + if b.mandatoryFeatures == [ ] then "-" else lib.concatStringsSep "," b.mandatoryFeatures; publicKey = if b.publicHostKey != null then b.publicHostKey else "-"; in lib.concatStringsSep " " [ @@ -127,4 +128,4 @@ in # absorbs the heavy lifting. The essentials/settings.nix already # leaves max-jobs at the default `auto` (2 here); no override needed. }; -} \ No newline at end of file +} diff --git a/modules/server/default.nix b/modules/server/default.nix index 75244ea..e700fa5 100644 --- a/modules/server/default.nix +++ b/modules/server/default.nix @@ -32,6 +32,7 @@ ./syncthing.nix ./systemd.nix ./ttyd.nix + ./vtimeline.nix ./uptime-kuma.nix # ../containers/remnawave.nix # ./coturn.nix diff --git a/modules/server/nginx.nix b/modules/server/nginx.nix index a34eb06..d7470dd 100644 --- a/modules/server/nginx.nix +++ b/modules/server/nginx.nix @@ -123,23 +123,24 @@ in forceSSL = true; enableACME = true; }; - # vtimeline.zeroq.su — static site behind Authelia forward-auth. - # Files live under /home/oqyude/External/Git/VeeamTimelineView/public_html, - # which is bind-mounted to /var/lib/vtimeline (see systemd.mounts below) - # because /home/oqyude is mode 700 and the nginx user (uid 60) cannot - # traverse it. Authentication is delegated to Authelia via - # auth_request: nginx sub-requests /authelia on every hit, Authelia - # returns 2xx if the session cookie is valid or 401 (which nginx - # converts into a 401 to the client; Authelia's response headers - # carry the redirect target). The login UI itself is served by the - # authelia.zeroq.su vhost below — same Authelia container, different - # vhost. + # vtimeline.zeroq.su — Veeam Timeline View. Reverse-proxies the + # entire vhost to a local Node.js/Express process (managed by + # systemd as `vtimeline-api` — see modules/server/vtimeline.nix), + # which serves both the static frontend (public_html/) and the + # /api/uploads JSON-upload CRUD over a single listener on + # 127.0.0.1:8000. Authelia forward-auth is wired on `/` so every + # hit (static OR /api/*) requires a valid session cookie. + # + # client_max_body_size 6m matches the server.js body limit + # (5 MB hard cap). nginx's default 1m would 413 any upload near + # the cap before the request reached the node process. "vtimeline.zeroq.su" = { forceSSL = true; enableACME = true; - root = "/var/lib/vtimeline"; locations = { "/" = { + proxyPass = "http://127.0.0.1:8000"; + proxyWebsockets = true; extraConfig = '' auth_request /authelia; auth_request_set $authelia_user $upstream_http_remote_user; @@ -160,6 +161,7 @@ in # Authelia constructs an absolute redirect back to the # original vhost. error_page 401 =302 https://authelia.zeroq.su/?rd=$scheme://$host$request_uri; + client_max_body_size 6m; ''; }; "= /authelia" = { @@ -368,20 +370,6 @@ in 443 ]; - # Bind-mount the vtimeline source tree into /var/lib so the nginx user - # (uid 60) doesn't have to traverse /home/oqyude (mode 700). The mount is - # lazy (x-systemd.automount) and nofail, so a missing /home/oqyude/External - # only shows up as a per-request 500/403, never as a hard boot failure. - systemd.mounts = [ - (xlib.helpers.mkSystemdBind { - what = "/home/oqyude/External/Git/VeeamTimelineView/public_html"; - where = "/var/lib/vtimeline"; - }) - ]; - systemd.tmpfiles.rules = [ - (xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx") - ]; - # Note: the previous vtimeline-htpasswd sops declaration lived here. It # was removed when authelia replaced nginx's auth_basic (see the vtimeline # vhost above). The encrypted file modules/server/secrets/vtimeline-htpasswd.yaml diff --git a/modules/server/ttyd.nix b/modules/server/ttyd.nix index 2ba60e9..2b4e4dc 100644 --- a/modules/server/ttyd.nix +++ b/modules/server/ttyd.nix @@ -100,8 +100,10 @@ let # а в 24.x был путём. `lib.getExe` умеет оба: derivation → bin/, # string → возвращает как есть. userShellExe = - let shell = config.users.users.${cfg.user}.shell or "/run/current-system/sw/bin/bash"; - in if builtins.isString shell then shell else lib.getExe shell; + let + shell = config.users.users.${cfg.user}.shell or "/run/current-system/sw/bin/bash"; + in + if builtins.isString shell then shell else lib.getExe shell; in { services.ttyd = { @@ -109,7 +111,11 @@ in port = 7681; interface = "127.0.0.1"; user = "oqyude"; - entrypoint = [ userShellExe "-i" "-l" ]; + entrypoint = [ + userShellExe + "-i" + "-l" + ]; writeable = true; checkOrigin = true; maxClients = 0; diff --git a/modules/server/vtimeline.nix b/modules/server/vtimeline.nix new file mode 100644 index 0000000..5885b99 --- /dev/null +++ b/modules/server/vtimeline.nix @@ -0,0 +1,98 @@ +{ + config, + lib, + pkgs, + xlib, + ... +}: + +# Veeam Timeline View — static frontend + REST API, both served by a +# single Node.js/Express process. nginx reverse-proxies the entire +# vtimeline.zeroq.su vhost to the local listener (see the vhost in +# modules/server/nginx.nix — no `root`, no `try_files`). +# +# API surface (server.js): +# GET /api/uploads list uploaded JSONs +# POST /api/uploads upload (raw JSON or {name, content}, ≤ 5 MB) +# GET /api/uploads/:name fetch parsed +# DELETE /api/uploads/:name delete +# +# Auth: delegated to Authelia at the nginx layer (`auth_request +# /authelia` on the vhost). The API itself trusts whoever reaches it — +# the only ingress is the same-origin nginx proxy, so the wide-open +# CORS header in server.js is a no-op in practice. +# +# Storage: /home/oqyude/External/Git/VeeamTimelineView/ +# ├── public_html/ static frontend (read-only at runtime) +# ├── server.js Express app +# ├── node_modules/ installed deps (express + transitive) +# └── uploads/ persistent JSON uploads (read+write) +# +# The repo lives on the External ext4 drive (fstab). The service gates +# on home-oqyude-External.mount so it never starts against an empty +# directory after a cold boot without the drive plugged in. +let + user = xlib.device.username; + group = "users"; + repo = "/home/oqyude/External/Git/VeeamTimelineView"; + port = 8000; + node = pkgs.nodejs_22; +in +{ + # systemPackages so /run/current-system/sw/bin/node exists for any + # operator tooling (logs, ad-hoc npm scripts). The systemd unit + # below references the absolute Nix-store path, so this is purely + # for the CLI path. + environment.systemPackages = [ node ]; + + # Bind-mount public_html into /var/lib. The current vhost proxies + # all traffic to the node listener, so nginx itself does not need + # this — kept for parity with the pre-API setup (so any future + # static-only fallback or external inspection has a stable + # /home-independent path). x-systemd.automount + nofail: a missing + # /home/oqyude/External only surfaces as a per-request 404, never a + # boot failure. + systemd.mounts = [ + (xlib.helpers.mkSystemdBind { + what = "${repo}/public_html"; + where = "/var/lib/vtimeline"; + }) + ]; + systemd.tmpfiles.rules = [ + (xlib.helpers.mkTmpfile "d" "/var/lib/vtimeline" "0755" "nginx" "nginx") + ]; + + systemd.services.vtimeline-api = { + description = "Veeam Timeline View API (Node.js + Express)"; + wantedBy = [ "multi-user.target" ]; + + after = [ + "network-online.target" + "home-oqyude-External.mount" + ]; + wants = [ "network-online.target" ]; + requires = [ "home-oqyude-External.mount" ]; + + serviceConfig = { + Type = "simple"; + User = user; + Group = group; + WorkingDirectory = repo; + ExecStart = "${node}/bin/node ${repo}/server.js"; + Environment = "PORT=${toString port}"; + + Restart = "on-failure"; + RestartSec = "5s"; + + # Sandbox. Server.js only needs to read public_html/ + + # node_modules/ and to read-write uploads/. ReadWritePaths + # lifts the ProtectHome/ProtectSystem write protection for + # uploads/ only; everywhere else stays read-only. + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + ReadWritePaths = [ "${repo}/uploads" ]; + }; + }; +}