From 4622c52d5b7df45a12b5657f3af638020e86eb7c Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 30 Jan 2026 06:06:03 +0100 Subject: [PATCH 01/18] refactor(nix): extract common system configs into reusable modules Create 6 new NixOS modules to reduce duplication across system configs: - hardware/wooting: Wooting keyboard udev rules and Bluetooth compat - services/nginx-base: Common nginx server settings - services/acme-base: ACME certificate defaults - services/xremap: Key remapping with sensible defaults - system/no-sleep: Disable sleep/suspend/hibernate targets - system/kernel-tweaks: PM freeze timeout and zram configuration Update system configuration files to use these new modules. Co-Authored-By: Claude Opus 4.5 --- modules/nixos/hardware/wooting/default.nix | 25 +++++++++++ modules/nixos/services/acme-base/default.nix | 41 +++++++++++++++++ modules/nixos/services/nginx-base/default.nix | 42 ++++++++++++++++++ modules/nixos/services/xremap/default.nix | 44 +++++++++++++++++++ .../nixos/system/kernel-tweaks/default.nix | 29 ++++++++++++ modules/nixos/system/no-sleep/default.nix | 28 ++++++++++++ systems/aarch64-linux/m4nix/default.nix | 22 +++------- systems/aarch64-linux/rnix/default.nix | 22 +++------- systems/x86_64-linux/amd/default.nix | 34 +++++--------- systems/x86_64-linux/amd/xremap.nix | 44 +++++++------------ systems/x86_64-linux/mx/acme.nix | 14 ++---- systems/x86_64-linux/mx/default.nix | 3 +- systems/x86_64-linux/mx/nginx.nix | 18 +------- systems/x86_64-linux/nixtee1/default.nix | 16 +++---- systems/x86_64-linux/sgx-attic/default.nix | 12 +++-- systems/x86_64-linux/sgx/acme.nix | 12 ++--- systems/x86_64-linux/sgx/default.nix | 19 ++++---- systems/x86_64-linux/sgx/nginx.nix | 18 +------- systems/x86_64-linux/t15/default.nix | 5 +-- systems/x86_64-linux/x1/default.nix | 36 +++++---------- systems/x86_64-linux/x1/xremap.nix | 44 +++++++------------ 21 files changed, 310 insertions(+), 218 deletions(-) create mode 100644 modules/nixos/hardware/wooting/default.nix create mode 100644 modules/nixos/services/acme-base/default.nix create mode 100644 modules/nixos/services/nginx-base/default.nix create mode 100644 modules/nixos/services/xremap/default.nix create mode 100644 modules/nixos/system/kernel-tweaks/default.nix create mode 100644 modules/nixos/system/no-sleep/default.nix diff --git a/modules/nixos/hardware/wooting/default.nix b/modules/nixos/hardware/wooting/default.nix new file mode 100644 index 0000000..e5cc8f5 --- /dev/null +++ b/modules/nixos/hardware/wooting/default.nix @@ -0,0 +1,25 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.hardware.wooting; +in +{ + options.metacfg.hardware.wooting = with types; { + enable = mkBoolOpt false "Whether or not to enable Wooting keyboard support."; + enableBluetoothCompat = mkBoolOpt true "Disable ClassicBondedOnly for Bluetooth compatibility."; + }; + + config = mkIf cfg.enable { + hardware.bluetooth.input.General.ClassicBondedOnly = mkIf cfg.enableBluetoothCompat false; + + services.udev.extraRules = '' + KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e4c5", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" + KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e489", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" + ''; + }; +} diff --git a/modules/nixos/services/acme-base/default.nix b/modules/nixos/services/acme-base/default.nix new file mode 100644 index 0000000..d572848 --- /dev/null +++ b/modules/nixos/services/acme-base/default.nix @@ -0,0 +1,41 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.services.acmeBase; +in +{ + options.metacfg.services.acmeBase = with types; { + enable = mkBoolOpt false "Whether or not to enable ACME with common settings."; + email = mkOption { + type = types.str; + default = "harald@hoyer.xyz"; + description = "Registration email for ACME."; + }; + dnsProvider = mkOption { + type = types.str; + default = "cloudflare"; + description = "DNS provider for ACME DNS-01 challenge."; + }; + credentialsFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to the credentials file for the DNS provider."; + }; + }; + + config = mkIf cfg.enable { + security.acme = { + acceptTerms = true; + defaults = { + email = cfg.email; + dnsProvider = cfg.dnsProvider; + credentialsFile = mkIf (cfg.credentialsFile != null) cfg.credentialsFile; + }; + }; + }; +} diff --git a/modules/nixos/services/nginx-base/default.nix b/modules/nixos/services/nginx-base/default.nix new file mode 100644 index 0000000..6b2dd52 --- /dev/null +++ b/modules/nixos/services/nginx-base/default.nix @@ -0,0 +1,42 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.services.nginxBase; +in +{ + options.metacfg.services.nginxBase = with types; { + enable = mkBoolOpt false "Whether or not to enable nginx with common settings."; + clientMaxBodySize = mkOption { + type = types.str; + default = "1000M"; + description = "Maximum allowed size of the client request body."; + }; + enableAcmeGroup = mkBoolOpt true "Add nginx user to acme group."; + enableVcombinedLog = mkBoolOpt true "Enable vcombined log format."; + }; + + config = mkIf cfg.enable { + users.users.nginx.extraGroups = mkIf cfg.enableAcmeGroup [ "acme" ]; + + services.nginx = { + enable = true; + clientMaxBodySize = cfg.clientMaxBodySize; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + appendHttpConfig = mkIf cfg.enableVcombinedLog '' + log_format vcombined '$host:$server_port ' + '$remote_addr - $remote_user [$time_local] ' + '"$request" $status $body_bytes_sent ' + '"$http_referer" "$http_user_agent"'; + access_log /var/log/nginx/access.log vcombined; + ''; + }; + }; +} diff --git a/modules/nixos/services/xremap/default.nix b/modules/nixos/services/xremap/default.nix new file mode 100644 index 0000000..6f22f38 --- /dev/null +++ b/modules/nixos/services/xremap/default.nix @@ -0,0 +1,44 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.services.xremap; +in +{ + options.metacfg.services.xremap = with types; { + enable = mkBoolOpt false "Whether or not to enable xremap key remapping."; + userName = mkOption { + type = types.str; + default = "harald"; + description = "User to run xremap as."; + }; + withGnome = mkBoolOpt true "Enable GNOME support."; + deviceNames = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "List of device names to remap."; + }; + config = mkOption { + type = types.attrs; + default = { }; + description = "Xremap configuration."; + }; + }; + + config = { + services.xremap = { + enable = cfg.enable; + userName = mkIf cfg.enable cfg.userName; + serviceMode = mkIf cfg.enable "user"; + withGnome = mkIf cfg.enable cfg.withGnome; + deviceNames = mkIf cfg.enable cfg.deviceNames; + config = mkIf cfg.enable cfg.config; + }; + + users.users.${cfg.userName}.extraGroups = mkIf cfg.enable [ "input" ]; + }; +} diff --git a/modules/nixos/system/kernel-tweaks/default.nix b/modules/nixos/system/kernel-tweaks/default.nix new file mode 100644 index 0000000..0443809 --- /dev/null +++ b/modules/nixos/system/kernel-tweaks/default.nix @@ -0,0 +1,29 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.system.kernelTweaks; +in +{ + options.metacfg.system.kernelTweaks = with types; { + enable = mkBoolOpt false "Whether or not to enable desktop kernel optimizations."; + pmFreezeTimeout = mkOption { + type = types.int; + default = 30000; + description = "PM freeze timeout in milliseconds."; + }; + enableZram = mkBoolOpt true "Enable zram swap."; + }; + + config = mkIf cfg.enable { + boot.kernel.sysctl = { + "power.pm_freeze_timeout" = cfg.pmFreezeTimeout; + }; + + zramSwap.enable = cfg.enableZram; + }; +} diff --git a/modules/nixos/system/no-sleep/default.nix b/modules/nixos/system/no-sleep/default.nix new file mode 100644 index 0000000..9e12659 --- /dev/null +++ b/modules/nixos/system/no-sleep/default.nix @@ -0,0 +1,28 @@ +{ + config, + lib, + ... +}: +with lib; +with lib.metacfg; +let + cfg = config.metacfg.system.noSleep; +in +{ + options.metacfg.system.noSleep = with types; { + enable = mkBoolOpt false "Whether or not to disable all sleep targets."; + disableGdmAutoSuspend = mkBoolOpt false "Disable GDM auto-suspend."; + ignoreLidSwitch = mkBoolOpt false "Ignore lid switch events."; + }; + + config = mkIf cfg.enable { + systemd.targets.sleep.enable = false; + systemd.targets.suspend.enable = false; + systemd.targets.hibernate.enable = false; + systemd.targets.hybrid-sleep.enable = false; + + services.displayManager.gdm.autoSuspend = mkIf cfg.disableGdmAutoSuspend false; + + services.logind.settings.Login.HandleLidSwitch = mkIf cfg.ignoreLidSwitch "ignore"; + }; +} diff --git a/systems/aarch64-linux/m4nix/default.nix b/systems/aarch64-linux/m4nix/default.nix index d8cd570..f8a6934 100644 --- a/systems/aarch64-linux/m4nix/default.nix +++ b/systems/aarch64-linux/m4nix/default.nix @@ -9,7 +9,13 @@ with lib.metacfg; services.spice-autorandr.enable = true; services.spice-vdagentd.enable = true; + services.resolved.enable = true; + services.resolved.extraConfig = '' + ResolveUnicastSingleLabel=yes + ''; + metacfg = { + system.noSleep.enable = true; base.enable = true; gui.enable = true; nix-ld.enable = true; @@ -34,13 +40,6 @@ with lib.metacfg; ]; }; - # Disable the GNOME3/GDM auto-suspend feature that cannot be disabled in GUI! - # If no user is logged in, the machine will power down after 20 minutes. - systemd.targets.sleep.enable = false; - systemd.targets.suspend.enable = false; - systemd.targets.hibernate.enable = false; - systemd.targets.hybrid-sleep.enable = false; - environment.systemPackages = with pkgs; [ azure-cli desktop-file-utils @@ -60,16 +59,11 @@ with lib.metacfg; services.ratbagd.enable = true; - services.resolved.enable = true; - #services.resolved.dnssec = "allow-downgrade"; - services.resolved.extraConfig = '' - ResolveUnicastSingleLabel=yes - ''; - virtualisation = { docker.enable = true; podman.dockerCompat = false; libvirtd.enable = false; + rosetta.enable = true; }; system.autoUpgrade = { @@ -78,7 +72,5 @@ with lib.metacfg; allowReboot = false; }; - virtualisation.rosetta.enable = true; - system.stateVersion = "25.05"; } diff --git a/systems/aarch64-linux/rnix/default.nix b/systems/aarch64-linux/rnix/default.nix index d8cd570..f8a6934 100644 --- a/systems/aarch64-linux/rnix/default.nix +++ b/systems/aarch64-linux/rnix/default.nix @@ -9,7 +9,13 @@ with lib.metacfg; services.spice-autorandr.enable = true; services.spice-vdagentd.enable = true; + services.resolved.enable = true; + services.resolved.extraConfig = '' + ResolveUnicastSingleLabel=yes + ''; + metacfg = { + system.noSleep.enable = true; base.enable = true; gui.enable = true; nix-ld.enable = true; @@ -34,13 +40,6 @@ with lib.metacfg; ]; }; - # Disable the GNOME3/GDM auto-suspend feature that cannot be disabled in GUI! - # If no user is logged in, the machine will power down after 20 minutes. - systemd.targets.sleep.enable = false; - systemd.targets.suspend.enable = false; - systemd.targets.hibernate.enable = false; - systemd.targets.hybrid-sleep.enable = false; - environment.systemPackages = with pkgs; [ azure-cli desktop-file-utils @@ -60,16 +59,11 @@ with lib.metacfg; services.ratbagd.enable = true; - services.resolved.enable = true; - #services.resolved.dnssec = "allow-downgrade"; - services.resolved.extraConfig = '' - ResolveUnicastSingleLabel=yes - ''; - virtualisation = { docker.enable = true; podman.dockerCompat = false; libvirtd.enable = false; + rosetta.enable = true; }; system.autoUpgrade = { @@ -78,7 +72,5 @@ with lib.metacfg; allowReboot = false; }; - virtualisation.rosetta.enable = true; - system.stateVersion = "25.05"; } diff --git a/systems/x86_64-linux/amd/default.nix b/systems/x86_64-linux/amd/default.nix index 08b0b84..c48ddf6 100644 --- a/systems/x86_64-linux/amd/default.nix +++ b/systems/x86_64-linux/amd/default.nix @@ -18,21 +18,17 @@ with lib.metacfg; 22000 ]; - services.tailscale.enable = true; - services.cratedocs-mcp.enable = true; services.openssh = { enable = true; }; - hardware.bluetooth.input.General.ClassicBondedOnly = false; - services.udev.extraRules = '' - KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e4c5", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" - KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e489", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" - ''; + services.tailscale.enable = true; + services.resolved.enable = true; metacfg = { + hardware.wooting.enable = true; base.enable = true; gui.enable = true; nix-ld.enable = true; @@ -59,15 +55,21 @@ with lib.metacfg; "dialout" "tss" ]; + system.kernelTweaks.enable = true; + }; + + system.autoUpgrade = { + enable = true; + operation = "boot"; + allowReboot = false; }; nixpkgs.config.permittedInsecurePackages = [ "electron-27.3.11" ]; - # Kernel tuning + # Additional kernel tuning beyond the module defaults boot.kernel.sysctl = { - "power.pm_freeze_timeout" = 30000; # Reduce swap usage (you have zram) "vm.swappiness" = 10; # Prefer keeping directory/inode caches @@ -111,32 +113,18 @@ with lib.metacfg; # zram swap with zstd compression for better performance zramSwap = { - enable = true; algorithm = "zstd"; memoryPercent = 50; }; services.ratbagd.enable = true; - services.resolved.enable = true; - - #services.resolved.dnssec = "allow-downgrade"; - #services.resolved.extraConfig = '' - # ResolveUnicastSingleLabel=yes - #''; - virtualisation = { libvirtd.enable = true; docker.enable = true; podman.dockerCompat = false; }; - system.autoUpgrade = { - enable = true; - operation = "boot"; - allowReboot = false; - }; - services.trezord.enable = true; services.ollama = { diff --git a/systems/x86_64-linux/amd/xremap.nix b/systems/x86_64-linux/amd/xremap.nix index 64a45c0..c28dd0a 100644 --- a/systems/x86_64-linux/amd/xremap.nix +++ b/systems/x86_64-linux/amd/xremap.nix @@ -1,33 +1,21 @@ -# In /etc/nixos/configuration.nix { ... }: { - users.users.harald.extraGroups = [ "input" ]; - - # Enable the xremap service - services.xremap.enable = true; - services.xremap.userName = "harald"; # Replace with your username - services.xremap.serviceMode = "user"; # Run as user service, not system-wide - services.xremap.withGnome = true; - - # Add a specific configuration block to select your keyboard(s) by name - services.xremap.deviceNames = [ - # Use the name found in the log output: "Hangsheng MonsGeek Keyboard System Control" - "Hangsheng MonsGeek Keyboard" - "HS Galaxy100 Keyboard" - # You can usually shorten the name slightly to match the device you want - ]; - - # Define your remapping configuration using Nix's attribute set format - services.xremap.config = { - keymap = [ - { - remap = { - # Map Alt+C (LeftAlt-C) to Ctrl+C (LeftControl-C) - LeftAlt-C = "COPY"; - LeftAlt-V = "PASTE"; - LeftAlt-X = "CUT"; - }; - } + metacfg.services.xremap = { + enable = true; + deviceNames = [ + "Hangsheng MonsGeek Keyboard" + "HS Galaxy100 Keyboard" ]; + config = { + keymap = [ + { + remap = { + LeftAlt-C = "COPY"; + LeftAlt-V = "PASTE"; + LeftAlt-X = "CUT"; + }; + } + ]; + }; }; } diff --git a/systems/x86_64-linux/mx/acme.nix b/systems/x86_64-linux/mx/acme.nix index 069bb2d..ee338c4 100644 --- a/systems/x86_64-linux/mx/acme.nix +++ b/systems/x86_64-linux/mx/acme.nix @@ -1,6 +1,4 @@ { - pkgs, - lib, config, ... }: @@ -9,14 +7,9 @@ sopsFile = ../../../.secrets/hetzner/internetbs.yaml; # bring your own password file }; - security.acme = { - acceptTerms = true; - defaults = { - email = "harald@hoyer.xyz"; - dnsProvider = "cloudflare"; - credentialsFile = config.sops.secrets.internetbs.path; - }; - certs = { + metacfg.services.acmeBase.credentialsFile = config.sops.secrets.internetbs.path; + + security.acme.certs = { "surfsite.org" = { extraDomainNames = [ "*.surfsite.org" ]; }; @@ -71,5 +64,4 @@ extraDomainNames = [ "*.harald-hoyer.de" ]; }; }; - }; } diff --git a/systems/x86_64-linux/mx/default.nix b/systems/x86_64-linux/mx/default.nix index e8ce185..d2130c7 100644 --- a/systems/x86_64-linux/mx/default.nix +++ b/systems/x86_64-linux/mx/default.nix @@ -22,6 +22,8 @@ services.tailscale.enable = true; metacfg = { + services.nginxBase.enable = true; + services.acmeBase.enable = true; emailOnFailure.enable = true; base.enable = true; nix.enable = true; @@ -42,7 +44,6 @@ dates = "04:00"; operation = "switch"; allowReboot = true; - # flake = lib.mkForce "git+file:///var/lib/gitea/repositories/harald/nixcfg.git#mx"; flake = lib.mkForce "/root/nixcfg/.#mx"; }; diff --git a/systems/x86_64-linux/mx/nginx.nix b/systems/x86_64-linux/mx/nginx.nix index 26556bf..e71eb46 100644 --- a/systems/x86_64-linux/mx/nginx.nix +++ b/systems/x86_64-linux/mx/nginx.nix @@ -1,21 +1,6 @@ { ... }: { - users.users.nginx.extraGroups = [ "acme" ]; - services.nginx = { - enable = true; - clientMaxBodySize = "1000M"; - appendHttpConfig = '' - log_format vcombined '$host:$server_port ' - '$remote_addr - $remote_user [$time_local] ' - '"$request" $status $body_bytes_sent ' - '"$http_referer" "$http_user_agent"'; - access_log /var/log/nginx/access.log vcombined; - ''; - recommendedGzipSettings = true; - recommendedOptimisation = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - virtualHosts = { + services.nginx.virtualHosts = { "00000" = { useACMEHost = "hoyer.xyz"; serverName = "_"; @@ -157,5 +142,4 @@ forceSSL = true; }; }; - }; } diff --git a/systems/x86_64-linux/nixtee1/default.nix b/systems/x86_64-linux/nixtee1/default.nix index 4ca3282..6c879ae 100644 --- a/systems/x86_64-linux/nixtee1/default.nix +++ b/systems/x86_64-linux/nixtee1/default.nix @@ -6,8 +6,6 @@ { imports = [ ./hardware-configuration.nix ]; - services.tailscale.enable = true; - boot.kernelPackages = lib.mkOverride 0 pkgs.linuxPackages_latest; boot.loader.systemd-boot.enable = false; # Bootloader. @@ -18,6 +16,8 @@ security.tpm2.enable = false; security.tpm2.abrmd.enable = false; + services.tailscale.enable = true; + metacfg = { base.enable = true; nix-ld.enable = true; @@ -37,12 +37,6 @@ podman.dockerCompat = false; }; - system.autoUpgrade = { - enable = true; - operation = "switch"; - allowReboot = true; - }; - networking.wireless.enable = false; # Enables wireless support via wpa_supplicant. networking.firewall.allowPing = true; @@ -66,5 +60,11 @@ } ]; + system.autoUpgrade = { + enable = true; + operation = "switch"; + allowReboot = true; + }; + system.stateVersion = "25.05"; } diff --git a/systems/x86_64-linux/sgx-attic/default.nix b/systems/x86_64-linux/sgx-attic/default.nix index 5cd7e1d..896c57c 100644 --- a/systems/x86_64-linux/sgx-attic/default.nix +++ b/systems/x86_64-linux/sgx-attic/default.nix @@ -1,7 +1,5 @@ { - pkgs, lib, - config, ... }: with lib; @@ -17,17 +15,17 @@ with lib.metacfg; nix.enable = true; }; - virtualisation = { - docker.enable = true; - podman.dockerCompat = false; - }; - system.autoUpgrade = { enable = true; operation = "switch"; allowReboot = true; }; + virtualisation = { + docker.enable = true; + podman.dockerCompat = false; + }; + security.tpm2.enable = false; security.tpm2.abrmd.enable = false; diff --git a/systems/x86_64-linux/sgx/acme.nix b/systems/x86_64-linux/sgx/acme.nix index b3e1272..76df2ef 100644 --- a/systems/x86_64-linux/sgx/acme.nix +++ b/systems/x86_64-linux/sgx/acme.nix @@ -7,14 +7,9 @@ sopsFile = ../../../.secrets/sgx/internetbs.yaml; # bring your own password file }; - security.acme = { - acceptTerms = true; - defaults = { - email = "harald@hoyer.xyz"; - dnsProvider = "cloudflare"; - credentialsFile = config.sops.secrets.internetbs.path; - }; - certs = { + metacfg.services.acmeBase.credentialsFile = config.sops.secrets.internetbs.path; + + security.acme.certs = { "internal.hoyer.world" = { extraDomainNames = [ "openwebui.hoyer.world" @@ -23,5 +18,4 @@ ]; }; }; - }; } diff --git a/systems/x86_64-linux/sgx/default.nix b/systems/x86_64-linux/sgx/default.nix index 0a95eef..e4ecaa8 100644 --- a/systems/x86_64-linux/sgx/default.nix +++ b/systems/x86_64-linux/sgx/default.nix @@ -12,8 +12,6 @@ ./wyoming.nix ]; - services.tailscale.enable = true; - boot.tmp.useTmpfs = false; sops.secrets.pccs.sopsFile = ../../../.secrets/sgx/pccs.yaml; @@ -23,7 +21,16 @@ claude-code ]; + services.tailscale.enable = true; + metacfg = { + services.nginxBase.enable = true; + services.acmeBase.enable = true; + system.noSleep = { + enable = true; + disableGdmAutoSuspend = true; + ignoreLidSwitch = true; + }; emailOnFailure.enable = true; base.enable = true; gui.enable = true; @@ -58,13 +65,5 @@ allowReboot = true; }; - systemd.targets.sleep.enable = false; - systemd.targets.suspend.enable = false; - systemd.targets.hibernate.enable = false; - systemd.targets.hybrid-sleep.enable = false; - services.displayManager.gdm.autoSuspend = false; - - services.logind.settings.Login.HandleLidSwitch = "ignore"; - system.stateVersion = "23.11"; } diff --git a/systems/x86_64-linux/sgx/nginx.nix b/systems/x86_64-linux/sgx/nginx.nix index 0c685c0..52f1cdc 100644 --- a/systems/x86_64-linux/sgx/nginx.nix +++ b/systems/x86_64-linux/sgx/nginx.nix @@ -3,22 +3,7 @@ ... }: { - users.users.nginx.extraGroups = [ "acme" ]; - services.nginx = { - enable = true; - clientMaxBodySize = "1000M"; - appendHttpConfig = '' - log_format vcombined '$host:$server_port ' - '$remote_addr - $remote_user [$time_local] ' - '"$request" $status $body_bytes_sent ' - '"$http_referer" "$http_user_agent"'; - access_log /var/log/nginx/access.log vcombined; - ''; - recommendedGzipSettings = true; - recommendedOptimisation = true; - recommendedProxySettings = true; - recommendedTlsSettings = true; - virtualHosts = { + services.nginx.virtualHosts = { "openwebui.hoyer.world" = { enableACME = false; useACMEHost = "internal.hoyer.world"; @@ -48,5 +33,4 @@ }; }; }; - }; } diff --git a/systems/x86_64-linux/t15/default.nix b/systems/x86_64-linux/t15/default.nix index cce5666..9e39ddc 100644 --- a/systems/x86_64-linux/t15/default.nix +++ b/systems/x86_64-linux/t15/default.nix @@ -2,6 +2,8 @@ { imports = [ ./hardware-configuration.nix ]; + services.resolved.enable = true; + metacfg = { base.enable = true; gui.enable = true; @@ -27,9 +29,6 @@ system.stateVersion = "23.11"; - services.resolved.enable = true; - #services.resolved.dnssec = "allow-downgrade"; - sops.age.sshKeyPaths = [ "/persist/ssh/ssh_host_ed25519_key" ]; sops.secrets.backup-s3.sopsFile = ../../../.secrets/t15/backup-s3.yaml; sops.secrets.backup-pw.sopsFile = ../../../.secrets/t15/backup-s3.yaml; diff --git a/systems/x86_64-linux/x1/default.nix b/systems/x86_64-linux/x1/default.nix index 96122d9..84fbaea 100644 --- a/systems/x86_64-linux/x1/default.nix +++ b/systems/x86_64-linux/x1/default.nix @@ -20,8 +20,6 @@ with lib.metacfg; programs.ccache.enable = true; nix.settings.extra-sandbox-paths = [ config.programs.ccache.cacheDir ]; - services.tailscale.enable = true; - services.cratedocs-mcp.enable = true; sops.age.sshKeyPaths = [ "/var/lib/secrets/ssh_host_ed25519_key" ]; @@ -45,13 +43,11 @@ with lib.metacfg; ]; }; - hardware.bluetooth.input.General.ClassicBondedOnly = false; - services.udev.extraRules = '' - KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e4c5", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" - KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="342d", ATTRS{idProduct}=="e489", MODE="0660", GROUP="users", TAG+="uaccess", TAG+="udev-acl" - ''; + services.tailscale.enable = true; + services.resolved.enable = true; metacfg = { + hardware.wooting.enable = true; base.enable = true; gui.enable = true; nix-ld.enable = true; @@ -77,17 +73,19 @@ with lib.metacfg; "dialout" "tss" ]; + system.kernelTweaks.enable = true; + }; + + system.autoUpgrade = { + enable = true; + operation = "boot"; + allowReboot = false; }; nixpkgs.config.permittedInsecurePackages = [ "electron-27.3.11" ]; - # increase freezing timeout - boot.kernel.sysctl = { - "power.pm_freeze_timeout" = 30000; - }; - environment.systemPackages = with pkgs; [ attic-client azure-cli @@ -112,26 +110,12 @@ with lib.metacfg; vscode ]; - zramSwap.enable = true; - services.ratbagd.enable = true; - services.resolved.enable = true; - #services.resolved.dnssec = "allow-downgrade"; - #services.resolved.extraConfig = '' - # ResolveUnicastSingleLabel=yes - #''; - virtualisation = { libvirtd.enable = true; }; - system.autoUpgrade = { - enable = true; - operation = "boot"; - allowReboot = false; - }; - services.trezord.enable = true; services.ollama = { diff --git a/systems/x86_64-linux/x1/xremap.nix b/systems/x86_64-linux/x1/xremap.nix index 64a45c0..c28dd0a 100644 --- a/systems/x86_64-linux/x1/xremap.nix +++ b/systems/x86_64-linux/x1/xremap.nix @@ -1,33 +1,21 @@ -# In /etc/nixos/configuration.nix { ... }: { - users.users.harald.extraGroups = [ "input" ]; - - # Enable the xremap service - services.xremap.enable = true; - services.xremap.userName = "harald"; # Replace with your username - services.xremap.serviceMode = "user"; # Run as user service, not system-wide - services.xremap.withGnome = true; - - # Add a specific configuration block to select your keyboard(s) by name - services.xremap.deviceNames = [ - # Use the name found in the log output: "Hangsheng MonsGeek Keyboard System Control" - "Hangsheng MonsGeek Keyboard" - "HS Galaxy100 Keyboard" - # You can usually shorten the name slightly to match the device you want - ]; - - # Define your remapping configuration using Nix's attribute set format - services.xremap.config = { - keymap = [ - { - remap = { - # Map Alt+C (LeftAlt-C) to Ctrl+C (LeftControl-C) - LeftAlt-C = "COPY"; - LeftAlt-V = "PASTE"; - LeftAlt-X = "CUT"; - }; - } + metacfg.services.xremap = { + enable = true; + deviceNames = [ + "Hangsheng MonsGeek Keyboard" + "HS Galaxy100 Keyboard" ]; + config = { + keymap = [ + { + remap = { + LeftAlt-C = "COPY"; + LeftAlt-V = "PASTE"; + LeftAlt-X = "CUT"; + }; + } + ]; + }; }; } From 0523639f2aab13d5a90813adce6c85e0baf0178c Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 30 Jan 2026 11:52:59 +0100 Subject: [PATCH 02/18] feat(nix): add nvtop package to amd system - Added `nvtopPackages.amd` to the package list for better GPU monitoring on AMD systems. - Enhances system configuration by enabling real-time visualization of GPU usage. --- systems/x86_64-linux/amd/default.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/systems/x86_64-linux/amd/default.nix b/systems/x86_64-linux/amd/default.nix index c48ddf6..795fe5d 100644 --- a/systems/x86_64-linux/amd/default.nix +++ b/systems/x86_64-linux/amd/default.nix @@ -103,6 +103,7 @@ with lib.metacfg; kubectl kubectx logseq + nvtopPackages.amd obsidian piper-tts tipp10 From eb10ad018ff6bc81e9f85a2eb8673896cf133da7 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 30 Jan 2026 11:53:03 +0100 Subject: [PATCH 03/18] chore(nix): update flake.lock - Updated flake.lock to incorporate the latest revisions for locked dependencies. - Includes updates for `homebrew`, `sops-nix`, `nixos-hardware`, `rust-overlay`, and more. - Ensures the system remains aligned with the most recent upstream changes. --- flake.lock | 88 +++++++++++++++++++++++++++--------------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/flake.lock b/flake.lock index da101c7..644d00b 100644 --- a/flake.lock +++ b/flake.lock @@ -19,16 +19,16 @@ "brew-src": { "flake": false, "locked": { - "lastModified": 1763638478, - "narHash": "sha256-n/IMowE9S23ovmTkKX7KhxXC2Yq41EAVFR2FBIXPcT8=", + "lastModified": 1769363988, + "narHash": "sha256-BiGPeulrDVetXP+tjxhMcGLUROZAtZIhU5m4MqawCfM=", "owner": "Homebrew", "repo": "brew", - "rev": "fbfdbaba008189499958a7aeb1e2c36ab10c067d", + "rev": "d01011cac6d72032c75fd2cd9489909e95d9faf2", "type": "github" }, "original": { "owner": "Homebrew", - "ref": "5.0.3", + "ref": "5.0.12", "repo": "brew", "type": "github" } @@ -134,11 +134,11 @@ ] }, "locked": { - "lastModified": 1768923567, - "narHash": "sha256-GVJ0jKsyXLuBzRMXCDY6D5J8wVdwP1DuQmmvYL/Vw/Q=", + "lastModified": 1769524058, + "narHash": "sha256-zygdD6X1PcVNR2PsyK4ptzrVEiAdbMqLos7utrMDEWE=", "owner": "nix-community", "repo": "disko", - "rev": "00395d188e3594a1507f214a2f15d4ce5c07cb28", + "rev": "71a3fc97d80881e91710fe721f1158d3b96ae14d", "type": "github" }, "original": { @@ -421,11 +421,11 @@ ] }, "locked": { - "lastModified": 1768949235, - "narHash": "sha256-TtjKgXyg1lMfh374w5uxutd6Vx2P/hU81aEhTxrO2cg=", + "lastModified": 1769580047, + "narHash": "sha256-tNqCP/+2+peAXXQ2V8RwsBkenlfWMERb+Uy6xmevyhM=", "owner": "nix-community", "repo": "home-manager", - "rev": "75ed713570ca17427119e7e204ab3590cc3bf2a5", + "rev": "366d78c2856de6ab3411c15c1cb4fb4c2bf5c826", "type": "github" }, "original": { @@ -454,11 +454,11 @@ "homebrew-cask": { "flake": false, "locked": { - "lastModified": 1769077283, - "narHash": "sha256-alvFQmhX8POHxBP3/jResx6AJ06X+k6SF4/CiNndpPA=", + "lastModified": 1769770011, + "narHash": "sha256-Z+qyxP9dQVk1xBJKJvrvKg2/8SGnYEUArs5vJuhc4ZE=", "owner": "homebrew", "repo": "homebrew-cask", - "rev": "4a8185e145fa4fc8326705c666d608c3ee761612", + "rev": "4b98892b8c059ebc23e6516c917f6b01741a2969", "type": "github" }, "original": { @@ -470,11 +470,11 @@ "homebrew-core": { "flake": false, "locked": { - "lastModified": 1769077518, - "narHash": "sha256-QtWC5CcY9xzfjcThSwZgise9RXbM2vZmw+Tot67RiJo=", + "lastModified": 1769769028, + "narHash": "sha256-9RhJZXZO/PJ7A+917XRROv8xPtzHlPthtAMhunUAfM0=", "owner": "homebrew", "repo": "homebrew-core", - "rev": "2ac083c750fa2a6999ad05a7352e8edbd7abd969", + "rev": "95b2944276a57b176eadc835575c3b591f88999f", "type": "github" }, "original": { @@ -562,11 +562,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1768906339, - "narHash": "sha256-iwkHIz2IYRcELkBoKXQUHlP0bFGmrHIz/roJUVYsyx8=", + "lastModified": 1769716128, + "narHash": "sha256-CAsiyTNjI0WmtJstw3kGyL7Q1jPCn7AsO6Ms47G+x3w=", "owner": "NotAShelf", "repo": "nvf", - "rev": "18c55d3bebf2c704970b4ea6fd0261808bec8d94", + "rev": "866b983c4047b87bcdca6ab3673ed7bd602f0251", "type": "github" }, "original": { @@ -580,11 +580,11 @@ "brew-src": "brew-src" }, "locked": { - "lastModified": 1764473698, - "narHash": "sha256-C91gPgv6udN5WuIZWNehp8qdLqlrzX6iF/YyboOj6XI=", + "lastModified": 1769437432, + "narHash": "sha256-8d7KnCpT2LweRvSzZYEGd9IM3eFX+A78opcnDM0+ndk=", "owner": "zhaofengli-wip", "repo": "nix-homebrew", - "rev": "6a8ab60bfd66154feeaa1021fc3b32684814a62a", + "rev": "a5409abd0d5013d79775d3419bcac10eacb9d8c5", "type": "github" }, "original": { @@ -595,11 +595,11 @@ }, "nixos-hardware": { "locked": { - "lastModified": 1768736227, - "narHash": "sha256-qgGq7CfrYKc3IBYQ7qp0Z/ZXndQVC5Bj0N8HW9mS2rM=", + "lastModified": 1769302137, + "narHash": "sha256-QEDtctEkOsbx8nlFh4yqPEOtr4tif6KTqWwJ37IM2ds=", "owner": "NixOS", "repo": "nixos-hardware", - "rev": "d447553bcbc6a178618d37e61648b19e744370df", + "rev": "a351494b0e35fd7c0b7a1aae82f0afddf4907aa8", "type": "github" }, "original": { @@ -642,11 +642,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1768940263, - "narHash": "sha256-sJERJIYTKPFXkoz/gBaBtRKke82h4DkX3BBSsKbfbvI=", + "lastModified": 1769598131, + "narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=", "owner": "nixos", "repo": "nixpkgs", - "rev": "3ceaaa8bc963ced4d830e06ea2d0863b6490ff03", + "rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211", "type": "github" }, "original": { @@ -748,11 +748,11 @@ ] }, "locked": { - "lastModified": 1769050281, - "narHash": "sha256-1H8DN4UZgEUqPUA5ecHOufLZMscJ4IlcGaEftaPtpBY=", + "lastModified": 1769742225, + "narHash": "sha256-roSD/OJ3x9nF+Dxr+/bLClX3U8FP9EkCQIFpzxKjSUM=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "6deef0585c52d9e70f96b6121207e1496d4b0c49", + "rev": "bcdd8d37594f0e201639f55889c01c827baf5c75", "type": "github" }, "original": { @@ -835,11 +835,11 @@ ] }, "locked": { - "lastModified": 1768863606, - "narHash": "sha256-1IHAeS8WtBiEo5XiyJBHOXMzECD6aaIOJmpQKzRRl64=", + "lastModified": 1769469829, + "narHash": "sha256-wFcr32ZqspCxk4+FvIxIL0AZktRs6DuF8oOsLt59YBU=", "owner": "Mic92", "repo": "sops-nix", - "rev": "c7067be8db2c09ab1884de67ef6c4f693973f4a2", + "rev": "c5eebd4eb2e3372fe12a8d70a248a6ee9dd02eff", "type": "github" }, "original": { @@ -932,11 +932,11 @@ }, "unstable": { "locked": { - "lastModified": 1768886240, - "narHash": "sha256-C2TjvwYZ2VDxYWeqvvJ5XPPp6U7H66zeJlRaErJKoEM=", + "lastModified": 1769461804, + "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", "owner": "nixos", "repo": "nixpkgs", - "rev": "80e4adbcf8992d3fd27ad4964fbb84907f9478b0", + "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", "type": "github" }, "original": { @@ -949,16 +949,16 @@ "xremap": { "flake": false, "locked": { - "lastModified": 1766606475, - "narHash": "sha256-FPZ4iQA/vVZGzbO8i8lTK8i9A3zs9BLqMvTMeAVv9rQ=", + "lastModified": 1769021727, + "narHash": "sha256-2wylBk3+Zu1pHa41dhKwvUtxOVyHSMRDfOD9fIp8x2I=", "owner": "k0kubun", "repo": "xremap", - "rev": "cdc744d873c19899ef21f329c4305b4b5e53d459", + "rev": "890e0a6ca92e90f3bcbd1e235abcf2192e233a46", "type": "github" }, "original": { "owner": "k0kubun", - "ref": "v0.14.8", + "ref": "v0.14.10", "repo": "xremap", "type": "github" } @@ -971,11 +971,11 @@ "xremap": "xremap" }, "locked": { - "lastModified": 1767318478, - "narHash": "sha256-h3oE50RedA8DRGrFU+Hv2kirt4rmzdaC9oSD+MSg9Ms=", + "lastModified": 1769636170, + "narHash": "sha256-X000Dgg053Dv9NIzm1b9QYSAHYtW2jHMVALQezui7L0=", "owner": "xremap", "repo": "nix-flake", - "rev": "9a2224aa01a3c86e94b398c33329c8ff6496dc5d", + "rev": "00bc6dd4275d4b003a17ef7f5f271ba87f73d698", "type": "github" }, "original": { From bc6091f63f805b2f91d3cab32c157a4c823acf79 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 15:40:57 +0100 Subject: [PATCH 04/18] feat(nix): add Nextcloud Claude Bot integration - Added configuration for Nextcloud Claude Bot, including NixOS module, secrets management, and example setup files. - Introduced a Python-based HTTP server for handling webhook events and interacting with Nextcloud Talk. - Integrated necessary dependencies and systemd service for seamless operation. --- .secrets/hetzner/nextcloud-claude-bot.yaml | 35 +++ systems/x86_64-linux/mx/default.nix | 1 + .../mx/nextcloud-claude-bot/README.md | 146 +++++++++ .../mx/nextcloud-claude-bot/bot.py | 294 ++++++++++++++++++ .../mx/nextcloud-claude-bot/default.nix | 31 ++ .../nextcloud-claude-bot/example-config.nix | 80 +++++ .../mx/nextcloud-claude-bot/module.nix | 141 +++++++++ 7 files changed, 728 insertions(+) create mode 100644 .secrets/hetzner/nextcloud-claude-bot.yaml create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/README.md create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix create mode 100644 systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix diff --git a/.secrets/hetzner/nextcloud-claude-bot.yaml b/.secrets/hetzner/nextcloud-claude-bot.yaml new file mode 100644 index 0000000..4d638f0 --- /dev/null +++ b/.secrets/hetzner/nextcloud-claude-bot.yaml @@ -0,0 +1,35 @@ +nextcloud-claude-bot: + secret: ENC[AES256_GCM,data:I0YxTjU89dDFnpF/TwZYBliLDyre0kNZbWvJD5Jdleihe1LGEptcLuTN0lkO9I8z9U7GDGxoAprb8W+5d2MQrA==,iv:m/q82cfbFID0aW3KfXCZSIa7FhtGx/3TCxv5x8GXVk0=,tag:+IuHUKVqdGrU0RS18NUlPg==,type:str] +sops: + age: + - recipient: age1qur4kh3gay9ryk3jh2snvjp6x9eq94zdrmgkrfcv4fzsu7l6lumq4tr3uy + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBwUkZhbzhZbHZQRDJvZW1q + QjlIS2IxL1NaSnZ6L3JDU3hENEh4SFBBLzN3CnRvZkFmOUIzWUgybGdPblp4UmRH + U1JmUCt5WkNUc09EdktUdFBHY0lKUFkKLS0tIDZHRXRtZTBROGJJcFhMVDM4ZDJt + OGZOUElSNGJmaEtPalQ5MXBxQUFaRFkKu2EIbPsNMkejgc2rVC/nL5G2Hfp1IkiA + 3CV36NHFXKRlo8Fxj+hl1Fi063TRlNW0TK5fc15u4En7tdMnCdfJ+A== + -----END AGE ENCRYPTED FILE----- + - recipient: age1dwcz3fmp29ju4svy0t0wz4ylhpwlqa8xpw4l7t4gmgqr0ev37qrsfn840l + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPQTd4U0ZVOHJKMUgyLzF5 + RkpiVmMzYTRZS21ZUnNERTB3K2pDSXpFMlVjClkxNDl6WlcyN0xBT3MzYWVOWnNL + UldRZER4YVFuSHZ0S3BMSVZLQm5pRWcKLS0tIEpZVlA2RFZGbElUQWVWb3c5OSt3 + WlpSVGx4OEJGYU52L2xkdmNteWdGUE0KS0Xa9GmwTiAURgC72OhNLHW1/XgHyHFZ + 4yQ2qri2m14E5oheB8ELzMMY9K/yQUs90UqdZIS8UoSeaG4GqjEuQA== + -----END AGE ENCRYPTED FILE----- + - recipient: age1cpm9xhgue7sjvq7zyeeaxwr96c93sfzxxxj76sxsq7s7kgnygvcq5jxren + enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSA4YjNVSE9mdjhKY2hWVGJ3 + d3VnVDBJODRyMkRyMUJUREwvT0ZUUkRtVUU0CjFOWCtFK05saHNTWGRoazQ2aVgw + bnlPMUNmdVVSUEFoVEtkaXcwVklETm8KLS0tIFBWMERoR0ZiMDJ1bW5May9RSWlv + VktQbU9STjNRVTh6TndIRVBLdFVFUVkKz0dBpDQ9+/Pp3FKsBpcmzuEROsZ65jkw + 9LRQTMGF6kSrbLjRkBs21t5t2kunKgCriAmd8Nv+S/sG/NKqpQMJ6A== + -----END AGE ENCRYPTED FILE----- + lastmodified: "2026-02-03T14:36:03Z" + mac: ENC[AES256_GCM,data:y0gOLMHjzv2ER+Bvo0glkY2EC28K2uWtPPhv0EDzb4PczGDNgQWGHhdyFsN07+JJIf2LMpKV1u7BMp4e/dF1wDgZsR6wErZLxuLrXfZ6B7mTDOPGUR1rGo5PhbNIO90LL5uQ/aRLl38efqxgU8fHCkuXJkUtM38UQ9+7JN4PVic=,iv:2AKgYujqxeGiiVMhqC8FGFiYbTcogxZx/uUgh+8XowQ=,tag:3RwH2AboBU9T25fWjecsMQ==,type:str] + unencrypted_suffix: _unencrypted + version: 3.11.0 diff --git a/systems/x86_64-linux/mx/default.nix b/systems/x86_64-linux/mx/default.nix index d2130c7..3efb22c 100644 --- a/systems/x86_64-linux/mx/default.nix +++ b/systems/x86_64-linux/mx/default.nix @@ -12,6 +12,7 @@ ./mailserver.nix ./network.nix ./nextcloud.nix + ./nextcloud-claude-bot ./nginx.nix ./postgresql.nix ./rspamd.nix diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md b/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md new file mode 100644 index 0000000..a16b3f8 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/README.md @@ -0,0 +1,146 @@ +# Nextcloud Claude Bot Setup + +## Voraussetzungen + +- NixOS Server mit Nextcloud (Talk App aktiviert) +- Claude Code CLI installiert und authentifiziert +- Nextcloud Talk Version 17+ (Nextcloud 26+) + +## 1. Bot Secret generieren + +```bash +openssl rand -hex 32 > /var/secrets/nextcloud-claude-bot +chmod 600 /var/secrets/nextcloud-claude-bot +``` + +## 2. NixOS Konfiguration + +Kopiere die Dateien nach `/etc/nixos/nextcloud-claude-bot/` oder in dein Flake: + +``` +/etc/nixos/ +├── configuration.nix +└── nextcloud-claude-bot/ + ├── module.nix + └── bot.py +``` + +Füge das Modul zu deiner `configuration.nix` hinzu (siehe `example-config.nix`). + +## 3. System rebuilden + +```bash +nixos-rebuild switch +``` + +## 4. Bot bei Nextcloud registrieren + +```bash +# Als root oder mit sudo +cd /var/www/nextcloud # oder wo dein Nextcloud liegt + +# Bot secret auslesen +BOT_SECRET=$(cat /var/secrets/nextcloud-claude-bot) + +# Bot installieren +sudo -u nextcloud php occ talk:bot:install \ + "Claude" \ + "Claude AI Assistant" \ + "http://127.0.0.1:8085/webhook" \ + "$BOT_SECRET" +``` + +Falls der Bot extern erreichbar sein muss: +```bash +sudo -u nextcloud php occ talk:bot:install \ + "Claude" \ + "Claude AI Assistant" \ + "https://cloud.example.com/_claude-bot/webhook" \ + "$BOT_SECRET" +``` + +## 5. Bot aktivieren + +Nach der Installation musst du den Bot für Konversationen aktivieren: + +```bash +# Liste alle Bots +sudo -u nextcloud php occ talk:bot:list + +# Bot für alle User verfügbar machen (optional) +sudo -u nextcloud php occ talk:bot:state 1 +``` + +## 6. Testen + +1. Öffne Nextcloud Talk +2. Starte einen neuen Chat mit dem Bot (suche nach "Claude") +3. Schreibe eine Nachricht + +### Health Check + +```bash +curl http://127.0.0.1:8085/health +``` + +### Logs prüfen + +```bash +journalctl -u nextcloud-claude-bot -f +``` + +## Troubleshooting + +### Bot antwortet nicht + +1. Prüfe ob der Service läuft: + ```bash + systemctl status nextcloud-claude-bot + ``` + +2. Prüfe die Logs: + ```bash + journalctl -u nextcloud-claude-bot -n 50 + ``` + +3. Teste den Webhook manuell: + ```bash + curl -X POST http://127.0.0.1:8085/webhook \ + -H "Content-Type: application/json" \ + -d '{"actor":{"type":"users","id":"harald"},"message":{"message":"test","id":1},"conversation":{"token":"abc123","type":1}}' + ``` + +### Claude CLI Fehler + +Stelle sicher, dass Claude CLI als der Service-User funktioniert: + +```bash +# Teste als der User +sudo -u nextcloud-claude-bot claude --print "Hello" +``` + +Die Claude CLI Config liegt in `/var/lib/nextcloud-claude-bot/.config/claude/`. + +### Signature Fehler + +Prüfe ob das Bot Secret in Nextcloud und im Service übereinstimmt: + +```bash +# Secret im Service +cat /var/secrets/nextcloud-claude-bot + +# Secret in Nextcloud (verschlüsselt gespeichert) +sudo -u nextcloud php occ talk:bot:list +``` + +## Befehle im Chat + +- `/help` oder `/hilfe` – Hilfe anzeigen +- `/clear` oder `/reset` – Konversation zurücksetzen + +## Sicherheitshinweise + +- Der Bot läuft nur auf localhost und ist nicht direkt erreichbar +- Nur in `allowedUsers` gelistete Nutzer können den Bot verwenden +- Webhook-Signaturen werden verifiziert +- DynamicUser isoliert den Service diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py new file mode 100644 index 0000000..d1eef3e --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Nextcloud Talk Claude Bot + +Receives webhooks from Nextcloud Talk and responds using Claude CLI. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import subprocess +from datetime import datetime +from typing import Optional + +import httpx +from fastapi import FastAPI, Request, HTTPException, Header +from fastapi.responses import JSONResponse + +# Configuration from environment +NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/") +CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude") +ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()] +MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096")) +TIMEOUT = int(os.environ.get("TIMEOUT", "120")) +SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") + +# Bot secret from systemd credential +def get_bot_secret() -> str: + cred_path = os.environ.get("CREDENTIALS_DIRECTORY", "") + if cred_path: + secret_file = os.path.join(cred_path, "bot-secret") + if os.path.exists(secret_file): + with open(secret_file) as f: + return f.read().strip() + # Fallback for development + return os.environ.get("BOT_SECRET", "") + +BOT_SECRET = get_bot_secret() + +# Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s" +) +log = logging.getLogger(__name__) + +app = FastAPI(title="Nextcloud Claude Bot") + +# Simple in-memory conversation history (per user) +# Format: {user_id: [(timestamp, role, message), ...]} +conversations: dict[str, list[tuple[datetime, str, str]]] = {} +MAX_HISTORY = 10 # Keep last N exchanges per user + + +def verify_signature(body: bytes, signature: str) -> bool: + """Verify Nextcloud webhook signature.""" + if not BOT_SECRET: + log.warning("No bot secret configured, skipping signature verification") + return True + + expected = hmac.new( + BOT_SECRET.encode(), + body, + hashlib.sha256 + ).hexdigest() + + # Nextcloud sends: sha256= + if signature.startswith("sha256="): + signature = signature[7:] + + return hmac.compare_digest(expected, signature) + + +def build_prompt(user_id: str, message: str) -> str: + """Build prompt with conversation history.""" + history = conversations.get(user_id, []) + + parts = [] + + if SYSTEM_PROMPT: + parts.append(f"System: {SYSTEM_PROMPT}\n") + + # Add recent history + for ts, role, msg in history[-MAX_HISTORY:]: + prefix = "User" if role == "user" else "Assistant" + parts.append(f"{prefix}: {msg}") + + # Add current message + parts.append(f"User: {message}") + + return "\n\n".join(parts) + + +async def call_claude(prompt: str) -> str: + """Call Claude CLI and return response.""" + cmd = [CLAUDE_PATH, "--print"] + + if MAX_TOKENS: + cmd.extend(["--max-tokens", str(MAX_TOKENS)]) + + log.info(f"Calling Claude: {' '.join(cmd)}") + + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + + stdout, stderr = await asyncio.wait_for( + proc.communicate(prompt.encode()), + timeout=TIMEOUT + ) + + if proc.returncode != 0: + log.error(f"Claude CLI error: {stderr.decode()}") + return f"❌ Fehler beim Aufruf von Claude: {stderr.decode()[:200]}" + + return stdout.decode().strip() + + except asyncio.TimeoutError: + log.error(f"Claude CLI timeout after {TIMEOUT}s") + return f"⏱️ Timeout: Claude hat nicht innerhalb von {TIMEOUT}s geantwortet." + except Exception as e: + log.exception("Error calling Claude") + return f"❌ Fehler: {str(e)}" + + +async def send_reply(conversation_token: str, message: str, reply_to: int = None): + """Send reply back to Nextcloud Talk.""" + if not NEXTCLOUD_URL: + log.error("NEXTCLOUD_URL not configured") + return + + url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message" + + headers = { + "OCS-APIRequest": "true", + "Content-Type": "application/json", + } + + # Bot authentication + if BOT_SECRET: + # Generate random string for request + import secrets + random = secrets.token_hex(32) + digest = hmac.new( + BOT_SECRET.encode(), + (random + message).encode(), + hashlib.sha256 + ).hexdigest() + headers["X-Nextcloud-Talk-Bot-Random"] = random + headers["X-Nextcloud-Talk-Bot-Signature"] = digest + + payload = { + "message": message, + "referenceId": hashlib.sha256(f"{conversation_token}-{datetime.now().isoformat()}".encode()).hexdigest()[:32], + } + + if reply_to: + payload["replyTo"] = reply_to + + async with httpx.AsyncClient() as client: + try: + resp = await client.post(url, json=payload, headers=headers) + if resp.status_code not in (200, 201): + log.error(f"Failed to send reply: {resp.status_code} {resp.text}") + else: + log.info(f"Reply sent to conversation {conversation_token}") + except Exception as e: + log.exception("Error sending reply to Nextcloud") + + +@app.post("/webhook") +async def handle_webhook( + request: Request, + x_nextcloud_talk_signature: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Signature"), +): + """Handle incoming webhook from Nextcloud Talk.""" + body = await request.body() + + # Verify signature + if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature): + log.warning("Invalid webhook signature") + raise HTTPException(status_code=401, detail="Invalid signature") + + try: + data = json.loads(body) + except json.JSONDecodeError: + raise HTTPException(status_code=400, detail="Invalid JSON") + + log.info(f"Received webhook: {json.dumps(data, indent=2)[:500]}") + + # Extract message info + # Nextcloud Talk Bot API structure + actor = data.get("actor", {}) + actor_id = actor.get("id", "") + actor_type = actor.get("type", "") + + message_data = data.get("message", {}) + message_text = message_data.get("message", "") + message_id = message_data.get("id") + + conversation = data.get("conversation", {}) + conversation_token = conversation.get("token", "") + conversation_type = conversation.get("type", 0) + + # Only respond to user messages in one-on-one chats (type 1) + if actor_type != "users": + log.info(f"Ignoring non-user actor: {actor_type}") + return JSONResponse({"status": "ignored", "reason": "not a user message"}) + + if conversation_type != 1: # 1 = one-to-one + log.info(f"Ignoring non-DM conversation type: {conversation_type}") + return JSONResponse({"status": "ignored", "reason": "not a direct message"}) + + # Check allowed users + if ALLOWED_USERS and actor_id not in ALLOWED_USERS: + log.warning(f"User {actor_id} not in allowed list") + await send_reply( + conversation_token, + "🚫 Du bist nicht berechtigt, diesen Bot zu nutzen.", + reply_to=message_id + ) + return JSONResponse({"status": "rejected", "reason": "user not allowed"}) + + if not message_text.strip(): + return JSONResponse({"status": "ignored", "reason": "empty message"}) + + log.info(f"Processing message from {actor_id}: {message_text[:100]}") + + # Store user message in history + if actor_id not in conversations: + conversations[actor_id] = [] + conversations[actor_id].append((datetime.now(), "user", message_text)) + + # Handle special commands + if message_text.strip().lower() in ("/clear", "/reset", "/neu"): + conversations[actor_id] = [] + await send_reply( + conversation_token, + "🧹 Konversation zurückgesetzt.", + reply_to=message_id + ) + return JSONResponse({"status": "ok", "action": "cleared"}) + + if message_text.strip().lower() in ("/help", "/hilfe"): + help_text = """🤖 **Claude Bot Hilfe** + +Schreib mir einfach eine Nachricht und ich antworte dir. + +**Befehle:** +• `/clear` oder `/reset` – Konversation zurücksetzen +• `/help` oder `/hilfe` – Diese Hilfe anzeigen + +Der Bot merkt sich die letzten Nachrichten für Kontext.""" + await send_reply(conversation_token, help_text, reply_to=message_id) + return JSONResponse({"status": "ok", "action": "help"}) + + # Build prompt and call Claude + prompt = build_prompt(actor_id, message_text) + response = await call_claude(prompt) + + # Store assistant response in history + conversations[actor_id].append((datetime.now(), "assistant", response)) + + # Trim history + if len(conversations[actor_id]) > MAX_HISTORY * 2: + conversations[actor_id] = conversations[actor_id][-MAX_HISTORY * 2:] + + # Send response + await send_reply(conversation_token, response, reply_to=message_id) + + return JSONResponse({"status": "ok"}) + + +@app.get("/health") +async def health(): + """Health check endpoint.""" + return { + "status": "ok", + "nextcloud_url": NEXTCLOUD_URL, + "claude_path": CLAUDE_PATH, + "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="127.0.0.1", port=8085) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix new file mode 100644 index 0000000..7d61fe7 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -0,0 +1,31 @@ +{ config, ... }: +{ + imports = [ ./module.nix ]; + + services.nextcloud-claude-bot = { + enable = true; + nextcloudUrl = "https://nc.hoyer.xyz"; + botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; + allowedUsers = [ "harald" ]; + }; + + sops.secrets."nextcloud-claude-bot/secret" = { + sopsFile = ../../../../.secrets/hetzner/nextcloud-claude-bot.yaml; + restartUnits = [ "nextcloud-claude-bot.service" ]; + }; + + # Nginx location for Nextcloud to send webhooks to the bot + services.nginx.virtualHosts."nc.hoyer.xyz".locations."/_claude-bot/" = { + proxyPass = "http://127.0.0.1:8085/"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Only allow from localhost (Nextcloud on same server) + allow 127.0.0.1; + deny all; + ''; + }; +} diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix new file mode 100644 index 0000000..9560ff3 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/example-config.nix @@ -0,0 +1,80 @@ +# Example NixOS configuration for the Nextcloud Claude Bot +# Add this to your configuration.nix or a separate module + +{ config, pkgs, ... }: + +{ + imports = [ + ./nextcloud-claude-bot/module.nix + ]; + + # Install Claude Code CLI + # Note: You'll need to either: + # 1. Use the official package if available in nixpkgs + # 2. Package it yourself + # 3. Use a binary wrapper + + # Option 1: If claude-code is in nixpkgs (check latest state) + # environment.systemPackages = [ pkgs.claude-code ]; + + # Option 2: Manual binary installation wrapper + nixpkgs.overlays = [ + (final: prev: { + claude-code = final.writeShellScriptBin "claude" '' + # Assumes claude is installed via npm globally or similar + exec ${final.nodejs}/bin/node /opt/claude-code/cli.js "$@" + ''; + }) + ]; + + # Create bot secret + # Generate with: openssl rand -hex 32 + # Store in a file, e.g., /var/secrets/nextcloud-claude-bot + + services.nextcloud-claude-bot = { + enable = true; + port = 8085; + host = "127.0.0.1"; + + nextcloudUrl = "https://cloud.example.com"; + botSecretFile = "/var/secrets/nextcloud-claude-bot"; + + # Only allow specific users + allowedUsers = [ "harald" ]; + + # Claude settings + maxTokens = 4096; + timeout = 120; + + # Optional system prompt + systemPrompt = '' + Du bist ein hilfreicher Assistent. Antworte auf Deutsch, + es sei denn der Nutzer schreibt auf Englisch. + ''; + }; + + # Ensure secrets directory exists with proper permissions + systemd.tmpfiles.rules = [ + "d /var/secrets 0750 root root -" + ]; + + # If Nextcloud runs locally, bot can stay on localhost. + # If you need external access (e.g., Nextcloud on different server): + services.nginx.virtualHosts."cloud.example.com" = { + # ... your existing Nextcloud config ... + + locations."/_claude-bot/" = { + proxyPass = "http://127.0.0.1:8085/"; + extraConfig = '' + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Only allow from Nextcloud itself + allow 127.0.0.1; + deny all; + ''; + }; + }; +} diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix new file mode 100644 index 0000000..2cf2495 --- /dev/null +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -0,0 +1,141 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.nextcloud-claude-bot; + + botScript = pkgs.python3Packages.buildPythonApplication { + pname = "nextcloud-claude-bot"; + version = "0.1.0"; + format = "other"; + + propagatedBuildInputs = with pkgs.python3Packages; [ + fastapi + uvicorn + httpx + ]; + + dontUnpack = true; + + installPhase = '' + mkdir -p $out/bin + cp ${./bot.py} $out/bin/nextcloud-claude-bot + chmod +x $out/bin/nextcloud-claude-bot + ''; + }; + +in { + options.services.nextcloud-claude-bot = { + enable = mkEnableOption "Nextcloud Talk Claude Bot"; + + port = mkOption { + type = types.port; + default = 8085; + description = "Port for the webhook listener"; + }; + + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "Host to bind to"; + }; + + nextcloudUrl = mkOption { + type = types.str; + example = "https://cloud.example.com"; + description = "Base URL of your Nextcloud instance"; + }; + + botSecretFile = mkOption { + type = types.path; + description = "Path to file containing the bot secret (shared with Nextcloud)"; + }; + + claudePath = mkOption { + type = types.path; + default = "${pkgs.claude-code}/bin/claude"; + description = "Path to claude CLI binary"; + }; + + allowedUsers = mkOption { + type = types.listOf types.str; + default = []; + example = [ "harald" "admin" ]; + description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; + }; + + maxTokens = mkOption { + type = types.int; + default = 4096; + description = "Max tokens for Claude response"; + }; + + timeout = mkOption { + type = types.int; + default = 120; + description = "Timeout in seconds for Claude CLI"; + }; + + systemPrompt = mkOption { + type = types.nullOr types.str; + default = null; + example = "Du bist ein hilfreicher Assistent."; + description = "Optional system prompt for Claude"; + }; + }; + + config = mkIf cfg.enable { + systemd.services.nextcloud-claude-bot = { + description = "Nextcloud Talk Claude Bot"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + environment = { + BOT_HOST = cfg.host; + BOT_PORT = toString cfg.port; + NEXTCLOUD_URL = cfg.nextcloudUrl; + CLAUDE_PATH = cfg.claudePath; + ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; + MAX_TOKENS = toString cfg.maxTokens; + TIMEOUT = toString cfg.timeout; + SYSTEM_PROMPT = cfg.systemPrompt or ""; + }; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.python3Packages.uvicorn}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}"; + Restart = "always"; + RestartSec = 5; + + # Security hardening + DynamicUser = true; + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + PrivateDevices = true; + ProtectKernelTunables = true; + ProtectKernelModules = true; + ProtectControlGroups = true; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + MemoryDenyWriteExecute = false; # Python needs this + LockPersonality = true; + + # Bot secret + LoadCredential = "bot-secret:${cfg.botSecretFile}"; + + # Claude CLI needs home for config + StateDirectory = "nextcloud-claude-bot"; + Environment = "HOME=/var/lib/nextcloud-claude-bot"; + }; + }; + + # Nginx reverse proxy config (optional, if you want external access) + # services.nginx.virtualHosts."cloud.example.com".locations."/claude-bot/" = { + # proxyPass = "http://${cfg.host}:${toString cfg.port}/"; + # }; + }; +} From 8404f0998b0c961c9b5094d610ffce7fcde13121 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 15:54:01 +0100 Subject: [PATCH 05/18] refactor(nix): simplify Nextcloud Claude Bot packaging - Replaced `buildPythonApplication` with `python3.withPackages` for a cleaner and more concise implementation. - Adjusted service configuration to use the updated packaging structure, ensuring compatibility with the new setup. - Simplifies the NixOS module by reducing redundancy and improving maintainability. --- .../mx/nextcloud-claude-bot/module.nix | 36 ++++++++----------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix index 2cf2495..7b2cda0 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -4,26 +4,17 @@ with lib; let cfg = config.services.nextcloud-claude-bot; - - botScript = pkgs.python3Packages.buildPythonApplication { - pname = "nextcloud-claude-bot"; - version = "0.1.0"; - format = "other"; - - propagatedBuildInputs = with pkgs.python3Packages; [ - fastapi - uvicorn - httpx - ]; - - dontUnpack = true; - - installPhase = '' - mkdir -p $out/bin - cp ${./bot.py} $out/bin/nextcloud-claude-bot - chmod +x $out/bin/nextcloud-claude-bot - ''; - }; + + pythonEnv = pkgs.python3.withPackages (ps: with ps; [ + fastapi + uvicorn + httpx + ]); + + botModule = pkgs.runCommand "nextcloud-claude-bot-module" {} '' + mkdir -p $out + cp ${./bot.py} $out/nextcloud_claude_bot.py + ''; in { options.services.nextcloud-claude-bot = { @@ -100,11 +91,12 @@ in { MAX_TOKENS = toString cfg.maxTokens; TIMEOUT = toString cfg.timeout; SYSTEM_PROMPT = cfg.systemPrompt or ""; + PYTHONPATH = botModule; }; - + serviceConfig = { Type = "simple"; - ExecStart = "${pkgs.python3Packages.uvicorn}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}"; + ExecStart = "${pythonEnv}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}"; Restart = "always"; RestartSec = 5; From b1370b5fc6dbb04a8a59e8cd17b04f1b3a87420b Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:09:25 +0100 Subject: [PATCH 06/18] feat(bot): enhance group chat handling and mention detection - Updated bot to only respond in group chats when explicitly mentioned. - Added mention detection using regex for "Claude" patterns and cleaned up the message text for processing. - Improved help message to clarify usage in direct messages and group chats. --- .../mx/nextcloud-claude-bot/bot.py | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index d1eef3e..044da45 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -11,6 +11,7 @@ import hmac import json import logging import os +import re import subprocess from datetime import datetime from typing import Optional @@ -209,15 +210,40 @@ async def handle_webhook( conversation_token = conversation.get("token", "") conversation_type = conversation.get("type", 0) - # Only respond to user messages in one-on-one chats (type 1) + # Only respond to user messages if actor_type != "users": log.info(f"Ignoring non-user actor: {actor_type}") return JSONResponse({"status": "ignored", "reason": "not a user message"}) - - if conversation_type != 1: # 1 = one-to-one - log.info(f"Ignoring non-DM conversation type: {conversation_type}") - return JSONResponse({"status": "ignored", "reason": "not a direct message"}) - + + # For group chats (type 2, 3, 4), only respond if bot is mentioned + # Type 1 = one-to-one, Type 2 = group, Type 3 = public, Type 4 = changelog + is_direct_message = conversation_type == 1 + + # Check for bot mention in message (Nextcloud uses @"Bot Name" format) + bot_mentioned = False + clean_message = message_text + + # Look for mention patterns: @Claude or @"Claude" + mention_patterns = [ + r'@"?Claude"?\s*', + r'@"?claude"?\s*', + ] + + for pattern in mention_patterns: + if re.search(pattern, message_text, re.IGNORECASE): + bot_mentioned = True + clean_message = re.sub(pattern, '', message_text, flags=re.IGNORECASE).strip() + break + + # In group chats, only respond if mentioned + if not is_direct_message and not bot_mentioned: + log.info(f"Ignoring message in group chat without mention") + return JSONResponse({"status": "ignored", "reason": "not mentioned in group chat"}) + + # Use clean message (without mention) for processing + if bot_mentioned: + message_text = clean_message + # Check allowed users if ALLOWED_USERS and actor_id not in ALLOWED_USERS: log.warning(f"User {actor_id} not in allowed list") @@ -253,6 +279,10 @@ async def handle_webhook( Schreib mir einfach eine Nachricht und ich antworte dir. +**Nutzung:** +• In Direktnachrichten: Einfach schreiben +• In Gruppenchats: @Claude gefolgt von deiner Frage + **Befehle:** • `/clear` oder `/reset` – Konversation zurücksetzen • `/help` oder `/hilfe` – Diese Hilfe anzeigen From d5967cf392bbde851ef1184fb4a5d1830c565129 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:14:21 +0100 Subject: [PATCH 07/18] feat(nix): improve Nextcloud Claude Bot security and user setup - Set `User` and `Group` for the bot service to enhance security and isolation. - Added system user and group for `claude-bot` with defined home directory. - Modified secrets ownership to align with the new bot user. --- .../mx/nextcloud-claude-bot/default.nix | 1 + .../mx/nextcloud-claude-bot/module.nix | 20 ++++++++++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix index 7d61fe7..234efd7 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -12,6 +12,7 @@ sops.secrets."nextcloud-claude-bot/secret" = { sopsFile = ../../../../.secrets/hetzner/nextcloud-claude-bot.yaml; restartUnits = [ "nextcloud-claude-bot.service" ]; + owner = "claude-bot"; }; # Nginx location for Nextcloud to send webhooks to the bot diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix index 7b2cda0..eb1a0a7 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -83,6 +83,7 @@ in { after = [ "network.target" ]; environment = { + HOME = "/var/lib/nextcloud-claude-bot"; BOT_HOST = cfg.host; BOT_PORT = toString cfg.port; NEXTCLOUD_URL = cfg.nextcloudUrl; @@ -99,9 +100,11 @@ in { ExecStart = "${pythonEnv}/bin/uvicorn nextcloud_claude_bot:app --host ${cfg.host} --port ${toString cfg.port}"; Restart = "always"; RestartSec = 5; - + + User = "claude-bot"; + Group = "claude-bot"; + # Security hardening - DynamicUser = true; NoNewPrivileges = true; ProtectSystem = "strict"; ProtectHome = "read-only"; @@ -115,15 +118,22 @@ in { RestrictSUIDSGID = true; MemoryDenyWriteExecute = false; # Python needs this LockPersonality = true; - + # Bot secret LoadCredential = "bot-secret:${cfg.botSecretFile}"; - + # Claude CLI needs home for config StateDirectory = "nextcloud-claude-bot"; - Environment = "HOME=/var/lib/nextcloud-claude-bot"; }; }; + + users.users.claude-bot = { + isSystemUser = true; + group = "claude-bot"; + home = "/var/lib/nextcloud-claude-bot"; + }; + + users.groups.claude-bot = {}; # Nginx reverse proxy config (optional, if you want external access) # services.nginx.virtualHosts."cloud.example.com".locations."/claude-bot/" = { From 33937ab115374f2d1e7f2c7447665515cff856c3 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:23:14 +0100 Subject: [PATCH 08/18] feat(bot): add signature verification logging - Added info-level logging to provide details about signature verification, including secret length and partial hashes for expected and received signatures. - Helps in debugging signature mismatches without exposing full sensitive data. --- systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 044da45..54d9622 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -61,17 +61,19 @@ def verify_signature(body: bytes, signature: str) -> bool: if not BOT_SECRET: log.warning("No bot secret configured, skipping signature verification") return True - + expected = hmac.new( BOT_SECRET.encode(), body, hashlib.sha256 ).hexdigest() - + # Nextcloud sends: sha256= if signature.startswith("sha256="): signature = signature[7:] - + + log.info(f"Signature verification: secret_len={len(BOT_SECRET)}, expected={expected[:16]}..., received={signature[:16]}...") + return hmac.compare_digest(expected, signature) From 77cf4a0aedfaf9e624f11c8fda7d6a708eafb7ed Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:26:37 +0100 Subject: [PATCH 09/18] feat(bot): support random token in signature verification - Enhanced signature verification by adding support for a `random` token included in webhook headers. - Introduced logging to display signature variants for debugging purposes. - Improved webhook handling to process new `X-Nextcloud-Talk-Random` header. --- .../mx/nextcloud-claude-bot/bot.py | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 54d9622..5d051a4 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -56,25 +56,37 @@ conversations: dict[str, list[tuple[datetime, str, str]]] = {} MAX_HISTORY = 10 # Keep last N exchanges per user -def verify_signature(body: bytes, signature: str) -> bool: +def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool: """Verify Nextcloud webhook signature.""" if not BOT_SECRET: log.warning("No bot secret configured, skipping signature verification") return True - expected = hmac.new( - BOT_SECRET.encode(), - body, - hashlib.sha256 - ).hexdigest() - # Nextcloud sends: sha256= if signature.startswith("sha256="): signature = signature[7:] - log.info(f"Signature verification: secret_len={len(BOT_SECRET)}, expected={expected[:16]}..., received={signature[:16]}...") + # Try different signature computation methods + # Method 1: Just body + expected1 = hmac.new(BOT_SECRET.encode(), body, hashlib.sha256).hexdigest() - return hmac.compare_digest(expected, signature) + # Method 2: random + body (if random header present) + if random: + expected2 = hmac.new(BOT_SECRET.encode(), (random.encode() + body), hashlib.sha256).hexdigest() + else: + expected2 = None + + log.info(f"Signature verification: received={signature[:16]}...") + log.info(f" Method 1 (body only): {expected1[:16]}...") + if expected2: + log.info(f" Method 2 (random+body): {expected2[:16]}...") + + if hmac.compare_digest(expected1, signature): + return True + if expected2 and hmac.compare_digest(expected2, signature): + return True + + return False def build_prompt(user_id: str, message: str) -> str: @@ -182,12 +194,16 @@ async def send_reply(conversation_token: str, message: str, reply_to: int = None async def handle_webhook( request: Request, x_nextcloud_talk_signature: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Signature"), + x_nextcloud_talk_random: Optional[str] = Header(None, alias="X-Nextcloud-Talk-Random"), ): """Handle incoming webhook from Nextcloud Talk.""" body = await request.body() - + + log.info(f"Headers: signature={x_nextcloud_talk_signature}, random={x_nextcloud_talk_random}") + log.info(f"Body (first 200): {body[:200]}") + # Verify signature - if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature): + if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random): log.warning("Invalid webhook signature") raise HTTPException(status_code=401, detail="Invalid signature") From 1f61a0d1ec9204836d7cb1448516bde917fdd178 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:29:10 +0100 Subject: [PATCH 10/18] feat(bot): refactor webhook parsing for better structure - Updated webhook parsing to align with the latest Nextcloud Talk Bot format. - Improved handling of actor, message, and conversation data for clarity and flexibility. - Added robust JSON decoding with fallback for content extraction. --- .../mx/nextcloud-claude-bot/bot.py | 46 +++++++++++-------- 1 file changed, 28 insertions(+), 18 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 5d051a4..25cb550 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -213,29 +213,39 @@ async def handle_webhook( raise HTTPException(status_code=400, detail="Invalid JSON") log.info(f"Received webhook: {json.dumps(data, indent=2)[:500]}") - - # Extract message info - # Nextcloud Talk Bot API structure + + # Extract message info - Nextcloud Talk Bot webhook format actor = data.get("actor", {}) - actor_id = actor.get("id", "") actor_type = actor.get("type", "") - - message_data = data.get("message", {}) - message_text = message_data.get("message", "") - message_id = message_data.get("id") - - conversation = data.get("conversation", {}) - conversation_token = conversation.get("token", "") - conversation_type = conversation.get("type", 0) - - # Only respond to user messages - if actor_type != "users": + actor_id_full = actor.get("id", "") # e.g., "users/harald" + + # Extract username from "users/username" format + if "/" in actor_id_full: + actor_id = actor_id_full.split("/", 1)[1] + else: + actor_id = actor_id_full + + # Message is in object.content as JSON string + obj = data.get("object", {}) + message_id = obj.get("id") + content_str = obj.get("content", "{}") + try: + content = json.loads(content_str) + message_text = content.get("message", "") + except json.JSONDecodeError: + message_text = content_str + + # Conversation info is in target + target = data.get("target", {}) + conversation_token = target.get("id", "") + + # Only respond to user/person messages + if actor_type not in ("users", "Person"): log.info(f"Ignoring non-user actor: {actor_type}") return JSONResponse({"status": "ignored", "reason": "not a user message"}) - # For group chats (type 2, 3, 4), only respond if bot is mentioned - # Type 1 = one-to-one, Type 2 = group, Type 3 = public, Type 4 = changelog - is_direct_message = conversation_type == 1 + # For now, treat all conversations the same (respond to mentions) + is_direct_message = False # We can't easily determine this from the webhook # Check for bot mention in message (Nextcloud uses @"Bot Name" format) bot_mentioned = False From 538d7623beff19c5cc1b4b6670c2b3ef0ec5fd82 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 16:30:47 +0100 Subject: [PATCH 11/18] refactor(bot): remove unused max-tokens argument handling - Simplified the `call_claude` function by removing the unused `MAX_TOKENS` argument handling. - Ensures cleaner and more maintainable command construction. --- systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 25cb550..da1eb17 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -112,10 +112,7 @@ def build_prompt(user_id: str, message: str) -> str: async def call_claude(prompt: str) -> str: """Call Claude CLI and return response.""" cmd = [CLAUDE_PATH, "--print"] - - if MAX_TOKENS: - cmd.extend(["--max-tokens", str(MAX_TOKENS)]) - + log.info(f"Calling Claude: {' '.join(cmd)}") try: From b35373b0ecbd21e0621521eb23dde19ed951a5e1 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 17:00:50 +0100 Subject: [PATCH 12/18] feat(bot): replace maxTokens with contextMessages option - Switched `maxTokens` to `contextMessages` to set chat history length instead of token limit. - Updated environment variables, NixOS module, and prompt building logic for consistency. - Removed in-memory conversation history, now fetching from Nextcloud for better scalability. --- flake.lock | 54 +++--- .../mx/nextcloud-claude-bot/bot.py | 165 ++++++++++-------- .../mx/nextcloud-claude-bot/default.nix | 2 +- .../mx/nextcloud-claude-bot/module.nix | 10 +- 4 files changed, 122 insertions(+), 109 deletions(-) diff --git a/flake.lock b/flake.lock index 644d00b..703e220 100644 --- a/flake.lock +++ b/flake.lock @@ -454,11 +454,11 @@ "homebrew-cask": { "flake": false, "locked": { - "lastModified": 1769770011, - "narHash": "sha256-Z+qyxP9dQVk1xBJKJvrvKg2/8SGnYEUArs5vJuhc4ZE=", + "lastModified": 1770127519, + "narHash": "sha256-wIpVsLhx1gaB2JYfpVipt9ZLAReKFO0kmVIOhieHfqs=", "owner": "homebrew", "repo": "homebrew-cask", - "rev": "4b98892b8c059ebc23e6516c917f6b01741a2969", + "rev": "76e6c1bda247fe48dc30683203cce2b28b5d6eee", "type": "github" }, "original": { @@ -470,11 +470,11 @@ "homebrew-core": { "flake": false, "locked": { - "lastModified": 1769769028, - "narHash": "sha256-9RhJZXZO/PJ7A+917XRROv8xPtzHlPthtAMhunUAfM0=", + "lastModified": 1770130704, + "narHash": "sha256-95Jwssj3WbBwHO4nNB5uVIgIym/fuSDBb5vs6eKdgp0=", "owner": "homebrew", "repo": "homebrew-core", - "rev": "95b2944276a57b176eadc835575c3b591f88999f", + "rev": "5369d45006ea107dead79ef8ef4b29b7c972f276", "type": "github" }, "original": { @@ -515,11 +515,11 @@ }, "mnw": { "locked": { - "lastModified": 1768701608, - "narHash": "sha256-kSvWF3Xt2HW9hmV5V7i8PqeWJIBUKmuKoHhOgj3Znzs=", + "lastModified": 1769981889, + "narHash": "sha256-ndI7AxL/6auelkLHngdUGVImBiHkG8w2N2fOTKZKn4k=", "owner": "Gerg-L", "repo": "mnw", - "rev": "20d63a8a1ae400557c770052a46a9840e768926b", + "rev": "332fed8f43b77149c582f1782683d6aeee1f07cf", "type": "github" }, "original": { @@ -562,11 +562,11 @@ "systems": "systems_2" }, "locked": { - "lastModified": 1769716128, - "narHash": "sha256-CAsiyTNjI0WmtJstw3kGyL7Q1jPCn7AsO6Ms47G+x3w=", + "lastModified": 1770130359, + "narHash": "sha256-IfoT9oaeIE6XjXprMORG2qZFzGGZ0v6wJcOlQRdlpvY=", "owner": "NotAShelf", "repo": "nvf", - "rev": "866b983c4047b87bcdca6ab3673ed7bd602f0251", + "rev": "92854bd0eaaa06914afba345741c372439b8e335", "type": "github" }, "original": { @@ -642,11 +642,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1769598131, - "narHash": "sha256-e7VO/kGLgRMbWtpBqdWl0uFg8Y2XWFMdz0uUJvlML8o=", + "lastModified": 1770056022, + "narHash": "sha256-yvCz+Qmci1bVucXEyac3TdoSPMtjqVJmVy5wro6j/70=", "owner": "nixos", "repo": "nixpkgs", - "rev": "fa83fd837f3098e3e678e6cf017b2b36102c7211", + "rev": "d04d8548aed39902419f14a8537006426dc1e4fa", "type": "github" }, "original": { @@ -748,11 +748,11 @@ ] }, "locked": { - "lastModified": 1769742225, - "narHash": "sha256-roSD/OJ3x9nF+Dxr+/bLClX3U8FP9EkCQIFpzxKjSUM=", + "lastModified": 1770088046, + "narHash": "sha256-4hfYDnUTvL1qSSZEA4CEThxfz+KlwSFQ30Z9jgDguO0=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "bcdd8d37594f0e201639f55889c01c827baf5c75", + "rev": "71f9daa4e05e49c434d08627e755495ae222bc34", "type": "github" }, "original": { @@ -835,11 +835,11 @@ ] }, "locked": { - "lastModified": 1769469829, - "narHash": "sha256-wFcr32ZqspCxk4+FvIxIL0AZktRs6DuF8oOsLt59YBU=", + "lastModified": 1770110318, + "narHash": "sha256-NUVGVtYBTC96WhPh4Y3SVM7vf0o1z5W4uqRBn9v1pfo=", "owner": "Mic92", "repo": "sops-nix", - "rev": "c5eebd4eb2e3372fe12a8d70a248a6ee9dd02eff", + "rev": "f990b0a334e96d3ef9ca09d4bd92778b42fd84f9", "type": "github" }, "original": { @@ -857,11 +857,11 @@ "rust-overlay": "rust-overlay_3" }, "locked": { - "lastModified": 1768997903, - "narHash": "sha256-UpBfh3I4PhykVHqV74rrxufF3X1Z8z8sx/lFgMFfIP8=", + "lastModified": 1769829418, + "narHash": "sha256-ALZKPUa0eHP6HwETAJ9PsAnYQjNLF6eEpo1W2fmYqwA=", "owner": "haraldh", "repo": "ssh-tresor", - "rev": "dd45aed45f8d9b8729b7698ef43e7cc32fab97b6", + "rev": "2e1bfa29bd5ad5a60c3e0effd69851a67d455781", "type": "github" }, "original": { @@ -932,11 +932,11 @@ }, "unstable": { "locked": { - "lastModified": 1769461804, - "narHash": "sha256-msG8SU5WsBUfVVa/9RPLaymvi5bI8edTavbIq3vRlhI=", + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "bfc1b8a4574108ceef22f02bafcf6611380c100d", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", "type": "github" }, "original": { diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index da1eb17..dd1049e 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -12,7 +12,7 @@ import json import logging import os import re -import subprocess +import secrets from datetime import datetime from typing import Optional @@ -24,7 +24,6 @@ from fastapi.responses import JSONResponse NEXTCLOUD_URL = os.environ.get("NEXTCLOUD_URL", "").rstrip("/") CLAUDE_PATH = os.environ.get("CLAUDE_PATH", "claude") ALLOWED_USERS = [u.strip() for u in os.environ.get("ALLOWED_USERS", "").split(",") if u.strip()] -MAX_TOKENS = int(os.environ.get("MAX_TOKENS", "4096")) TIMEOUT = int(os.environ.get("TIMEOUT", "120")) SYSTEM_PROMPT = os.environ.get("SYSTEM_PROMPT", "") @@ -50,10 +49,54 @@ log = logging.getLogger(__name__) app = FastAPI(title="Nextcloud Claude Bot") -# Simple in-memory conversation history (per user) -# Format: {user_id: [(timestamp, role, message), ...]} -conversations: dict[str, list[tuple[datetime, str, str]]] = {} -MAX_HISTORY = 10 # Keep last N exchanges per user +# Number of recent messages to fetch for context +CONTEXT_MESSAGES = int(os.environ.get("CONTEXT_MESSAGES", "6")) + + +def generate_bot_auth_headers(body: str = "") -> dict: + """Generate authentication headers for bot requests to Nextcloud.""" + random = secrets.token_hex(32) + digest = hmac.new( + BOT_SECRET.encode(), + (random + body).encode(), + hashlib.sha256 + ).hexdigest() + return { + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": digest, + "OCS-APIRequest": "true", + } + + +async def fetch_chat_history(conversation_token: str, limit: int = CONTEXT_MESSAGES) -> list[dict]: + """Fetch recent messages from Nextcloud Talk conversation.""" + if not NEXTCLOUD_URL: + log.warning("NEXTCLOUD_URL not configured, cannot fetch history") + return [] + + url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/chat/{conversation_token}" + params = { + "limit": limit, + "lookIntoFuture": 0, + } + + headers = generate_bot_auth_headers() + headers["Accept"] = "application/json" + + async with httpx.AsyncClient() as client: + try: + resp = await client.get(url, params=params, headers=headers) + if resp.status_code == 200: + data = resp.json() + messages = data.get("ocs", {}).get("data", []) + # Messages come newest first, reverse for chronological order + return list(reversed(messages)) + else: + log.warning(f"Failed to fetch chat history: {resp.status_code} {resp.text[:200]}") + return [] + except Exception as e: + log.exception("Error fetching chat history") + return [] def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool: @@ -76,10 +119,6 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) else: expected2 = None - log.info(f"Signature verification: received={signature[:16]}...") - log.info(f" Method 1 (body only): {expected1[:16]}...") - if expected2: - log.info(f" Method 2 (random+body): {expected2[:16]}...") if hmac.compare_digest(expected1, signature): return True @@ -89,23 +128,36 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) return False -def build_prompt(user_id: str, message: str) -> str: - """Build prompt with conversation history.""" - history = conversations.get(user_id, []) - +async def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: + """Build prompt with conversation history from Nextcloud.""" parts = [] - + if SYSTEM_PROMPT: parts.append(f"System: {SYSTEM_PROMPT}\n") - - # Add recent history - for ts, role, msg in history[-MAX_HISTORY:]: - prefix = "User" if role == "user" else "Assistant" - parts.append(f"{prefix}: {msg}") - + + # Fetch recent history from Nextcloud + history = await fetch_chat_history(conversation_token) + + # Add recent history (excluding the current message which triggered this) + for msg in history: + actor_type = msg.get("actorType", "") + actor_id = msg.get("actorId", "") + message_text = msg.get("message", "") + msg_type = msg.get("messageType", "") + + # Skip system messages + if msg_type == "system": + continue + + # Determine if this is a user or the bot + if actor_type == "bots": + parts.append(f"Assistant: {message_text}") + elif actor_type == "users": + parts.append(f"User ({actor_id}): {message_text}") + # Add current message - parts.append(f"User: {message}") - + parts.append(f"User ({current_user}): {current_message}") + return "\n\n".join(parts) @@ -147,35 +199,21 @@ async def send_reply(conversation_token: str, message: str, reply_to: int = None if not NEXTCLOUD_URL: log.error("NEXTCLOUD_URL not configured") return - + url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/bot/{conversation_token}/message" - - headers = { - "OCS-APIRequest": "true", - "Content-Type": "application/json", - } - - # Bot authentication - if BOT_SECRET: - # Generate random string for request - import secrets - random = secrets.token_hex(32) - digest = hmac.new( - BOT_SECRET.encode(), - (random + message).encode(), - hashlib.sha256 - ).hexdigest() - headers["X-Nextcloud-Talk-Bot-Random"] = random - headers["X-Nextcloud-Talk-Bot-Signature"] = digest - + + # Bot authentication - signature is over the message being sent + headers = generate_bot_auth_headers(message) + headers["Content-Type"] = "application/json" + payload = { "message": message, "referenceId": hashlib.sha256(f"{conversation_token}-{datetime.now().isoformat()}".encode()).hexdigest()[:32], } - + if reply_to: payload["replyTo"] = reply_to - + async with httpx.AsyncClient() as client: try: resp = await client.post(url, json=payload, headers=headers) @@ -196,9 +234,6 @@ async def handle_webhook( """Handle incoming webhook from Nextcloud Talk.""" body = await request.body() - log.info(f"Headers: signature={x_nextcloud_talk_signature}, random={x_nextcloud_talk_random}") - log.info(f"Body (first 200): {body[:200]}") - # Verify signature if x_nextcloud_talk_signature and not verify_signature(body, x_nextcloud_talk_signature, x_nextcloud_talk_random): log.warning("Invalid webhook signature") @@ -284,21 +319,6 @@ async def handle_webhook( log.info(f"Processing message from {actor_id}: {message_text[:100]}") - # Store user message in history - if actor_id not in conversations: - conversations[actor_id] = [] - conversations[actor_id].append((datetime.now(), "user", message_text)) - - # Handle special commands - if message_text.strip().lower() in ("/clear", "/reset", "/neu"): - conversations[actor_id] = [] - await send_reply( - conversation_token, - "🧹 Konversation zurückgesetzt.", - reply_to=message_id - ) - return JSONResponse({"status": "ok", "action": "cleared"}) - if message_text.strip().lower() in ("/help", "/hilfe"): help_text = """🤖 **Claude Bot Hilfe** @@ -309,24 +329,16 @@ Schreib mir einfach eine Nachricht und ich antworte dir. • In Gruppenchats: @Claude gefolgt von deiner Frage **Befehle:** -• `/clear` oder `/reset` – Konversation zurücksetzen • `/help` oder `/hilfe` – Diese Hilfe anzeigen -Der Bot merkt sich die letzten Nachrichten für Kontext.""" +Der Bot nutzt die letzten Nachrichten aus dem Chat als Kontext.""" await send_reply(conversation_token, help_text, reply_to=message_id) return JSONResponse({"status": "ok", "action": "help"}) - - # Build prompt and call Claude - prompt = build_prompt(actor_id, message_text) + + # Build prompt with chat history and call Claude + prompt = await build_prompt(conversation_token, message_text, actor_id) response = await call_claude(prompt) - - # Store assistant response in history - conversations[actor_id].append((datetime.now(), "assistant", response)) - - # Trim history - if len(conversations[actor_id]) > MAX_HISTORY * 2: - conversations[actor_id] = conversations[actor_id][-MAX_HISTORY * 2:] - + # Send response await send_reply(conversation_token, response, reply_to=message_id) @@ -341,6 +353,7 @@ async def health(): "nextcloud_url": NEXTCLOUD_URL, "claude_path": CLAUDE_PATH, "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", + "context_messages": CONTEXT_MESSAGES, } diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix index 234efd7..c74f2ed 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -6,7 +6,7 @@ enable = true; nextcloudUrl = "https://nc.hoyer.xyz"; botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; - allowedUsers = [ "harald" ]; + allowedUsers = []; # Allow all registered users }; sops.secrets."nextcloud-claude-bot/secret" = { diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix index eb1a0a7..6edcd71 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/module.nix @@ -56,12 +56,12 @@ in { description = "Nextcloud usernames allowed to talk to the bot (empty = all)"; }; - maxTokens = mkOption { + contextMessages = mkOption { type = types.int; - default = 4096; - description = "Max tokens for Claude response"; + default = 6; + description = "Number of recent messages to fetch from chat for context"; }; - + timeout = mkOption { type = types.int; default = 120; @@ -89,7 +89,7 @@ in { NEXTCLOUD_URL = cfg.nextcloudUrl; CLAUDE_PATH = cfg.claudePath; ALLOWED_USERS = concatStringsSep "," cfg.allowedUsers; - MAX_TOKENS = toString cfg.maxTokens; + CONTEXT_MESSAGES = toString cfg.contextMessages; TIMEOUT = toString cfg.timeout; SYSTEM_PROMPT = cfg.systemPrompt or ""; PYTHONPATH = botModule; From 93429339874fb04aad10522f6190bb10244c0b02 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 17:26:46 +0100 Subject: [PATCH 13/18] feat(bot): switch to in-memory conversation history - Replaced Nextcloud chat history fetching with in-memory storage for conversation history. - Added limits to history length based on an environment variable (`CONTEXT_MESSAGES`). - Simplified prompt-building logic by removing async history fetching. --- .../mx/nextcloud-claude-bot/bot.py | 80 ++++++------------- 1 file changed, 24 insertions(+), 56 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index dd1049e..2f1f162 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -49,8 +49,10 @@ log = logging.getLogger(__name__) app = FastAPI(title="Nextcloud Claude Bot") -# Number of recent messages to fetch for context -CONTEXT_MESSAGES = int(os.environ.get("CONTEXT_MESSAGES", "6")) +# In-memory conversation history per conversation token +# Format: {token: [(user, message), ...]} +conversations: dict[str, list[tuple[str, str]]] = {} +MAX_HISTORY = int(os.environ.get("CONTEXT_MESSAGES", "6")) def generate_bot_auth_headers(body: str = "") -> dict: @@ -68,35 +70,6 @@ def generate_bot_auth_headers(body: str = "") -> dict: } -async def fetch_chat_history(conversation_token: str, limit: int = CONTEXT_MESSAGES) -> list[dict]: - """Fetch recent messages from Nextcloud Talk conversation.""" - if not NEXTCLOUD_URL: - log.warning("NEXTCLOUD_URL not configured, cannot fetch history") - return [] - - url = f"{NEXTCLOUD_URL}/ocs/v2.php/apps/spreed/api/v1/chat/{conversation_token}" - params = { - "limit": limit, - "lookIntoFuture": 0, - } - - headers = generate_bot_auth_headers() - headers["Accept"] = "application/json" - - async with httpx.AsyncClient() as client: - try: - resp = await client.get(url, params=params, headers=headers) - if resp.status_code == 200: - data = resp.json() - messages = data.get("ocs", {}).get("data", []) - # Messages come newest first, reverse for chronological order - return list(reversed(messages)) - else: - log.warning(f"Failed to fetch chat history: {resp.status_code} {resp.text[:200]}") - return [] - except Exception as e: - log.exception("Error fetching chat history") - return [] def verify_signature(body: bytes, signature: str, random: Optional[str] = None) -> bool: @@ -128,32 +101,17 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) return False -async def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: - """Build prompt with conversation history from Nextcloud.""" +def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: + """Build prompt with in-memory conversation history.""" parts = [] if SYSTEM_PROMPT: parts.append(f"System: {SYSTEM_PROMPT}\n") - # Fetch recent history from Nextcloud - history = await fetch_chat_history(conversation_token) - - # Add recent history (excluding the current message which triggered this) - for msg in history: - actor_type = msg.get("actorType", "") - actor_id = msg.get("actorId", "") - message_text = msg.get("message", "") - msg_type = msg.get("messageType", "") - - # Skip system messages - if msg_type == "system": - continue - - # Determine if this is a user or the bot - if actor_type == "bots": - parts.append(f"Assistant: {message_text}") - elif actor_type == "users": - parts.append(f"User ({actor_id}): {message_text}") + # Add recent history from memory + history = conversations.get(conversation_token, []) + for role, msg in history[-MAX_HISTORY:]: + parts.append(f"{role}: {msg}") # Add current message parts.append(f"User ({current_user}): {current_message}") @@ -331,17 +289,27 @@ Schreib mir einfach eine Nachricht und ich antworte dir. **Befehle:** • `/help` oder `/hilfe` – Diese Hilfe anzeigen -Der Bot nutzt die letzten Nachrichten aus dem Chat als Kontext.""" +Der Bot merkt sich die letzten Nachrichten pro Raum (bis zum Neustart).""" await send_reply(conversation_token, help_text, reply_to=message_id) return JSONResponse({"status": "ok", "action": "help"}) # Build prompt with chat history and call Claude - prompt = await build_prompt(conversation_token, message_text, actor_id) + prompt = build_prompt(conversation_token, message_text, actor_id) response = await call_claude(prompt) + # Store in history + if conversation_token not in conversations: + conversations[conversation_token] = [] + conversations[conversation_token].append((f"User ({actor_id})", message_text)) + conversations[conversation_token].append(("Assistant", response)) + + # Trim history + if len(conversations[conversation_token]) > MAX_HISTORY * 2: + conversations[conversation_token] = conversations[conversation_token][-MAX_HISTORY * 2:] + # Send response await send_reply(conversation_token, response, reply_to=message_id) - + return JSONResponse({"status": "ok"}) @@ -353,7 +321,7 @@ async def health(): "nextcloud_url": NEXTCLOUD_URL, "claude_path": CLAUDE_PATH, "allowed_users": ALLOWED_USERS if ALLOWED_USERS else "all", - "context_messages": CONTEXT_MESSAGES, + "max_history": MAX_HISTORY, } From f25aab2441282479a2f47a9b2c4e3c4e4ccd7e18 Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Tue, 3 Feb 2026 17:39:31 +0100 Subject: [PATCH 14/18] feat(bot): improve prompt-building and help command handling - Added a default system prompt and adjusted the structure to use XML for clarity. - Improved help command handling by simplifying triggers and updating responses. - Enhanced NixOS configuration with support for optional custom instructions. --- .../mx/nextcloud-claude-bot/bot.py | 41 ++++++++++++++----- .../mx/nextcloud-claude-bot/default.nix | 2 + 2 files changed, 33 insertions(+), 10 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 2f1f162..6962f5c 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -101,22 +101,44 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) return False +DEFAULT_SYSTEM_PROMPT = """Du bist Claude, ein KI-Assistent im Nextcloud Talk Chat. +Deine Antworten werden direkt in den Chatraum gepostet. +Halte deine Antworten kurz und prägnant, da es ein Chat ist. +Nutze Markdown für Formatierung wenn sinnvoll. + +Du erhältst: +- : Die letzten Nachrichten im Chatraum (User und deine Antworten) +- : Die aktuelle Nachricht, auf die du antworten sollst""" + + def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: - """Build prompt with in-memory conversation history.""" + """Build prompt with in-memory conversation history using XML structure.""" parts = [] + # Add system prompt (hardcoded + optional custom) + parts.append("") + parts.append(DEFAULT_SYSTEM_PROMPT) if SYSTEM_PROMPT: - parts.append(f"System: {SYSTEM_PROMPT}\n") + parts.append("") + parts.append(SYSTEM_PROMPT.strip()) + parts.append("") + parts.append("") - # Add recent history from memory + # Add chat history if available history = conversations.get(conversation_token, []) - for role, msg in history[-MAX_HISTORY:]: - parts.append(f"{role}: {msg}") + if history: + parts.append("") + for role, msg in history[-MAX_HISTORY:]: + parts.append(f"{role}: {msg}") + parts.append("") + parts.append("") # Add current message - parts.append(f"User ({current_user}): {current_message}") + parts.append(f"") + parts.append(current_message) + parts.append("") - return "\n\n".join(parts) + return "\n".join(parts) async def call_claude(prompt: str) -> str: @@ -277,17 +299,16 @@ async def handle_webhook( log.info(f"Processing message from {actor_id}: {message_text[:100]}") - if message_text.strip().lower() in ("/help", "/hilfe"): + if message_text.strip().lower() in ("hilfe", "help", "?"): help_text = """🤖 **Claude Bot Hilfe** Schreib mir einfach eine Nachricht und ich antworte dir. **Nutzung:** -• In Direktnachrichten: Einfach schreiben • In Gruppenchats: @Claude gefolgt von deiner Frage **Befehle:** -• `/help` oder `/hilfe` – Diese Hilfe anzeigen +• `hilfe` oder `?` – Diese Hilfe anzeigen Der Bot merkt sich die letzten Nachrichten pro Raum (bis zum Neustart).""" await send_reply(conversation_token, help_text, reply_to=message_id) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix index c74f2ed..9bda0c7 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/default.nix @@ -7,6 +7,8 @@ nextcloudUrl = "https://nc.hoyer.xyz"; botSecretFile = config.sops.secrets."nextcloud-claude-bot/secret".path; allowedUsers = []; # Allow all registered users + # Optional extra instructions (base prompt is hardcoded in bot.py) + # systemPrompt = "Additional custom instructions here"; }; sops.secrets."nextcloud-claude-bot/secret" = { From 9b42e808d366975c89d33e9b6216117b2b831b7d Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Wed, 4 Feb 2026 09:11:08 +0100 Subject: [PATCH 15/18] feat(bot): refactor system prompt and enhance CLI command - Replaced `DEFAULT_SYSTEM_PROMPT` with `BOT_SYSTEM_PROMPT` for clarity and modularity. - Introduced a `build_system_prompt` function to dynamically compose prompts. - Enhanced `call_claude` CLI with new tool options and appendable prompts. --- .../mx/nextcloud-claude-bot/bot.py | 32 +++++++++++-------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py index 6962f5c..3788d2a 100644 --- a/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py +++ b/systems/x86_64-linux/mx/nextcloud-claude-bot/bot.py @@ -101,7 +101,8 @@ def verify_signature(body: bytes, signature: str, random: Optional[str] = None) return False -DEFAULT_SYSTEM_PROMPT = """Du bist Claude, ein KI-Assistent im Nextcloud Talk Chat. +BOT_SYSTEM_PROMPT = """\ +Du bist ein KI-Assistent im Nextcloud Talk Chat. Deine Antworten werden direkt in den Chatraum gepostet. Halte deine Antworten kurz und prägnant, da es ein Chat ist. Nutze Markdown für Formatierung wenn sinnvoll. @@ -111,18 +112,16 @@ Du erhältst: - : Die aktuelle Nachricht, auf die du antworten sollst""" -def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: - """Build prompt with in-memory conversation history using XML structure.""" - parts = [] - - # Add system prompt (hardcoded + optional custom) - parts.append("") - parts.append(DEFAULT_SYSTEM_PROMPT) +def build_system_prompt() -> str: + """Build the full system prompt from hardcoded + optional custom parts.""" if SYSTEM_PROMPT: - parts.append("") - parts.append(SYSTEM_PROMPT.strip()) - parts.append("") - parts.append("") + return f"{BOT_SYSTEM_PROMPT}\n\n{SYSTEM_PROMPT.strip()}" + return BOT_SYSTEM_PROMPT + + +def build_prompt(conversation_token: str, current_message: str, current_user: str) -> str: + """Build user prompt with in-memory conversation history using XML structure.""" + parts = [] # Add chat history if available history = conversations.get(conversation_token, []) @@ -143,9 +142,14 @@ def build_prompt(conversation_token: str, current_message: str, current_user: st async def call_claude(prompt: str) -> str: """Call Claude CLI and return response.""" - cmd = [CLAUDE_PATH, "--print"] + cmd = [ + CLAUDE_PATH, "--print", + "--tools", "WebSearch,WebFetch", + "--allowedTools", "WebSearch,WebFetch", + "--append-system-prompt", build_system_prompt(), + ] - log.info(f"Calling Claude: {' '.join(cmd)}") + log.info(f"Calling Claude: {cmd[0]} --print --append-system-prompt ...") try: proc = await asyncio.create_subprocess_exec( From 4bc62866a82e38bc7c02f80a17fcee9fd28bba6e Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 6 Feb 2026 08:57:49 +0100 Subject: [PATCH 16/18] feat(nix): update `claude-code` package and dependencies - Upgraded `claude-code` to version 2.1.34 and updated associated npm dependencies and hash values. - Refactored `update.sh` to use `nix shell` instead of `nix-shell` for improved compatibility. - Added musl-based `sharp` dependencies and adjusted sandbox requirements in `package.nix`. --- .../unstable/claude-code/package-lock.json | 82 ++++++++++++++++++- overlays/unstable/claude-code/package.nix | 40 +++++++-- overlays/unstable/claude-code/update.sh | 11 ++- overlays/unstable/default.nix | 4 +- 4 files changed, 124 insertions(+), 13 deletions(-) diff --git a/overlays/unstable/claude-code/package-lock.json b/overlays/unstable/claude-code/package-lock.json index 313206e..41383c8 100644 --- a/overlays/unstable/claude-code/package-lock.json +++ b/overlays/unstable/claude-code/package-lock.json @@ -1,12 +1,12 @@ { "name": "@anthropic-ai/claude-code", - "version": "2.0.50", + "version": "2.1.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@anthropic-ai/claude-code", - "version": "2.0.50", + "version": "2.1.34", "license": "SEE LICENSE IN README.md", "bin": { "claude": "cli.js" @@ -20,6 +20,8 @@ "@img/sharp-linux-arm": "^0.33.5", "@img/sharp-linux-arm64": "^0.33.5", "@img/sharp-linux-x64": "^0.33.5", + "@img/sharp-linuxmusl-arm64": "^0.33.5", + "@img/sharp-linuxmusl-x64": "^0.33.5", "@img/sharp-win32-x64": "^0.33.5" } }, @@ -147,6 +149,38 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", + "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", + "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-linux-arm": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", @@ -213,6 +247,50 @@ "@img/sharp-libvips-linux-x64": "1.0.4" } }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", + "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.33.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", + "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + } + }, "node_modules/@img/sharp-win32-x64": { "version": "0.33.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", diff --git a/overlays/unstable/claude-code/package.nix b/overlays/unstable/claude-code/package.nix index 6b77fcc..d5403e0 100644 --- a/overlays/unstable/claude-code/package.nix +++ b/overlays/unstable/claude-code/package.nix @@ -1,23 +1,37 @@ +# NOTE: Use the following command to update the package +# ```sh +# nix-shell maintainers/scripts/update.nix --argstr commit true --arg predicate '(path: pkg: builtins.elem path [["claude-code"] ["claude-code-bin"] ["vscode-extensions" "anthropic" "claude-code"]])' +# ``` { lib, + stdenv, buildNpmPackage, fetchzip, - writableTmpDirAsHomeHook, versionCheckHook, + writableTmpDirAsHomeHook, + bubblewrap, + procps, + socat, }: buildNpmPackage (finalAttrs: { pname = "claude-code"; - version = "2.0.51"; + version = "2.1.34"; src = fetchzip { url = "https://registry.npmjs.org/@anthropic-ai/claude-code/-/claude-code-${finalAttrs.version}.tgz"; - hash = "sha256-rfJZaACY+Kbm+0lWOPwAfl/x2yFxskLKZpJJhqlccSY="; + hash = "sha256-J3kltFY5nR3PsRWbW310VqD/6hhfMbVSvynv0eaIi3M="; }; - npmDepsHash = "sha256-/5Qh99vAcTiFz6FrzJgm26RserqxVjLYqOOx5q5hkgc="; + npmDepsHash = "sha256-n762einDxLUUXWMsfdPVhA/kn0ywlJgFQ2ZGoEk3E68="; + + strictDeps = true; postPatch = '' cp ${./package-lock.json} package-lock.json + + # https://github.com/anthropics/claude-code/issues/15195 + substituteInPlace cli.js \ + --replace-fail '#!/bin/sh' '#!/usr/bin/env sh' ''; dontNpmBuild = true; @@ -30,7 +44,21 @@ buildNpmPackage (finalAttrs: { postInstall = '' wrapProgram $out/bin/claude \ --set DISABLE_AUTOUPDATER 1 \ - --unset DEV + --set DISABLE_INSTALLATION_CHECKS 1 \ + --unset DEV \ + --prefix PATH : ${ + lib.makeBinPath ( + [ + # claude-code uses [node-tree-kill](https://github.com/pkrumins/node-tree-kill) which requires procps's pgrep(darwin) or ps(linux) + procps + ] + # the following packages are required for the sandbox to work (Linux only) + ++ lib.optionals stdenv.hostPlatform.isLinux [ + bubblewrap + socat + ] + ) + } ''; doInstallCheck = true; @@ -39,7 +67,6 @@ buildNpmPackage (finalAttrs: { versionCheckHook ]; versionCheckKeepEnvironment = [ "HOME" ]; - versionCheckProgramArg = "--version"; passthru.updateScript = ./update.sh; @@ -49,6 +76,7 @@ buildNpmPackage (finalAttrs: { downloadPage = "https://www.npmjs.com/package/@anthropic-ai/claude-code"; license = lib.licenses.unfree; maintainers = with lib.maintainers; [ + adeci malo markus1189 omarjatoi diff --git a/overlays/unstable/claude-code/update.sh b/overlays/unstable/claude-code/update.sh index a3fe5d8..870f733 100755 --- a/overlays/unstable/claude-code/update.sh +++ b/overlays/unstable/claude-code/update.sh @@ -1,5 +1,5 @@ -#!/usr/bin/env nix-shell -#!nix-shell --pure --keep NIX_PATH -i bash --packages nodejs nix-update git cacert +#!/usr/bin/env nix +#!nix shell --ignore-environment .#cacert .#nodejs .#git .#nix-update .#nix .#gnused .#findutils .#bash --command bash set -euo pipefail @@ -7,4 +7,9 @@ version=$(npm view @anthropic-ai/claude-code version) # Update version and hashes AUTHORIZED=1 NIXPKGS_ALLOW_UNFREE=1 nix-update claude-code --version="$version" --generate-lockfile -nix-update vscode-extensions.anthropic.claude-code --use-update-script --version "$version" + +# nix-update can't update package-lock.json along with npmDepsHash +# TODO: Remove this workaround if nix-update can update package-lock.json along with npmDepsHash. +(nix-build --expr '((import ./.) { system = builtins.currentSystem; }).claude-code.npmDeps.overrideAttrs { outputHash = ""; outputHashAlgo = "sha256"; }' 2>&1 || true) \ +| sed -nE '$s/ *got: *(sha256-[A-Za-z0-9+/=-]+).*/\1/p' \ +| xargs -I{} sed -i 's|npmDepsHash = "sha256-[^"]*";|npmDepsHash = "{}";|' pkgs/by-name/cl/claude-code/package.nix diff --git a/overlays/unstable/default.nix b/overlays/unstable/default.nix index 32e54ad..2c05417 100644 --- a/overlays/unstable/default.nix +++ b/overlays/unstable/default.nix @@ -4,7 +4,7 @@ final: prev: { gemini-cli # opencode tailscale - claude-code + # claude-code # open-webui # vscode # nodejs_20 @@ -19,7 +19,7 @@ final: prev: { */ # goose-cli = channels.unstable.callPackage ./goose.nix { }; - # claude-code = channels.unstable.callPackage ./claude-code/package.nix { }; + claude-code = channels.unstable.callPackage ./claude-code/package.nix { }; # gemini-cli = channels.unstable.callPackage ./gemini-cli/package.nix { }; # vscode-extensions = channels.unstable.vscode-extensions // { # rooveterinaryinc = { roo-cline = channels.unstable.callPackage ./roo-code.nix { }; }; From 958175fb014a9b63d57d00e4b06e58d530a9407a Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 6 Feb 2026 11:40:10 +0100 Subject: [PATCH 17/18] feat(nix): enable Searx and configure Nginx for domain - Added Searx service with Nginx configuration for the domain `search.hoyer.world`. --- systems/x86_64-linux/sgx/default.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/systems/x86_64-linux/sgx/default.nix b/systems/x86_64-linux/sgx/default.nix index e4ecaa8..3113971 100644 --- a/systems/x86_64-linux/sgx/default.nix +++ b/systems/x86_64-linux/sgx/default.nix @@ -23,6 +23,12 @@ services.tailscale.enable = true; + services.searx = { + enable = true; + configureNginx = true; + domain = "search.hoyer.world"; + }; + metacfg = { services.nginxBase.enable = true; services.acmeBase.enable = true; From 14c9a4f084ab0111ba8bf27561f2ecce5aa7a15b Mon Sep 17 00:00:00 2001 From: Harald Hoyer Date: Fri, 6 Feb 2026 11:43:37 +0100 Subject: [PATCH 18/18] feat(nix): add new domain to ACME certificate - Added `search.hoyer.world` to the `extraDomainNames` list for the `internal.hoyer.world` ACME certificate. - Ensures proper SSL configuration for the new subdomain. --- systems/x86_64-linux/sgx/acme.nix | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/systems/x86_64-linux/sgx/acme.nix b/systems/x86_64-linux/sgx/acme.nix index 76df2ef..5cf5b00 100644 --- a/systems/x86_64-linux/sgx/acme.nix +++ b/systems/x86_64-linux/sgx/acme.nix @@ -10,12 +10,13 @@ metacfg.services.acmeBase.credentialsFile = config.sops.secrets.internetbs.path; security.acme.certs = { - "internal.hoyer.world" = { - extraDomainNames = [ - "openwebui.hoyer.world" - "syncthing.hoyer.world" - "home.hoyer.world" - ]; - }; + "internal.hoyer.world" = { + extraDomainNames = [ + "openwebui.hoyer.world" + "syncthing.hoyer.world" + "home.hoyer.world" + "search.hoyer.world" + ]; }; + }; }