From 34be676dd3747b6659268c1da48e2a3bfedd4961 Mon Sep 17 00:00:00 2001 From: C4 Patino Date: Sat, 27 Jun 2026 16:04:16 -0500 Subject: [PATCH] feat: add asciinema server module and CLI tool --- flake.lock | 172 ++++++++++++------ flake.nix | 4 +- homes/x86_64-linux/c4patino@arisu/default.nix | 1 + .../x86_64-linux/c4patino@kokoro/default.nix | 1 + modules/home/cli/tools/asciinema/default.nix | 32 ++++ modules/nixos/bundles/common/default.nix | 1 + modules/nixos/bundles/common/networking.nix | 10 +- .../nixos/services/apps/asciinema/default.nix | 66 +++++++ modules/nixos/services/networking/default.nix | 9 +- .../services/networking/httpd/default.nix | 6 +- .../services/storage/impermanence/default.nix | 4 +- secrets/crypt/asciinema/config.toml | Bin 0 -> 69 bytes secrets/crypt/secrets.json | Bin 3617 -> 3879 bytes 13 files changed, 246 insertions(+), 60 deletions(-) create mode 100644 modules/home/cli/tools/asciinema/default.nix create mode 100644 modules/nixos/services/apps/asciinema/default.nix create mode 100644 secrets/crypt/asciinema/config.toml diff --git a/flake.lock b/flake.lock index e28fddf..b069de8 100755 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,25 @@ { "nodes": { + "asciinema": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1782462228, + "narHash": "sha256-uVOzyBA0KorKZRxC8QEjaO6FdFSLmhDwdXxutisVyD8=", + "owner": "asciinema", + "repo": "asciinema-server", + "rev": "38a908d8cf3312bbdfd18cf47d58eb42046a2655", + "type": "github" + }, + "original": { + "owner": "asciinema", + "ref": "main", + "repo": "asciinema-server", + "type": "github" + } + }, "base16": { "inputs": { "fromYaml": "fromYaml" @@ -86,7 +106,7 @@ "deploy-rs": { "inputs": { "flake-compat": "flake-compat", - "nixpkgs": "nixpkgs", + "nixpkgs": "nixpkgs_2", "utils": "utils" }, "locked": { @@ -105,7 +125,7 @@ }, "devshell": { "inputs": { - "nixpkgs": "nixpkgs_2" + "nixpkgs": "nixpkgs_3" }, "locked": { "lastModified": 1768818222, @@ -326,14 +346,14 @@ }, "flake-utils": { "inputs": { - "systems": "systems_3" + "systems": "systems" }, "locked": { - "lastModified": 1694529238, - "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -344,7 +364,7 @@ }, "flake-utils-plus": { "inputs": { - "flake-utils": "flake-utils" + "flake-utils": "flake-utils_2" }, "locked": { "lastModified": 1715533576, @@ -363,7 +383,25 @@ }, "flake-utils_2": { "inputs": { - "systems": "systems_6" + "systems": "systems_4" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "flake-utils_3": { + "inputs": { + "systems": "systems_7" }, "locked": { "lastModified": 1731533236, @@ -457,7 +495,7 @@ "impermanence": { "inputs": { "home-manager": "home-manager_2", - "nixpkgs": "nixpkgs_3" + "nixpkgs": "nixpkgs_4" }, "locked": { "lastModified": 1769548169, @@ -476,8 +514,8 @@ "nix-minecraft": { "inputs": { "flake-compat": "flake-compat_2", - "nixpkgs": "nixpkgs_4", - "systems": "systems_2" + "nixpkgs": "nixpkgs_5", + "systems": "systems_3" }, "locked": { "lastModified": 1780375694, @@ -516,15 +554,15 @@ }, "nixpkgs": { "locked": { - "lastModified": 1743014863, - "narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=", - "owner": "NixOS", + "lastModified": 1780336545, + "narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=", + "owner": "nixos", "repo": "nixpkgs", - "rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f", + "rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f", "type": "github" }, "original": { - "owner": "NixOS", + "owner": "nixos", "ref": "nixpkgs-unstable", "repo": "nixpkgs", "type": "github" @@ -562,6 +600,22 @@ } }, "nixpkgs_10": { + "locked": { + "lastModified": 1780336545, + "narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_11": { "locked": { "lastModified": 1770107345, "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", @@ -578,6 +632,22 @@ } }, "nixpkgs_2": { + "locked": { + "lastModified": 1743014863, + "narHash": "sha256-jAIUqsiN2r3hCuHji80U7NNEafpIMBXiwKlSrjWMlpg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bd3bac8bfb542dbde7ffffb6987a1a1f9d41699f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_3": { "locked": { "lastModified": 1762156382, "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=", @@ -593,7 +663,7 @@ "type": "github" } }, - "nixpkgs_3": { + "nixpkgs_4": { "locked": { "lastModified": 1768564909, "narHash": "sha256-Kell/SpJYVkHWMvnhqJz/8DqQg2b6PguxVWOuadbHCc=", @@ -609,7 +679,7 @@ "type": "github" } }, - "nixpkgs_4": { + "nixpkgs_5": { "locked": { "lastModified": 1769461804, "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", @@ -625,7 +695,7 @@ "type": "github" } }, - "nixpkgs_5": { + "nixpkgs_6": { "locked": { "lastModified": 1780902259, "narHash": "sha256-q8yYEC5f1mFlQO9RGna4LTc9QrcvWunX6FYp83munkQ=", @@ -641,7 +711,7 @@ "type": "github" } }, - "nixpkgs_6": { + "nixpkgs_7": { "locked": { "lastModified": 1770107345, "narHash": "sha256-tbS0Ebx2PiA1FRW8mt8oejR0qMXmziJmPaU1d4kYY9g=", @@ -657,7 +727,7 @@ "type": "github" } }, - "nixpkgs_7": { + "nixpkgs_8": { "locked": { "lastModified": 1775710090, "narHash": "sha256-ar3rofg+awPB8QXDaFJhJ2jJhu+KqN/PRCXeyuXR76E=", @@ -673,7 +743,7 @@ "type": "github" } }, - "nixpkgs_8": { + "nixpkgs_9": { "locked": { "lastModified": 1767026758, "narHash": "sha256-7fsac/f7nh/VaKJ/qm3I338+wAJa/3J57cOGpXi0Sbg=", @@ -689,27 +759,11 @@ "type": "github" } }, - "nixpkgs_9": { - "locked": { - "lastModified": 1780336545, - "narHash": "sha256-vhVhuXzFrIOfcssC/9hDHx7MHzDKjF3keHuREOQqQiQ=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "4df1b885d76a54e1aa1a318f8d16fd6005b6401f", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixpkgs-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, "nixvim": { "inputs": { "flake-parts": "flake-parts_3", - "nixpkgs": "nixpkgs_9", - "systems": "systems_7" + "nixpkgs": "nixpkgs_10", + "systems": "systems_8" }, "locked": { "lastModified": 1780235872, @@ -752,6 +806,7 @@ }, "root": { "inputs": { + "asciinema": "asciinema", "deploy-rs": "deploy-rs", "devshell": "devshell", "disko": "disko", @@ -760,7 +815,7 @@ "impermanence": "impermanence", "nix-minecraft": "nix-minecraft", "nixos-wsl": "nixos-wsl", - "nixpkgs": "nixpkgs_5", + "nixpkgs": "nixpkgs_6", "nixpkgs-unstable": "nixpkgs-unstable", "snowfall-lib": "snowfall-lib", "sops-nix": "sops-nix", @@ -827,7 +882,7 @@ "nixpkgs" ], "nur": "nur", - "systems": "systems_4", + "systems": "systems_5", "tinted-kitty": "tinted-kitty", "tinted-schemes": "tinted-schemes", "tinted-tmux": "tinted-tmux", @@ -909,6 +964,21 @@ } }, "systems_5": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_6": { "locked": { "lastModified": 1689347949, "narHash": "sha256-12tWmuL2zgBgZkdoB6qXZsgJEH9LR3oUgpaQq2RbI80=", @@ -923,7 +993,7 @@ "type": "github" } }, - "systems_6": { + "systems_7": { "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", @@ -938,7 +1008,7 @@ "type": "github" } }, - "systems_7": { + "systems_8": { "locked": { "lastModified": 1774449309, "narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=", @@ -1020,7 +1090,7 @@ }, "treefmt-nix": { "inputs": { - "nixpkgs": "nixpkgs_6" + "nixpkgs": "nixpkgs_7" }, "locked": { "lastModified": 1780220602, @@ -1038,7 +1108,7 @@ }, "treefmt-nix_2": { "inputs": { - "nixpkgs": "nixpkgs_10" + "nixpkgs": "nixpkgs_11" }, "locked": { "lastModified": 1780220602, @@ -1056,7 +1126,7 @@ }, "utils": { "inputs": { - "systems": "systems" + "systems": "systems_2" }, "locked": { "lastModified": 1731533236, @@ -1075,8 +1145,8 @@ "walker": { "inputs": { "elephant": "elephant", - "nixpkgs": "nixpkgs_7", - "systems": "systems_5" + "nixpkgs": "nixpkgs_8", + "systems": "systems_6" }, "locked": { "lastModified": 1777789924, @@ -1096,7 +1166,7 @@ "inputs": { "crane": "crane", "flake-parts": "flake-parts_2", - "nixpkgs": "nixpkgs_8", + "nixpkgs": "nixpkgs_9", "xremap": "xremap_2" }, "locked": { @@ -1132,7 +1202,7 @@ }, "yumevim-nix": { "inputs": { - "flake-utils": "flake-utils_2", + "flake-utils": "flake-utils_3", "nixpkgs": [ "nixpkgs-unstable" ], diff --git a/flake.nix b/flake.nix index a90f1b7..e2ade4b 100755 --- a/flake.nix +++ b/flake.nix @@ -13,9 +13,11 @@ deploy-rs.url = "github:serokell/deploy-rs"; devshell.url = "github:numtide/devshell"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + + asciinema.url = "github:asciinema/asciinema-server/main"; impermanence.url = "github:nix-community/impermanence"; nix-minecraft.url = "github:Infinidoge/nix-minecraft"; - treefmt-nix.url = "github:numtide/treefmt-nix"; walker.url = "github:abenz1267/walker"; xremap.url = "github:xremap/nix-flake"; diff --git a/homes/x86_64-linux/c4patino@arisu/default.nix b/homes/x86_64-linux/c4patino@arisu/default.nix index 02cda06..dcc44f2 100644 --- a/homes/x86_64-linux/c4patino@arisu/default.nix +++ b/homes/x86_64-linux/c4patino@arisu/default.nix @@ -42,6 +42,7 @@ in { }; tools = { + asciinema = enabled; presenterm = enabled; rustypaste = enabled; }; diff --git a/homes/x86_64-linux/c4patino@kokoro/default.nix b/homes/x86_64-linux/c4patino@kokoro/default.nix index affdf04..f42fb85 100644 --- a/homes/x86_64-linux/c4patino@kokoro/default.nix +++ b/homes/x86_64-linux/c4patino@kokoro/default.nix @@ -37,6 +37,7 @@ in { }; tools = { + asciinema = enabled; presenterm = enabled; rustypaste = enabled; }; diff --git a/modules/home/cli/tools/asciinema/default.nix b/modules/home/cli/tools/asciinema/default.nix new file mode 100644 index 0000000..2b7b0e6 --- /dev/null +++ b/modules/home/cli/tools/asciinema/default.nix @@ -0,0 +1,32 @@ +{ + config, + lib, + namespace, + pkgs, + ... +}: let + inherit (lib) mkIf mkEnableOption; + inherit (lib.${namespace}) getAttrByNamespace mkOptionsWithNamespace; + base = "${namespace}.cli.tools.asciinema"; + cfg = getAttrByNamespace config base; +in { + options = mkOptionsWithNamespace base { + enable = mkEnableOption "asciinema"; + }; + + config = mkIf cfg.enable { + home = { + packages = with pkgs; [ + asciinema + ]; + + file = let + crypt = "${config.snowfallorg.user.home.directory}/dotfiles/secrets/crypt"; + in { + ".config/asciinema/config.toml".source = + "${crypt}/asciinema/config.toml" + |> config.lib.file.mkOutOfStoreSymlink; + }; + }; + }; +} diff --git a/modules/nixos/bundles/common/default.nix b/modules/nixos/bundles/common/default.nix index 556d057..af8a9a0 100644 --- a/modules/nixos/bundles/common/default.nix +++ b/modules/nixos/bundles/common/default.nix @@ -86,6 +86,7 @@ in { tsuki = [ "forgejo" "grafana" + "asciinema" "terraform" "vaultwarden" ]; diff --git a/modules/nixos/bundles/common/networking.nix b/modules/nixos/bundles/common/networking.nix index 445d075..a73d93a 100644 --- a/modules/nixos/bundles/common/networking.nix +++ b/modules/nixos/bundles/common/networking.nix @@ -60,6 +60,11 @@ in { }; tsuki = { + asciinema = { + port = 4000; + public = true; + websocket.enable = true; + }; dash = { port = 5150; internal = true; @@ -73,7 +78,10 @@ in { port = 8096; internal = true; public = true; - websocket = true; + websocket = { + enable = true; + path = "/socket"; + }; }; paste = { port = 5100; diff --git a/modules/nixos/services/apps/asciinema/default.nix b/modules/nixos/services/apps/asciinema/default.nix new file mode 100644 index 0000000..82da572 --- /dev/null +++ b/modules/nixos/services/apps/asciinema/default.nix @@ -0,0 +1,66 @@ +{ + config, + inputs, + lib, + namespace, + pkgs, + ... +}: let + inherit (lib) mkIf mkForce; + inherit (lib.${namespace}) getAttrByNamespace getIn hostHasService readJsonOrEmpty resolveDatabaseHost resolveDatabaseIP resolveServicePort; + inherit (config.networking) hostName; + + networkCfg = getAttrByNamespace config "${namespace}.services.networking"; + pgCfg = getAttrByNamespace config "${namespace}.services.storage.postgresql"; + + isEnabled = hostHasService networkCfg.network-services hostName "asciinema"; + port = resolveServicePort networkCfg.network-services "asciinema" 4000; +in { + imports = [ + inputs.asciinema.nixosModules.default + ]; + + config = mkIf isEnabled { + services.asciinema = { + enable = true; + database.createLocally = false; + + environment = { + BIND_ALL = true; + PORT = port; + URL_HOST = "asciinema.cpatino.com"; + URL_SCHEME = "https"; + + SIGN_UP_DISABLED = true; + }; + + environmentFile = let + secrets = readJsonOrEmpty "${inputs.self}/secrets/crypt/secrets.json"; + ip = resolveDatabaseIP networkCfg.devices pgCfg.databases "asciinema"; + password = getIn "postgresql.asciinema.password" secrets; + in + pkgs.writeText "asciinema.env" '' + DATABASE_URL=ecto://asciinema:${password}@${ip}:5600/asciinema + ''; + }; + + networking.firewall.allowedTCPPorts = [port]; + + systemd.services.asciinema-server = mkIf (resolveDatabaseHost pgCfg.databases "asciinema" == hostName) { + after = ["postgresql.service" "pgbouncer.service"]; + requires = ["postgresql.service" "pgbouncer.service"]; + serviceConfig = { + RestartSec = mkForce "1s"; + }; + }; + + ${namespace}.services.storage.impermanence.folders = [ + { + directory = "/var/lib/asciinema"; + user = "asciinema"; + group = "asciinema"; + mode = "700"; + } + ]; + }; +} diff --git a/modules/nixos/services/networking/default.nix b/modules/nixos/services/networking/default.nix index 30ba993..6baf5e0 100644 --- a/modules/nixos/services/networking/default.nix +++ b/modules/nixos/services/networking/default.nix @@ -5,7 +5,7 @@ ... }: let inherit (lib) types mkOption optional optionalString concatStringsSep; - inherit (lib.${namespace}) getAttrByNamespace mkOptionsWithNamespace resolveHostIP flattenHostServices mkRequiredOpt mkBoolOpt mkOptAttrset mkListOpt; + inherit (lib.${namespace}) getAttrByNamespace mkOptionsWithNamespace resolveHostIP flattenHostServices mkRequiredOpt mkBoolOpt mkOpt mkOptAttrset mkListOpt; base = "${namespace}.services.networking"; cfg = getAttrByNamespace config base; @@ -24,7 +24,12 @@ in { port = mkRequiredOpt port "Local port of the service. If not specified, the module's default is used."; public = mkBoolOpt false "Whether the service should be publicly accessible over *.cpatino.com."; internal = mkBoolOpt false "Whether the service should be internally accessible over *.yumeami.sh."; - websocket = mkBoolOpt false "Whether to enable websocket endpoints for the service."; + websocket = mkOpt (submodule { + options = { + enable = mkBoolOpt false "Whether to enable websocket endpoints for the service."; + path = mkOpt str "/ws/" "WebSocket path prefix for the service."; + }; + }) {} "WebSocket configuration for the service."; }; })); default = {}; diff --git a/modules/nixos/services/networking/httpd/default.nix b/modules/nixos/services/networking/httpd/default.nix index 4462e5b..7b36a35 100644 --- a/modules/nixos/services/networking/httpd/default.nix +++ b/modules/nixos/services/networking/httpd/default.nix @@ -70,10 +70,10 @@ ''; - websocketConfig = optionalString service.websocket '' + websocketConfig = optionalString service.websocket.enable '' # --- ${name} (websocket access) --- - ProxyPass /socket ws://${hostIP}:${p}/socket connectiontimeout=30 timeout=300 retry=0 - ProxyPassReverse /socket ws://${hostIP}:${p}/socket + ProxyPass ${service.websocket.path} ws://${hostIP}:${p}${service.websocket.path} connectiontimeout=30 timeout=300 retry=0 + ProxyPassReverse ${service.websocket.path} ws://${hostIP}:${p}${service.websocket.path} ''; in { diff --git a/modules/nixos/services/storage/impermanence/default.nix b/modules/nixos/services/storage/impermanence/default.nix index 36604a3..794340c 100644 --- a/modules/nixos/services/storage/impermanence/default.nix +++ b/modules/nixos/services/storage/impermanence/default.nix @@ -5,8 +5,8 @@ namespace, ... }: let - inherit (lib) types mkIf mkEnableOption mkOption concatLists; - inherit (lib.${namespace}) getAttrByNamespace mkOptionsWithNamespace mkOpt mkRequiredOpt mkNullableOpt mkListOpt; + inherit (lib) types mkIf mkEnableOption concatLists; + inherit (lib.${namespace}) getAttrByNamespace mkOptionsWithNamespace mkRequiredOpt mkNullableOpt mkListOpt; base = "${namespace}.services.storage.impermanence"; cfg = getAttrByNamespace config base; in { diff --git a/secrets/crypt/asciinema/config.toml b/secrets/crypt/asciinema/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..df2d3890dd975a944d3d6ac996a4f878a847638a GIT binary patch literal 69 zcmZQ@_Y83kiVO&0;N1}{b>+FIuW&+9wC#>j=ZSQ->=d#OYae~5wb!E?-OFa%m bU(XD;((O}x{o{#IeDi}Y#ib%Whjsw~;vyeh literal 0 HcmV?d00001 diff --git a/secrets/crypt/secrets.json b/secrets/crypt/secrets.json index dd3f839273fab5df2a69e581c74e30bbc17f3715..2b3225d1238dfa017268021a75d5735daf91203e 100755 GIT binary patch literal 3879 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 literal 3617 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