From 7f594863eec4b6256a336fbc081a3b815fd5cd79 Mon Sep 17 00:00:00 2001 From: Andrew Torgesen Date: Thu, 25 Jun 2026 19:51:01 -0700 Subject: [PATCH 1/5] cozy: load secret_key and password_hash from secrets file Co-Authored-By: Claude Opus 4.8 --- pkgs/nixos/modules/comfyui/module.nix | 7 +++- pkgs/python-packages/flasks/cozy/cozy.py | 37 +++++++++++++++---- .../flasks/cozy/tests/test_secrets.py | 32 ++++++++++++++++ 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 pkgs/python-packages/flasks/cozy/tests/test_secrets.py diff --git a/pkgs/nixos/modules/comfyui/module.nix b/pkgs/nixos/modules/comfyui/module.nix index f35ef58c3..a5d007911 100644 --- a/pkgs/nixos/modules/comfyui/module.nix +++ b/pkgs/nixos/modules/comfyui/module.nix @@ -110,6 +110,11 @@ in "imggen2" ]; }; + secretsFile = lib.mkOption { + type = lib.types.str; + default = "/data/andrew/secrets/flask/cozy.json"; + description = "Path to JSON file with secret_key and password_hash"; + }; }; }; @@ -211,7 +216,7 @@ in unitConfig.StartLimitIntervalSec = 0; serviceConfig = { Type = "simple"; - ExecStart = "${cfg.cozy.package}/bin/cozy --port ${builtins.toString service-ports.cozy} --subdomain /cozy --comfyui-url http://127.0.0.1:${builtins.toString cfg.port} --state-dir ${cfg.cozy.stateDir} --workflow-dir ${cfg.cozy.workflowDir} --input-dir ${cfg.cozy.inputDir} --output-dir ${cfg.cozy.outputDir} --workflows ${lib.concatStringsSep "," cfg.cozy.workflows}"; + ExecStart = "${cfg.cozy.package}/bin/cozy --port ${builtins.toString service-ports.cozy} --subdomain /cozy --comfyui-url http://127.0.0.1:${builtins.toString cfg.port} --state-dir ${cfg.cozy.stateDir} --workflow-dir ${cfg.cozy.workflowDir} --input-dir ${cfg.cozy.inputDir} --output-dir ${cfg.cozy.outputDir} --workflows ${lib.concatStringsSep "," cfg.cozy.workflows} --secrets-file ${cfg.cozy.secretsFile}"; ReadWritePaths = [ cfg.cozy.stateDir ]; WorkingDirectory = cfg.cozy.stateDir; Restart = "always"; diff --git a/pkgs/python-packages/flasks/cozy/cozy.py b/pkgs/python-packages/flasks/cozy/cozy.py index 2a90d7618..7b38ce349 100644 --- a/pkgs/python-packages/flasks/cozy/cozy.py +++ b/pkgs/python-packages/flasks/cozy/cozy.py @@ -1,5 +1,7 @@ import argparse +import json import os +import sys import flask import flask_login @@ -11,11 +13,21 @@ from comfyui_client import ComfyUIClient from job_store import JobStore, job_duration -# Identical to stampserver's credential hash + secret key. -_PW_HASH = ("scrypt:32768:8:1$acPu0meyxPfx0SnS$26a570af250e0593c2dbb6bfb1d037a7" - "366109a0ba4886e68191237efdabb2fca07de6c81c337b5e275390c2d7ff96f3" - "455f47b7a05027a7e0ebf1628f537498") -_SECRET_KEY = b"71d2dcdb895b367a1d5f0c66ca559c8d69af0c29a7e101c18c7c2d10399f264e" +_PW_HASH = None # populated from the secrets file at startup; see _load_secrets + + +def _load_secrets(path): + try: + with open(path) as f: + data = json.load(f) + except OSError as e: + sys.exit(f"cozy: cannot read secrets file {path}: {e}") + except json.JSONDecodeError as e: + sys.exit(f"cozy: invalid JSON in secrets file {path}: {e}") + missing = [k for k in ("secret_key", "password_hash") if not data.get(k)] + if missing: + sys.exit(f"cozy: secrets file {path} missing keys: {', '.join(missing)}") + return data def _check_password(password): @@ -89,7 +101,11 @@ def get_id(self): def create_app(store, workflows, workflow_dir, subdomain="/cozy", - input_dir=None, output_dir=None, workflow_kinds=None): + input_dir=None, output_dir=None, workflow_kinds=None, + secret_key=None, password_hash=None): + global _PW_HASH + if password_hash is not None: + _PW_HASH = password_hash input_dir = input_dir or os.path.join(workflow_dir, "input") output_dir = output_dir or os.path.join(workflow_dir, "output") workflow_kinds = workflow_kinds or {} @@ -99,7 +115,7 @@ def create_app(store, workflows, workflow_dir, subdomain="/cozy", static_url_path = (subdomain.rstrip("/") or "") + "/static" app = flask.Flask(__name__, static_url_path=static_url_path, static_folder="static") - app.secret_key = _SECRET_KEY + app.secret_key = secret_key or os.urandom(24) app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=20) app.config.setdefault("WTF_CSRF_ENABLED", True) @@ -232,6 +248,8 @@ def run(): parser.add_argument("--output-dir", type=str, default="", help="Directory of selectable output images for edit workflows " "(default /output)") + parser.add_argument("--secrets-file", type=str, required=True, + help="Path to JSON file with secret_key and password_hash") args = parser.parse_args() state_dir = args.state_dir or os.path.join(os.getcwd(), "cozy-state") @@ -245,10 +263,13 @@ def run(): for n in names if os.path.exists(os.path.join(workflow_dir, n + ".api.json")) } store = JobStore(state_dir, ComfyUIClient(args.comfyui_url)) + secrets = _load_secrets(args.secrets_file) app = create_app(store=store, workflows=names, workflow_dir=workflow_dir, subdomain=args.subdomain, input_dir=input_dir, output_dir=output_dir, - workflow_kinds=workflow_kinds) + workflow_kinds=workflow_kinds, + secret_key=secrets["secret_key"].encode(), + password_hash=secrets["password_hash"]) app.run(host="0.0.0.0", port=args.port) diff --git a/pkgs/python-packages/flasks/cozy/tests/test_secrets.py b/pkgs/python-packages/flasks/cozy/tests/test_secrets.py new file mode 100644 index 000000000..6ea442da1 --- /dev/null +++ b/pkgs/python-packages/flasks/cozy/tests/test_secrets.py @@ -0,0 +1,32 @@ +import json + +import pytest + +import cozy + + +def test_load_secrets_missing_file(tmp_path): + with pytest.raises(SystemExit): + cozy._load_secrets(str(tmp_path / "nope.json")) + + +def test_load_secrets_invalid_json(tmp_path): + p = tmp_path / "s.json" + p.write_text("{not json") + with pytest.raises(SystemExit): + cozy._load_secrets(str(p)) + + +def test_load_secrets_missing_field(tmp_path): + p = tmp_path / "s.json" + p.write_text(json.dumps({"secret_key": "abc"})) + with pytest.raises(SystemExit): + cozy._load_secrets(str(p)) + + +def test_load_secrets_ok(tmp_path): + p = tmp_path / "s.json" + p.write_text(json.dumps({"secret_key": "abc", "password_hash": "scrypt:x"})) + data = cozy._load_secrets(str(p)) + assert data["secret_key"] == "abc" + assert data["password_hash"] == "scrypt:x" From 995fb57771e9412c02b551b04e5f26893fd5c941 Mon Sep 17 00:00:00 2001 From: Andrew Torgesen Date: Thu, 25 Jun 2026 19:53:00 -0700 Subject: [PATCH 2/5] stampserver: load secret_key and password_hash from secrets file Co-Authored-By: Claude Opus 4.8 --- .../flasks/stampserver/module.nix | 7 +++++- .../flasks/stampserver/stampserver.py | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pkgs/python-packages/flasks/stampserver/module.nix b/pkgs/python-packages/flasks/stampserver/module.nix index 476efc695..ffb6e06ab 100644 --- a/pkgs/python-packages/flasks/stampserver/module.nix +++ b/pkgs/python-packages/flasks/stampserver/module.nix @@ -21,6 +21,11 @@ in description = "Home directory (will be cwd of the server)"; default = "/data/andrew"; }; + secretsFile = lib.mkOption { + type = lib.types.str; + description = "Path to JSON file with secret_key and password_hash"; + default = "${cfg.rootDir}/secrets/flask/stampserver.json"; + }; }; config = lib.mkIf cfg.enable { @@ -57,7 +62,7 @@ in }; serviceConfig = { Type = "simple"; - ExecStart = "${cfg.package}/bin/stampserver --port ${builtins.toString service-ports.stampserver} --data-dir ${cfg.rootDir}/stampables --subdomain /stamp"; + ExecStart = "${cfg.package}/bin/stampserver --port ${builtins.toString service-ports.stampserver} --data-dir ${cfg.rootDir}/stampables --subdomain /stamp --secrets-file ${cfg.secretsFile}"; ReadWritePaths = [ "/" ]; WorkingDirectory = cfg.rootDir; Restart = "always"; diff --git a/pkgs/python-packages/flasks/stampserver/stampserver.py b/pkgs/python-packages/flasks/stampserver/stampserver.py index 5d63c4b67..a5a65fc3c 100644 --- a/pkgs/python-packages/flasks/stampserver/stampserver.py +++ b/pkgs/python-packages/flasks/stampserver/stampserver.py @@ -1,4 +1,6 @@ import os +import json +import sys import re import shutil import subprocess @@ -19,8 +21,26 @@ parser.add_argument("--port", action="store", type=int, default=5000, help="Port to run the server on") parser.add_argument("--subdomain", action="store", type=str, default="/", help="Subdomain for a reverse proxy") parser.add_argument("--data-dir", action="store", type=str, default="", help="Directory containing the stampable elements") +parser.add_argument("--secrets-file", action="store", type=str, required=True, help="Path to JSON file with secret_key and password_hash") args = parser.parse_args() + +def _load_secrets(path): + try: + with open(path) as f: + data = json.load(f) + except OSError as e: + sys.exit(f"stampserver: cannot read secrets file {path}: {e}") + except json.JSONDecodeError as e: + sys.exit(f"stampserver: invalid JSON in secrets file {path}: {e}") + missing = [k for k in ("secret_key", "password_hash") if not data.get(k)] + if missing: + sys.exit(f"stampserver: secrets file {path} missing keys: {', '.join(missing)}") + return data + + +_secrets = _load_secrets(args.secrets_file) + urlroot = args.subdomain if urlroot != "/": urlroot += "/" @@ -37,7 +57,7 @@ class LoginForm(flask_wtf.FlaskForm): class User(flask_login.UserMixin): def check_password(self, password): - return check_password_hash("scrypt:32768:8:1$acPu0meyxPfx0SnS$26a570af250e0593c2dbb6bfb1d037a7366109a0ba4886e68191237efdabb2fca07de6c81c337b5e275390c2d7ff96f3455f47b7a05027a7e0ebf1628f537498", password) + return check_password_hash(_secrets["password_hash"], password) def get_id(self): return "anonymous" @@ -51,7 +71,7 @@ def get_id(self): SHORT_RESDIR = os.path.basename(os.path.realpath(RES_DIR)) app = flask.Flask(__name__, static_url_path=args.subdomain, static_folder=RES_DIR) -app.secret_key = b"71d2dcdb895b367a1d5f0c66ca559c8d69af0c29a7e101c18c7c2d10399f264e" +app.secret_key = _secrets["secret_key"].encode() app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=20) login_manager = flask_login.LoginManager() From 992862c96acadbd788fda941c478159b7e42f20c Mon Sep 17 00:00:00 2001 From: Andrew Torgesen Date: Thu, 25 Jun 2026 19:54:29 -0700 Subject: [PATCH 3/5] rankserver: load secret_key and password_hash from secrets file Co-Authored-By: Claude Opus 4.8 --- .../flasks/rankserver/module.nix | 7 +++++- .../flasks/rankserver/rankserver.py | 24 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/pkgs/python-packages/flasks/rankserver/module.nix b/pkgs/python-packages/flasks/rankserver/module.nix index 54cd51f43..7749b8ba9 100644 --- a/pkgs/python-packages/flasks/rankserver/module.nix +++ b/pkgs/python-packages/flasks/rankserver/module.nix @@ -21,6 +21,11 @@ in description = "Home directory (will be cwd of the server)"; default = "/data/andrew"; }; + secretsFile = lib.mkOption { + type = lib.types.str; + description = "Path to JSON file with secret_key and password_hash"; + default = "${cfg.rootDir}/secrets/flask/rankserver.json"; + }; }; config = lib.mkIf cfg.enable { @@ -44,7 +49,7 @@ in }; serviceConfig = { Type = "simple"; - ExecStart = "${cfg.package}/bin/rankserver --port ${builtins.toString service-ports.rankserver} --data-dir ${cfg.rootDir}/rankables --subdomain /rank"; + ExecStart = "${cfg.package}/bin/rankserver --port ${builtins.toString service-ports.rankserver} --data-dir ${cfg.rootDir}/rankables --subdomain /rank --secrets-file ${cfg.secretsFile}"; ReadWritePaths = [ "/" ]; WorkingDirectory = cfg.rootDir; Restart = "always"; diff --git a/pkgs/python-packages/flasks/rankserver/rankserver.py b/pkgs/python-packages/flasks/rankserver/rankserver.py index 4c6014b47..e629ce5d9 100644 --- a/pkgs/python-packages/flasks/rankserver/rankserver.py +++ b/pkgs/python-packages/flasks/rankserver/rankserver.py @@ -1,4 +1,6 @@ import os +import json +import sys import argparse import flask import flask_login @@ -23,8 +25,26 @@ parser.add_argument("--port", action="store", type=int, default=5000, help="Port to run the server on") parser.add_argument("--subdomain", action="store", type=str, default="/rank", help="Subdomain for a reverse proxy") parser.add_argument("--data-dir", action="store", type=str, default="", help="Directory containing the rankable elements") +parser.add_argument("--secrets-file", action="store", type=str, required=True, help="Path to JSON file with secret_key and password_hash") args = parser.parse_args() + +def _load_secrets(path): + try: + with open(path) as f: + data = json.load(f) + except OSError as e: + sys.exit(f"rankserver: cannot read secrets file {path}: {e}") + except json.JSONDecodeError as e: + sys.exit(f"rankserver: invalid JSON in secrets file {path}: {e}") + missing = [k for k in ("secret_key", "password_hash") if not data.get(k)] + if missing: + sys.exit(f"rankserver: secrets file {path} missing keys: {', '.join(missing)}") + return data + + +_secrets = _load_secrets(args.secrets_file) + urlroot = args.subdomain if urlroot != "/": urlroot += "/" @@ -41,7 +61,7 @@ class LoginForm(flask_wtf.FlaskForm): class User(flask_login.UserMixin): def check_password(self, password): - return check_password_hash("scrypt:32768:8:1$acPu0meyxPfx0SnS$26a570af250e0593c2dbb6bfb1d037a7366109a0ba4886e68191237efdabb2fca07de6c81c337b5e275390c2d7ff96f3455f47b7a05027a7e0ebf1628f537498", password) + return check_password_hash(_secrets["password_hash"], password) def get_id(self): return "anonymous" @@ -54,7 +74,7 @@ def get_id(self): RES_DIR = os.path.join(PWD, args.data_dir) app = flask.Flask(__name__, static_url_path=args.subdomain, static_folder=RES_DIR) -app.secret_key = b"71d2dcdb895b367a1d5f0c66ca559c8d69af0c29a7e101c18c7c2d10399f264e" +app.secret_key = _secrets["secret_key"].encode() app.config["PERMANENT_SESSION_LIFETIME"] = timedelta(minutes=20) login_manager = flask_login.LoginManager() From 6a2c3bd11372dc3a376be230008cdf843c48fbb9 Mon Sep 17 00:00:00 2001 From: Andrew Torgesen Date: Thu, 25 Jun 2026 19:56:56 -0700 Subject: [PATCH 4/5] stamp/rank: place secrets under ~/secrets/flask consistently rootDir is repurposed to ~/fileservers on ATS hosts, which would have nested the secrets there; pin to ~/secrets/flask to match cozy. Co-Authored-By: Claude Opus 4.8 --- pkgs/python-packages/flasks/rankserver/module.nix | 2 +- pkgs/python-packages/flasks/stampserver/module.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pkgs/python-packages/flasks/rankserver/module.nix b/pkgs/python-packages/flasks/rankserver/module.nix index 7749b8ba9..b7bdfae47 100644 --- a/pkgs/python-packages/flasks/rankserver/module.nix +++ b/pkgs/python-packages/flasks/rankserver/module.nix @@ -24,7 +24,7 @@ in secretsFile = lib.mkOption { type = lib.types.str; description = "Path to JSON file with secret_key and password_hash"; - default = "${cfg.rootDir}/secrets/flask/rankserver.json"; + default = "/data/andrew/secrets/flask/rankserver.json"; }; }; diff --git a/pkgs/python-packages/flasks/stampserver/module.nix b/pkgs/python-packages/flasks/stampserver/module.nix index ffb6e06ab..7e6797734 100644 --- a/pkgs/python-packages/flasks/stampserver/module.nix +++ b/pkgs/python-packages/flasks/stampserver/module.nix @@ -24,7 +24,7 @@ in secretsFile = lib.mkOption { type = lib.types.str; description = "Path to JSON file with secret_key and password_hash"; - default = "${cfg.rootDir}/secrets/flask/stampserver.json"; + default = "/data/andrew/secrets/flask/stampserver.json"; }; }; From 9d43c5df527fd4b66a7f98b7127e1498df0f9ddc Mon Sep 17 00:00:00 2001 From: "goromal (bot)" Date: Fri, 26 Jun 2026 03:19:57 +0000 Subject: [PATCH 5/5] Update changelog --- changes/pr-558.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/pr-558.md diff --git a/changes/pr-558.md b/changes/pr-558.md new file mode 100644 index 000000000..de3356236 --- /dev/null +++ b/changes/pr-558.md @@ -0,0 +1 @@ +Patch hashes