diff --git a/hosts/modules/alexandria/services.nix b/hosts/modules/alexandria/services.nix index 3cbe4dc..f872300 100644 --- a/hosts/modules/alexandria/services.nix +++ b/hosts/modules/alexandria/services.nix @@ -12,6 +12,29 @@ in { services = { + forgejo = { + enable = true; + repositoryRoot = "/data/forgejo"; + settings = { + session.COOKIE_SECURE = true; + server = { + PROTOCOL = "http+unix"; + DOMAIN = "git.baduhai.dev"; + ROOT_URL = "https://git.baduhai.dev"; + OFFLINE_MODE = true; # disable use of CDNs + SSH_DOMAIN = "baduhai.dev"; + }; + log.LEVEL = "Warn"; + mailer.ENABLED = false; + actions.ENABLED = false; + }; + }; + + jellyfin = { + enable = true; + openFirewall = true; + }; + nginx = { enable = true; recommendedGzipSettings = true; @@ -37,33 +60,13 @@ in "jellyfin.baduhai.dev".locations."/".proxyPass = "http://127.0.0.1:${ports.jellyfin}"; "pass.baduhai.dev".locations."/".proxyPass = "http://127.0.0.1:${ports.vaultwarden}"; "speedtest.baduhai.dev".locations."/".proxyPass = "http://127.0.0.1:${ports.librespeed}"; - "webdav.baduhai.dev".locations."/".proxyPass = "http://127.0.0.1:${ports.webdav}"; + # "webdav.baduhai.dev".locations."/" = { + # proxyPass = "http://127.0.0.1:${ports.webdav}"; + # proxyNoTimeout = true; + # }; }; }; - forgejo = { - enable = true; - repositoryRoot = "/data/forgejo"; - settings = { - session.COOKIE_SECURE = true; - server = { - PROTOCOL = "http+unix"; - DOMAIN = "git.baduhai.dev"; - ROOT_URL = "https://git.baduhai.dev"; - OFFLINE_MODE = true; # disable use of CDNs - SSH_DOMAIN = "baduhai.dev"; - }; - log.LEVEL = "Warn"; - mailer.ENABLED = false; - actions.ENABLED = false; - }; - }; - - jellyfin = { - enable = true; - openFirewall = true; - }; - radicale = { enable = true; settings = { @@ -81,6 +84,15 @@ in }; }; + rclone-webdav = { + enable = true; + authFile = config.age.secrets.wevdav.path; + dataDirectory = "/data/webdav"; + maxFileSize = "5G"; + listenAddresses = [ "0.0.0.0" ]; + port = lib.toInt ports.webdav; + }; + vaultwarden = { enable = true; config = { @@ -90,25 +102,6 @@ in ROCKET_PORT = "${ports.vaultwarden}"; }; }; - - webdav = { - enable = true; - settings = { - address = "0.0.0.0"; - port = lib.toInt ports.webdav; - behindProxy = true; - modify = true; - auth = true; - users = [ - { - username = "{env}USERNAME_1"; - password = "{env}PASSWORD_1"; - directory = "{env}USERNAME_1"; - } - ]; - }; - environmentFile = config.age.secrets."webdav.env".path; - }; }; virtualisation.oci-containers.containers."librespeed" = { @@ -142,8 +135,8 @@ in owner = "nginx"; group = "nginx"; }; - "webdav.env" = { - file = ../../../secrets/webdav.env.age; + webdav = { + file = ../../../secrets/webdav.age; owner = "webdav"; group = "webdav"; }; diff --git a/modules/rclone-webdav.nix b/modules/rclone-webdav.nix new file mode 100644 index 0000000..097537c --- /dev/null +++ b/modules/rclone-webdav.nix @@ -0,0 +1,267 @@ +{ + config, + lib, + pkgs, + ... +}: + +with lib; + +let + cfg = config.services.rclone-webdav; + + parseUserFile = + userFile: + let + content = builtins.readFile userFile; + lines = filter (line: line != "" && !hasPrefix "#" line) (splitString "\n" content); + parseUser = + line: + let + parts = splitString ":" line; + in + { + username = elemAt parts 0; + password = elemAt parts 1; + }; + in + map parseUser lines; + + users = if cfg.authFile != null then parseUserFile cfg.authFile else [ ]; + usernames = map (u: u.username) users; + + socketDirectory = "/var/lib/webdav"; + + # Generate rclone service for each user + mkRcloneService = + user: + nameValuePair "rclone-webdav-${user.username}" { + description = "rclone WebDAV service for ${user.username}"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + Restart = "always"; + RestartSec = "5s"; + + # Ensure directories exist + ExecStartPre = [ + "${pkgs.coreutils}/bin/mkdir -p ${cfg.dataDirectory}/${user.username}" + "${pkgs.coreutils}/bin/mkdir -p ${socketDirectory}" + "${pkgs.coreutils}/bin/chown ${cfg.user}:${cfg.group} ${cfg.dataDirectory}/${user.username}" + "${pkgs.coreutils}/bin/chown ${cfg.user}:${cfg.group} ${socketDirectory}" + ]; + + ExecStart = '' + ${pkgs.rclone}/bin/rclone serve webdav ${cfg.dataDirectory}/${user.username} \ + --addr unix:${socketDirectory}/rclone-${user.username}.sock \ + --user ${user.username} \ + --pass ${user.password} \ + --log-level INFO + ''; + + # Security settings + NoNewPrivileges = true; + PrivateTmp = true; + ProtectSystem = "strict"; + ProtectHome = true; + ReadWritePaths = [ + cfg.dataDirectory + socketDirectory + ]; + }; + }; + + # Generate nginx upstream for each user + mkNginxUpstream = user: { + name = "rclone-${user.username}"; + value = { + servers = { + "unix:${socketDirectory}/rclone-${user.username}.sock" = { }; + }; + }; + }; + + # Generate nginx location for each user + mkNginxLocation = user: { + name = "/${user.username}/"; + value = { + proxyPass = "http://rclone-${user.username}"; + 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; + + # Remove the username prefix from the path + rewrite ^/${user.username}/(.*) /$1 break; + + # WebDAV specific headers + proxy_set_header Destination $http_destination; + proxy_set_header Depth $http_depth; + proxy_set_header Overwrite $http_overwrite; + proxy_set_header Lock-Token $http_lock_token; + proxy_set_header If $http_if; + + # Allow WebDAV methods + proxy_method $request_method; + + # Increase timeouts for large file uploads + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + + # Set maximum file size for this location + client_max_body_size ${cfg.maxFileSize}; + ''; + }; + }; + +in +{ + options.services.rclone-webdav = { + enable = mkEnableOption "rclone WebDAV multi-user service"; + + authFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to file containing username:password pairs, one per line"; + example = "/etc/rclone-webdav-users"; + }; + + dataDirectory = mkOption { + type = types.str; + default = "/srv/webdav"; + description = "Base directory where user subdirectories will be created"; + }; + + user = mkOption { + type = types.str; + default = "webdav"; + description = "User to run rclone services as"; + }; + + group = mkOption { + type = types.str; + default = "webdav"; + description = "Group to run rclone services as"; + }; + + listenAddresses = mkOption { + type = types.listOf types.str; + default = [ "localhost" ]; + description = "List of addresses for nginx to listen on"; + example = [ + "localhost" + "127.0.0.1" + "::1" + ]; + }; + + port = mkOption { + type = types.port; + default = 8000; + description = "Port for nginx to listen on"; + }; + + maxFileSize = mkOption { + type = types.str; + default = "0"; + description = "Maximum file size for uploads (nginx client_max_body_size). Use '0' for unlimited."; + example = "100M"; + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = cfg.authFile != null; + message = "services.rclone-webdav.authFile must be specified"; + } + { + assertion = users != [ ]; + message = "Auth file must contain at least one user"; + } + ]; + + # Create user and group + users.users = mkIf (cfg.user == "webdav") { + webdav = { + isSystemUser = true; + group = cfg.group; + description = "rclone WebDAV service user"; + home = cfg.dataDirectory; + createHome = true; + }; + }; + + users.groups = mkIf (cfg.group == "webdav") { + webdav = { + gid = null; + }; + }; + + # Create systemd services for each user + systemd.services = listToAttrs (map mkRcloneService users); + + # Configure nginx + services.nginx = { + enable = true; + + upstreams = listToAttrs (map mkNginxUpstream users); + + virtualHosts."rclone-webdav" = { + listen = map (addr: { + addr = addr; + port = cfg.port; + }) cfg.listenAddresses; + + locations = listToAttrs (map mkNginxLocation users) // { + "/" = { + return = "200 'rclone WebDAV Multi-user Server'"; + extraConfig = '' + add_header Content-Type text/plain; + ''; + }; + + # Catch-all location for non-existent users - return 400 + "~* ^/([^/]+)/" = { + extraConfig = '' + # Check if the requested user exists + set $user_exists 0; + ${concatStringsSep "\n" ( + map (username: "if ($1 = \"${username}\") { set $user_exists 1; }") usernames + )} + + # If user doesn't exist, return 400 + if ($user_exists = 0) { + return 400 "User not found"; + } + + # This should not be reached for valid users + return 404; + ''; + }; + }; + + extraConfig = '' + # Enable WebDAV methods + dav_methods PUT DELETE MKCOL COPY MOVE; + dav_ext_methods PROPFIND PROPPATCH LOCK UNLOCK; + + # Set default maximum file size + client_max_body_size ${cfg.maxFileSize}; + ''; + }; + }; + + # Ensure directories exist + systemd.tmpfiles.rules = [ + "d ${cfg.dataDirectory} 0755 ${cfg.user} ${cfg.group} -" + "d ${socketDirectory} 0755 ${cfg.user} ${cfg.group} -" + ] ++ map (user: "d ${cfg.dataDirectory}/${user.username} 0755 ${cfg.user} ${cfg.group} -") users; + }; +} diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 8ffe939..ba71b7a 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -25,5 +25,5 @@ let in { "cloudflare.age".publicKeys = all-hosts; - "webdav.env.age".publicKeys = all-hosts; + "webdav.age".publicKeys = all-hosts; } diff --git a/secrets/webdav.age b/secrets/webdav.age new file mode 100644 index 0000000..93bd850 --- /dev/null +++ b/secrets/webdav.age @@ -0,0 +1,15 @@ +age-encryption.org/v1 +-> ssh-ed25519 Kfdnog 9oKx6Oz/J/QJ0mmgoLX5AUx0sFdxnPVnjF42bElPSXA +BJ6h4lHGDsf1Npc4bwkvz5htGRT/x/b2bs9WFM2W/pc +-> ssh-ed25519 SP9f6A T5t4apynXLYN/4YEvaHRCI28rrKzet4r6LrbAye5VGk +BsXkZYBxG9zcfLYCd9H0+LW078oCDyYx9zG+DPfE7bA +-> ssh-ed25519 8YSAiw RY0YR30qyJPvhy7eTJLoj2JXpH9qHP43fJaHilJykXM +E5/P0Egz/LKwEhYLYd5Cnrat47gnYn93yDSeYgLi934 +-> ssh-ed25519 7cojTQ qTCTw7CjilThFLmXYph4YhVBhnk1DpnFCGwgioo/XB0 +N31nZ8nInQuddLD3b0bxI5Es/pTvTQD8nz0f/AZtNFg +-> ssh-ed25519 J6tVTA 7OawDsWwtVxu76ZgF0dFclMr19sBNdtu7H+Tr7Pd+SQ +hhVKcscIKIH1WChhRo/RYqUWy1rgs/EKnlHr9uY7QrQ +-> ssh-ed25519 Kl5yTQ +i2Q3uNHw1jAVH76NHy4QbjCc6sBBYjsbr7w4mLaHW4 +JOJ02zU0+IxlbXMBsW4UrvzvLUbifdzABBNL+bc0bBs +--- W40oEFdBUKbi0teNTc6B1sX0ReHDvkIJcBm1dlROnk8 +zҼCn箱e7{{N6az"EHB/BYbD \ No newline at end of file diff --git a/secrets/webdav.env.age b/secrets/webdav.env.age deleted file mode 100644 index 790cbe8..0000000 --- a/secrets/webdav.env.age +++ /dev/null @@ -1,15 +0,0 @@ -age-encryption.org/v1 --> ssh-ed25519 Kfdnog iYAk0YU1ekpdPEzhPKPBDeF2oM2XT6ZIqe/6xoJafSA -W0aPLLAsCUOIBqWtxzyZw5l4I72n1BFD/E9Oalz7lYg --> ssh-ed25519 SP9f6A VzaqzMWrKJ5Iuj/A2EnBnEJNApQ0wx6ktNzhqMhxkx4 -0/qTjEcQXanCLODi8pGEYWR6JX2QZU2fHy5REIw0bA8 --> ssh-ed25519 8YSAiw I+aTRniv+tTMrQQiTVONh4ziisdqGbE1Ntyy7zYFe3Y -tc4yyoNsPQum5lPc2/eKY6CXl+bYst9JqsXHJmt2RPo --> ssh-ed25519 7cojTQ 96asBSxBQWOxMaKdvBUXCro5fqpeZnpWSXapjCmxqF8 -m0MBushdOMI9zp7R7VYzPibRXRfsSX9m3HTDaiv3oYQ --> ssh-ed25519 J6tVTA uAW/2s8wHouV06Cf8XOl1MVVniZDU0STJDMyziG1x2E -BpwvevoU5w8m32KssiBUwuQXkvxl3LRjWN8u5dpDdS0 --> ssh-ed25519 Kl5yTQ p6rLLWT7Ey5RhYl4xrFI5blBXtNPMHYj8OezuQEWPU8 -Ao9FmLXhWaCirDOCHM3pVZEdKy0lQs4aNkSj0gtjRvM ---- 6EArX870mq5cAeKEJZfqwVZJ1CLMG2Od7ndZYVOrpfc -v˲1׹sH]Ck+ )˚ZVKnM-, R&>W2