diff options
Diffstat (limited to 'modules/services/nixos')
| -rw-r--r-- | modules/services/nixos/default.nix | 5 | ||||
| -rw-r--r-- | modules/services/nixos/ganyupload/AGENTS.md | 65 | ||||
| -rw-r--r-- | modules/services/nixos/ganyupload/main.go | 212 | ||||
| -rw-r--r-- | modules/services/nixos/jta/main.go | 30 | ||||
| -rw-r--r-- | modules/services/nixos/minecraft.nix | 12 | ||||
| -rw-r--r-- | modules/services/nixos/ngircd.nix | 8 | ||||
| -rw-r--r-- | modules/services/nixos/openssh.nix | 31 | ||||
| -rw-r--r-- | modules/services/nixos/polaris.nix | 26 |
8 files changed, 328 insertions, 61 deletions
diff --git a/modules/services/nixos/default.nix b/modules/services/nixos/default.nix index c3652b5..13cdcf4 100644 --- a/modules/services/nixos/default.nix +++ b/modules/services/nixos/default.nix @@ -6,11 +6,10 @@ ./forgejo.nix ./goaccess.nix ./btopweb.nix - ./cgit - ./jta ./ganyupload - ./polaris.nix + + ./jta ./agate.nix ./minecraft.nix ./ngircd.nix diff --git a/modules/services/nixos/ganyupload/AGENTS.md b/modules/services/nixos/ganyupload/AGENTS.md new file mode 100644 index 0000000..c4d9192 --- /dev/null +++ b/modules/services/nixos/ganyupload/AGENTS.md @@ -0,0 +1,65 @@ +# Ganyupload - Agent Instructions + +## Quick Context +- Simple Go file upload service using standard `net/http`. +- Single `main.go`, two Nix files (`default.nix`, `pkg.nix`), one README. +- Embedded README is served on GET /. +- Used as a NixOS module: `collinux.services.ganyupload` enables it, configures port and upload directory via systemd environment. + +## How to Build and Test + +### Build +```bash +nix build +``` + +Alternatively, from the repo root: +```bash +nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel +``` + +### Test +No dedicated test file. Verify locally by running with environment variables: +```bash +PORT=8080 UPLOAD_DIR=/tmp/upload go run main.go +``` + +Then test uploads: +```bash +curl -X PUT --data-binary @file.txt http://localhost:8080/file.txt +curl http://localhost:8080/ # Read embedded README +``` + +## Code Style + +**Go:** +- Keep `main.go` clean; use `gofmt -w .`. +- Match existing error handling (early return, `log.Printf` for warnings, `log.Fatalf` for fatal errors). +- Path traversal prevention is critical; do not weaken `filepath.Clean` + `..` prefix checks. + +**Nix:** +- Follow `/home/collin/nixos/AGENTS.md` conventions (2-space indent, common arg pattern, `lib.mkIf` for gating). +- `default.nix` imports `mkCaddyCfg.nix` to integrate with Caddy reverse proxy; do not remove that import. + +## Key Implementation Notes + +1. **Embedded README:** The README file is embedded at compile time using `//go:embed README`. If you update README, rebuild to reflect changes. +2. **Environment Variables:** + - `UPLOAD_DIR`: Defaults to `.`, overridden by systemd service to `/media/ganyupload`. + - `PORT`: Defaults to `8080`, set by systemd to the configured `cfg.port`. +3. **NixOS Integration:** The service runs as user/group `ganyupload` with home `/var/lib/ganyupload`. Ensure the service has write permission to `UPLOAD_DIR`. +4. **Caddy Integration:** `default.nix` imports `mkCaddyCfg.nix` to configure Caddy as a reverse proxy. Changes to hostname or port must be reflected in both Nix config and the Caddy rule. + +## Common Tasks + +- **Update Go code:** Edit `main.go`, test locally, then nix build to verify. +- **Change upload directory or port:** Update `default.nix` (systemd environment or `cfg.port`). +- **Update README:** Edit README file, then rebuild (`nix build`) so the embedded version updates. +- **Debug NixOS module:** Check `/etc/systemd/system/ganyupload.service` for actual service config after rebuild. + +## Validation + +After changes: +1. Run `nix build` from this directory or `nix build .#nixosConfigurations.<hostname>.config.system.build.toplevel` from repo root. +2. If applicable, test via `PORT=8080 UPLOAD_DIR=/tmp go run main.go`. +3. Verify no Nix lint issues (use repo-level `yo test` or `nix run nixpkgs#alejandra -- .` for formatting). diff --git a/modules/services/nixos/ganyupload/main.go b/modules/services/nixos/ganyupload/main.go index 6aa219e..75852c2 100644 --- a/modules/services/nixos/ganyupload/main.go +++ b/modules/services/nixos/ganyupload/main.go @@ -2,11 +2,14 @@ package main import ( _ "embed" + "fmt" + "html" "io" "log" "net/http" "os" "path/filepath" + "sort" "strings" ) @@ -15,6 +18,42 @@ var readme string var uploadDir = "." +// cleanPath safely cleans and validates a path to prevent traversal attacks +func cleanPath(filename string) (string, error) { + // Remove leading slash + filename = strings.TrimPrefix(filename, "/") + if filename == "" { + return "", nil + } + + // Clean the path + filename = filepath.Clean(filename) + + // Reject if it tries to go up + if strings.HasPrefix(filename, "..") || strings.Contains(filename, "/../") { + return "", fmt.Errorf("invalid filename: path traversal not allowed") + } + + return filename, nil +} + +// isPathSafe checks that the resolved fullPath stays within uploadDir +func isPathSafe(uploadDir, fullPath string) bool { + // Resolve both paths to absolute to catch symlink attacks + absUploadDir, err := filepath.Abs(uploadDir) + if err != nil { + return false + } + absFullPath, err := filepath.Abs(fullPath) + if err != nil { + return false + } + + // Ensure the resolved path starts with uploadDir + return strings.HasPrefix(absFullPath, absUploadDir+string(filepath.Separator)) || + absFullPath == absUploadDir +} + func handleRoot(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) @@ -32,27 +71,37 @@ func handleUpload(w http.ResponseWriter, r *http.Request) { return } + // Handle GET requests for downloads and directory listing + if r.Method == http.MethodGet { + handleDownload(w, r) + return + } + if r.Method != http.MethodPut { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } // Extract filename from URL path, preventing path traversal - filename := strings.TrimPrefix(r.URL.Path, "/") - if filename == "" { - http.Error(w, "filename required", http.StatusBadRequest) + filename, err := cleanPath(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) return } - // Prevent path traversal - filename = filepath.Clean(filename) - if strings.HasPrefix(filename, "..") { - http.Error(w, "invalid filename", http.StatusBadRequest) + if filename == "" { + http.Error(w, "filename required", http.StatusBadRequest) return } fullPath := filepath.Join(uploadDir, filename) + // Verify the path is safe + if !isPathSafe(uploadDir, fullPath) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + // Ensure the directory exists dir := filepath.Dir(fullPath) if err := os.MkdirAll(dir, 0755); err != nil { @@ -85,6 +134,155 @@ func handleUpload(w http.ResponseWriter, r *http.Request) { log.Printf("uploaded %s (%d bytes)", filename, written) } +func handleDownload(w http.ResponseWriter, r *http.Request) { + // Extract filename from URL path + filename, err := cleanPath(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + fullPath := filepath.Join(uploadDir, filename) + + // Verify the path is safe + if !isPathSafe(uploadDir, fullPath) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + + fileInfo, err := os.Stat(fullPath) + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "not found", http.StatusNotFound) + } else { + log.Printf("failed to stat: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + } + return + } + + // If it's a directory, list its contents + if fileInfo.IsDir() { + handleDirList(w, r, uploadDir, filename, fullPath) + return + } + + // Serve the file + http.ServeFile(w, r, fullPath) +} + +func handleDirList(w http.ResponseWriter, r *http.Request, uploadDir, relPath, fullPath string) { + entries, err := os.ReadDir(fullPath) + if err != nil { + log.Printf("failed to read directory: %v", err) + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + + // Sort entries by name + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + // Build breadcrumb navigation + pathParts := strings.Split(strings.Trim(relPath, "/"), "/") + if relPath == "" { + pathParts = []string{} + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + + fmt.Fprintf(w, `<!DOCTYPE html> +<html> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Directory: %s</title> + <style> + body { font-family: monospace; margin: 2rem; } + h1 { margin-bottom: 0.5rem; } + .breadcrumb { margin-bottom: 2rem; font-size: 0.9rem; } + .breadcrumb a { text-decoration: none; color: #0066cc; } + .breadcrumb a:hover { text-decoration: underline; } + table { border-collapse: collapse; width: 100%%; } + td { padding: 0.5rem; border-bottom: 1px solid #ccc; } + td:first-child { text-align: left; } + td:last-child { text-align: right; color: #666; } + a { text-decoration: none; color: #0066cc; } + a:hover { text-decoration: underline; } + .directory { color: #0066cc; font-weight: bold; } + </style> +</head> +<body> + <h1>Directory: %s</h1> +`, html.EscapeString(relPath), html.EscapeString(relPath)) + + // Breadcrumb navigation + fmt.Fprint(w, ` <div class="breadcrumb">`) + fmt.Fprintf(w, `<a href="/">home</a>`) + if len(pathParts) > 0 { + accumulated := "" + for _, part := range pathParts { + accumulated = accumulated + "/" + part + fmt.Fprintf(w, ` / <a href="%s">%s</a>`, html.EscapeString(accumulated), html.EscapeString(part)) + } + } + fmt.Fprint(w, `</div>`) + + // Directory listing + fmt.Fprint(w, ` <table> +`) + for _, entry := range entries { + name := entry.Name() + safeName := html.EscapeString(name) + + if entry.IsDir() { + // For directories, add trailing slash to the link + linkPath := relPath + "/" + name + if relPath == "" { + linkPath = "/" + name + } + linkPath = strings.TrimPrefix(linkPath, "/") + fmt.Fprintf(w, ` <tr><td><a href="/%s" class="directory">%s/</a></td><td>-</td></tr> +`, html.EscapeString(linkPath), safeName) + } else { + info, _ := entry.Info() + linkPath := relPath + "/" + name + if relPath == "" { + linkPath = "/" + name + } + linkPath = strings.TrimPrefix(linkPath, "/") + size := formatSize(info.Size()) + fmt.Fprintf(w, ` <tr><td><a href="/%s">%s</a></td><td>%s</td></tr> +`, html.EscapeString(linkPath), safeName, size) + } + } + fmt.Fprint(w, ` </table> +</body> +</html> +`) +} + +func formatSize(bytes int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + + switch { + case bytes < KB: + return fmt.Sprintf("%d B", bytes) + case bytes < MB: + return fmt.Sprintf("%.1f KB", float64(bytes)/KB) + case bytes < GB: + return fmt.Sprintf("%.1f MB", float64(bytes)/MB) + default: + return fmt.Sprintf("%.1f GB", float64(bytes)/GB) + } +} + func main() { if envUploadDir := os.Getenv("UPLOAD_DIR"); envUploadDir != "" { uploadDir = envUploadDir diff --git a/modules/services/nixos/jta/main.go b/modules/services/nixos/jta/main.go index 1390d7f..e7b5a0f 100644 --- a/modules/services/nixos/jta/main.go +++ b/modules/services/nixos/jta/main.go @@ -36,19 +36,35 @@ var pageTmpl = template.Must(template.New("md_template.html").Parse(mdTemplate)) const authCookieName = "jta_auth" -func isAuthenticated(r *http.Request) bool { +func isAuthenticated(w http.ResponseWriter, r *http.Request) bool { expectedHash := strings.TrimSpace(os.Getenv("AUTH_PASSWORD_HASH")) if expectedHash == "" { return false } - cookie, err := r.Cookie(authCookieName) - if err != nil { - return false + // Try cookie first + if cookie, err := r.Cookie(authCookieName); err == nil { + providedHash := strings.TrimSpace(cookie.Value) + if providedHash != "" && strings.EqualFold(providedHash, expectedHash) { + return true + } + } + + // Fallback to URL parameter + paramHash := strings.TrimSpace(r.URL.Query().Get(authCookieName)) + if paramHash != "" && strings.EqualFold(paramHash, expectedHash) { + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: paramHash, + Path: "/", + HttpOnly: true, // Security: Protects against XSS cookie theft + Secure: false, // Set to true if you are hosting over HTTPS + SameSite: http.SameSiteLaxMode, // Prevents CSRF vulnerabilities + }) + return true } - providedHash := strings.TrimSpace(cookie.Value) - return strings.EqualFold(providedHash, expectedHash) + return false } func serveMarkdown(w http.ResponseWriter, r *http.Request) { @@ -101,7 +117,7 @@ func main() { return } - if !isAuthenticated(r) { + if !isAuthenticated(w, r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } diff --git a/modules/services/nixos/minecraft.nix b/modules/services/nixos/minecraft.nix index 1b8b047..7e12a61 100644 --- a/modules/services/nixos/minecraft.nix +++ b/modules/services/nixos/minecraft.nix @@ -6,7 +6,7 @@ cfg = config.collinux.services.minecraft; in lib.mkIf cfg.enable { - networking.firewall.allowedUDPPorts = lib.optional (cfg.listenAddr == "0.0.0.0") cfg.port; + networking.firewall.allowedUDPPorts = lib.optional cfg.public cfg.port; virtualisation.oci-containers.containers."Minecraft" = { environment = { @@ -15,14 +15,20 @@ in SERVER_NAME = "YServer"; TZ = config.time.timeZone; - VERSION = "1.26.3.1"; + VERSION = "1.26.20.5"; CONTENT_LOG_FILE_ENABLED = "false"; ALLOW_CHEATS = "false"; DIFFICULTY = "1"; }; image = "itzg/minecraft-bedrock-server"; - ports = ["${cfg.listenAddr}:${toString cfg.port}:19132/udp"]; + ports = [ + "${ + if cfg.public + then "0.0.0.0" + else "127.0.0.1" + }:${toString cfg.port}:19132/udp" + ]; volumes = ["/var/lib/minecraft/:/data"]; extraOptions = ["--no-healthcheck"]; diff --git a/modules/services/nixos/ngircd.nix b/modules/services/nixos/ngircd.nix index d06a497..ac93f06 100644 --- a/modules/services/nixos/ngircd.nix +++ b/modules/services/nixos/ngircd.nix @@ -6,7 +6,7 @@ cfg = config.collinux.services.ngircd; in lib.mkIf cfg.enable { - networking.firewall.allowedTCPPorts = [cfg.port]; + networking.firewall.allowedTCPPorts = lib.optional cfg.public cfg.port; services.ngircd = { enable = true; @@ -16,7 +16,11 @@ in Info = Ganymede IRC Chat AdminInfo1 = Collin - Listen = 0.0.0.0 + Listen = ${ + if cfg.public + then "0.0.0.0" + else "127.0.0.1" + } Ports = ${toString cfg.port} [Channel] diff --git a/modules/services/nixos/openssh.nix b/modules/services/nixos/openssh.nix index 1ce1abc..98f8b87 100644 --- a/modules/services/nixos/openssh.nix +++ b/modules/services/nixos/openssh.nix @@ -6,15 +6,9 @@ ... }: let cfg = config.collinux.services.sshd; - - authorizedKeys = - hosts - |> builtins.mapAttrs (_: data: data.user_pubkey or null) - |> builtins.attrValues - |> builtins.filter (x: x != null); in { config = lib.mkIf cfg.enable { - networking.firewall.allowedTCPPorts = [cfg.port]; + networking.firewall.allowedTCPPorts = lib.optional cfg.public cfg.port; services.openssh = { enable = true; @@ -29,7 +23,10 @@ in { listenAddresses = [ { - addr = cfg.listenAddr; + addr = + if cfg.public + then "0.0.0.0" + else "127.0.0.1"; port = cfg.port; } ]; @@ -46,7 +43,7 @@ in { extraConfig = lib.concatStringsSep "\n" [ "Match LocalPort ${toString cfg.port}" ( - if cfg.otp + if cfg.conf.otp then '' ChallengeResponseAuthentication yes PubkeyAuthentication yes @@ -58,11 +55,11 @@ in { AuthenticationMethods publickey '' ) - (lib.optionalString cfg.rootLogin "PermitRootLogin yes") + (lib.optionalString cfg.conf.rootLogin "PermitRootLogin yes") ]; }; - security.pam.services = lib.optionalAttrs cfg.otp { + security.pam.services = lib.optionalAttrs cfg.conf.otp { login.googleAuthenticator.enable = true; sshd.text = '' @@ -78,8 +75,16 @@ in { ''; }; - users.users.${config.collinux.user.name}.openssh.authorizedKeys.keys = authorizedKeys; - users.users."root".openssh.authorizedKeys.keys = lib.mkIf cfg.rootLogin authorizedKeys; + users.users = let + k.openssh.authorizedKeys.keys = + hosts + |> builtins.mapAttrs (_: data: data.user_pubkey or null) + |> builtins.attrValues + |> builtins.filter (x: x != null); + in { + ${config.collinux.user.name} = k; + "root" = lib.mkIf cfg.conf.rootLogin k; + }; systemd.services."openssh" = { after = lib.mkAfter ["network-online.target"]; diff --git a/modules/services/nixos/polaris.nix b/modules/services/nixos/polaris.nix deleted file mode 100644 index 89f5ef3..0000000 --- a/modules/services/nixos/polaris.nix +++ /dev/null @@ -1,26 +0,0 @@ -{ - lib, - config, - ... -}: let - cfg = config.collinux.services.polaris; -in { - imports = [ - (import ./mkCaddyCfg.nix cfg) - ]; - - config = lib.mkIf cfg.enable { - services.polaris = { - enable = true; - port = cfg.port; - settings = { - mount_dirs = [ - { - name = "Ganymede Library"; - source = "/media/library/music"; - } - ]; - }; - }; - }; -} |
