feat: Initial Gordon v2 architecture - streamlined core implementation
Major refactoring to create a simplified, focused Gordon v2: - Remove complex web UI, templating, and database layers - Strip down to essential proxy and container management functionality - Update dependencies and build configuration - Establish foundation for cleaner, more maintainable architecture
- commit
- 99766e40c70917f8adf7f1533bd6582bc2fcffac
- author
- bamen · · 2025-06-02 15:56 UTC
- parent
- 4333716
- signature
- unsigned
this diff is incomplete: 2 files are too large to show. the totals above count everything; clone the repository to read the rest.
D .air.toml +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | root = "." | |
| 2 | - | testdata_dir = "testdata" | |
| 3 | - | tmp_dir = "tmp" | |
| 4 | - | ||
| 5 | - | [build] | |
| 6 | - | args_bin = ["serve"] | |
| 7 | - | bin = "./dist/tmp/main" | |
| 8 | - | cmd = "go build -o ./dist/tmp/main ./main.go" | |
| 9 | - | delay = 0 | |
| 10 | - | exclude_dir = ["assets", "tmp", "vendor", "testdata", "node_modules"] | |
| 11 | - | exclude_file = [] | |
| 12 | - | exclude_regex = ["_test.go"] | |
| 13 | - | exclude_unchanged = false | |
| 14 | - | follow_symlink = false | |
| 15 | - | full_bin = "./dist/tmp/main" | |
| 16 | - | include_dir = [] | |
| 17 | - | include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"] | |
| 18 | - | include_file = [] | |
| 19 | - | kill_delay = "0s" | |
| 20 | - | log = "build-errors.log" | |
| 21 | - | poll = false | |
| 22 | - | poll_interval = 0 | |
| 23 | - | rerun = false | |
| 24 | - | rerun_delay = 1000 | |
| 25 | - | send_interrupt = true | |
| 26 | - | stop_on_error = false | |
| 27 | - | ||
| 28 | - | [color] | |
| 29 | - | app = "" | |
| 30 | - | build = "yellow" | |
| 31 | - | main = "magenta" | |
| 32 | - | runner = "green" | |
| 33 | - | watcher = "cyan" | |
| 34 | - | ||
| 35 | - | [log] | |
| 36 | - | main_only = false | |
| 37 | - | time = false | |
| 38 | - | ||
| 39 | - | [misc] | |
| 40 | - | clean_on_exit = false | |
| 41 | - | ||
| 42 | - | [screen] | |
| 43 | - | clear_on_rebuild = true | |
| 44 | - | keep_scroll = true | |
D .env.example +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | # its empty here⏎̸ | |
M .gitignore +1 -23
| @@ -1,23 +1,1 @@ | |||
| 1 | - | .env | |
| 2 | - | # Node.js/Bun specific (removed) | |
| 3 | - | # node_modules/ | |
| 4 | - | *tmp/ | |
| 5 | - | tmp/* | |
| 6 | - | config.yml | |
| 7 | - | # bun.lockb | |
| 8 | - | *.log | |
| 9 | - | *.txt | |
| 10 | - | *sync-conflict* | |
| 11 | - | main | |
| 12 | - | gordon | |
| 13 | - | dist/ | |
| 14 | - | data/ | |
| 15 | - | setup.sh | |
| 16 | - | .iscontainer | |
| 17 | - | sqlite.db | |
| 18 | - | .vscode/ | |
| 19 | - | admin-ui/ | |
| 20 | - | ||
| 21 | - | # Standalone Tailwind/DaisyUI | |
| 22 | - | tailwindcss | |
| 23 | - | daisyui.js | |
| 1 | + | gordon⏎̸ | |
M .goreleaser.yaml +0 -3
| @@ -2,9 +2,6 @@ version: 2 | |||
| 2 | 2 | ||
| 3 | 3 | before: | |
| 4 | 4 | hooks: | |
| 5 | - | - go mod tidy | |
| 6 | - | - templ generate | |
| 7 | - | - tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css | |
| 8 | 5 | ||
| 9 | 6 | release: | |
| 10 | 7 | draft: true | |
D .templ.config.json +0 -4
| @@ -1,4 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | "root": "internal/templating/models/templ", | |
| 3 | - | "paths": ["internal/templating/models/templ"] | |
| 4 | - | }⏎̸ | |
D .vscode/launch.json +0 -18
| @@ -1,18 +0,0 @@ | |||
| 1 | - | { | |
| 2 | - | // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. | |
| 3 | - | // Pointez pour afficher la description des attributs existants. | |
| 4 | - | // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 | |
| 5 | - | "version": "0.2.0", | |
| 6 | - | "configurations": [ | |
| 7 | - | { | |
| 8 | - | "name": "Launch file", | |
| 9 | - | "type": "go", | |
| 10 | - | "request": "launch", | |
| 11 | - | "mode": "debug", | |
| 12 | - | "program": "./main.go", | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | ||
| 16 | - | ] | |
| 17 | - | ||
| 18 | - | }⏎̸ | |
D Dockerfile +0 -8
| @@ -1,8 +0,0 @@ | |||
| 1 | - | FROM --platform=$TARGETPLATFORM alpine:latest | |
| 2 | - | ||
| 3 | - | COPY gordon /gordon | |
| 4 | - | ||
| 5 | - | RUN touch /.iscontainer | |
| 6 | - | ||
| 7 | - | ENTRYPOINT ["/gordon"] | |
| 8 | - | CMD ["serve"] | |
D Makefile +0 -88
| @@ -1,88 +0,0 @@ | |||
| 1 | - | # This Makefile is used for dev purposes | |
| 2 | - | # Variables | |
| 3 | - | REPO := ghcr.io/bnema/gordon | |
| 4 | - | TAG := dev | |
| 5 | - | DIST_DIR := ./dist | |
| 6 | - | ENGINE := podman | |
| 7 | - | ||
| 8 | - | # Version information | |
| 9 | - | VERSION := $(shell git describe --tags --always --dirty) | |
| 10 | - | COMMIT := $(shell git rev-parse --short HEAD) | |
| 11 | - | BUILD_DATE := $(shell date -u '+%Y-%m-%d_%I:%M:%S%p') | |
| 12 | - | ||
| 13 | - | # Build flags | |
| 14 | - | LDFLAGS := -s -w \ | |
| 15 | - | -X main.version=$(VERSION) \ | |
| 16 | - | -X main.commit=$(COMMIT) \ | |
| 17 | - | -X main.date=$(BUILD_DATE) | |
| 18 | - | ||
| 19 | - | # Architectures | |
| 20 | - | ARCHS := amd64 arm64 | |
| 21 | - | ||
| 22 | - | # Phony targets | |
| 23 | - | .PHONY: all build build-push clean build-css build-templ | |
| 24 | - | ||
| 25 | - | # Default target | |
| 26 | - | all: build | |
| 27 | - | ||
| 28 | - | # Build binaries | |
| 29 | - | build: build-css build-templ | |
| 30 | - | @echo "Building Go binaries..." | |
| 31 | - | @mkdir -p $(DIST_DIR) | |
| 32 | - | @rm -f $(DIST_DIR)/* | |
| 33 | - | @echo "Building with version $(VERSION), commit $(COMMIT), date $(BUILD_DATE)" | |
| 34 | - | @CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-amd64 ./main.go | |
| 35 | - | @CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags="$(LDFLAGS)" -o $(DIST_DIR)/gordon-linux-arm64 ./main.go | |
| 36 | - | @echo "Go binaries built successfully" | |
| 37 | - | ||
| 38 | - | # Build CSS with tailwindcss | |
| 39 | - | build-css: | |
| 40 | - | @echo "Building CSS with tailwindcss..." | |
| 41 | - | @tailwindcss -i internal/webui/public/assets/css/custom.css -o internal/webui/public/assets/css/tailwind.css | |
| 42 | - | @echo "CSS built successfully" | |
| 43 | - | ||
| 44 | - | # Generate Go code from templ templates | |
| 45 | - | build-templ: | |
| 46 | - | @echo "Generating Go code from templ templates..." | |
| 47 | - | @templ generate | |
| 48 | - | @echo "templ generation completed successfully" | |
| 49 | - | ||
| 50 | - | # Build and push Docker images | |
| 51 | - | build-push: build-css build | |
| 52 | - | @echo "Cleaning up dangling images..." | |
| 53 | - | @$(ENGINE) image prune -f | |
| 54 | - | ||
| 55 | - | @echo "Building and pushing Docker images..." | |
| 56 | - | @for arch in $(ARCHS); do \ | |
| 57 | - | cp $(DIST_DIR)/gordon-linux-$$arch gordon; \ | |
| 58 | - | $(ENGINE) build -t $(REPO):$(TAG)-$$arch .; \ | |
| 59 | - | rm gordon; \ | |
| 60 | - | $(ENGINE) push $(REPO):$(TAG)-$$arch; \ | |
| 61 | - | done | |
| 62 | - | ||
| 63 | - | @echo "Removing existing manifest..." | |
| 64 | - | @$(ENGINE) manifest rm $(REPO):$(TAG) || true | |
| 65 | - | ||
| 66 | - | @echo "Creating multi-arch manifest..." | |
| 67 | - | @$(ENGINE) manifest create $(REPO):$(TAG) \ | |
| 68 | - | $(REPO):$(TAG)-amd64 \ | |
| 69 | - | $(REPO):$(TAG)-arm64 | |
| 70 | - | ||
| 71 | - | @echo "Annotating arm64 image..." | |
| 72 | - | @$(ENGINE) manifest annotate $(REPO):$(TAG) \ | |
| 73 | - | $(REPO):$(TAG)-arm64 --arch arm64 --variant v8 | |
| 74 | - | ||
| 75 | - | @echo "Inspecting manifest..." | |
| 76 | - | @$(ENGINE) manifest inspect $(REPO):$(TAG) | |
| 77 | - | ||
| 78 | - | @echo "Pushing multi-arch manifest..." | |
| 79 | - | @$(ENGINE) manifest push --all $(REPO):$(TAG) | |
| 80 | - | ||
| 81 | - | @echo "Script completed successfully." | |
| 82 | - | ||
| 83 | - | # Clean up | |
| 84 | - | clean: | |
| 85 | - | @echo "Cleaning up..." | |
| 86 | - | @rm -rf $(DIST_DIR) | |
| 87 | - | @$(ENGINE) system prune -f | |
| 88 | - | @echo "Cleanup completed." | |
D README.md +0 -81
| @@ -1,81 +0,0 @@ | |||
| 1 | - | <h1 align="center"> | |
| 2 | - | <br> | |
| 3 | - | <img src="https://github.com/bnema/gordon/blob/main/internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png?raw=true" alt="Gordon" width="192"> | |
| 4 | - | <br> | |
| 5 | - | Gordon | |
| 6 | - | <br> | |
| 7 | - | </h1> | |
| 8 | - | ||
| 9 | - | <h4 align="center">Minimalist self-hosted containerized app deployment tool.</h4> | |
| 10 | - | ||
| 11 | - | ||
| 12 | - | Gordon is a tool that aims to simplify the deployment of your containerized web applications. It automates the process of integrating your locally built image into your self-hosted setup without the need of a container registry. | |
| 13 | - | ||
| 14 | - | ## **Key Features** | |
| 15 | - | ||
| 16 | - | - **Ease of use**: | |
| 17 | - | - Push your container images effortlessly using the **`gordon push`** command. A container creation URL is generated upon successful push. | |
| 18 | - | ||
| 19 | - |  | |
| 20 | - | ||
| 21 | - | - Deploy your web applications quickly with **`gordon deploy`**. | |
| 22 | - | ||
| 23 | - |  | |
| 24 | - | ||
| 25 | - | - **Self-Hosted**: Operates within your existing self-hosted environment without interfering with your setup. | |
| 26 | - | - **Self-Updated**: The client can update itself to the latest version with `gordon update`. The backend is updated by pulling the latest image from ghcr.io. | |
| 27 | - | - **Minimal WebUI**: Provides a [simple web UI](assets/screenshots/webui-image-deploy.png) to manage your deployments, domains, exposed ports. Using a very minimal stack (sqlite3, Go templates, HTMX and Tailwind CSS). | |
| 28 | - | - **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client. | |
| 29 | - | - **Automated Routing**: Gordon automatically routes your application to your desired domain or subdomain, with no manual configuration needed. | |
| 30 | - | - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities. | |
| 31 | - | ||
| 32 | - | ## **Why ?** | |
| 33 | - | ||
| 34 | - | I needed a simple tool that would automate the process of quickly spin up my web apps and preferably not on someone else's computer. And since I wanted to learn Go, I decided to build it myself. | |
| 35 | - | ||
| 36 | - | ## **Installation / Getting Started / Usage** | |
| 37 | - | ||
| 38 | - | For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/) | |
| 39 | - | ||
| 40 | - | - [Setting up Gordon's Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Backend) | |
| 41 | - | - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client) | |
| 42 | - | - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment) | |
| 43 | - | ||
| 44 | - | ||
| 45 | - | ## **Development Status & Disclaimer** | |
| 46 | - | ||
| 47 | - | 🛠️ **Note**: Gordon is under heavy development and is absolutely not ready for production. Thank you in advance for taking the time to test it and report any issues you may encounter. | |
| 48 | - | ||
| 49 | - | I also want to emphasize that I am far from being a Go expert yet. Hence, if you notice any poor practices, I welcome your feedback—respectfully, of course. It's a valuable part of the learning process! | |
| 50 | - | ||
| 51 | - | ## **Roadmap beyond 0.1** | |
| 52 | - | ||
| 53 | - | - [x] Improved error handling | |
| 54 | - | - [x] Improved logging | |
| 55 | - | - [x] Refined CLI to backend authentication process (utilizing GitHub OAuth Device flow) | |
| 56 | - | - [x] `push` command | |
| 57 | - | - [x] `version` command | |
| 58 | - | - [x] `update` command | |
| 59 | - | - [x] `deploy` command | |
| 60 | - | - [x] Survey(s) on deploy command (stop/remove if already exists) | |
| 61 | - | - [ ] Improved web UI | |
| 62 | - | - [ ] Quick database deploy templates (libsql, postgresql) | |
| 63 | - | - [ ] GitHub Repository Integration: | |
| 64 | - | - Repository selection and branch/tag filtering | |
| 65 | - | - Dockerfile detection and validation | |
| 66 | - | - Build configuration UI | |
| 67 | - | - Build logs streaming | |
| 68 | - | - Automated deployment triggers | |
| 69 | - | - Build status notifications | |
| 70 | - | ||
| 71 | - | Have suggestions? Feel free to open an issue! | |
| 72 | - | ||
| 73 | - | ## **License** | |
| 74 | - | ||
| 75 | - | Gordon is licensed under the GPL-3.0 license. Please see the LICENSE file for more details. | |
| 76 | - | ||
| 77 | - | ## Features | |
| 78 | - | ||
| 79 | - | - **Container Management**: Connect to your Docker daemon to manage your containers, whether they're running locally or remotely. | |
| 80 | - | - **Image Management**: Download, delete, and manage your container images with ease. | |
| 81 | - | - **Built-in Reverse Proxy**: Gordon includes a built-in reverse proxy that automatically handles domain routing, Let's Encrypt TLS certificates, and HTTP to HTTPS redirects. | |
A cmd/generate.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/cobra" | |
| 8 | + | "gordon/internal/middleware" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | var generateCmd = &cobra.Command{ | |
| 12 | + | Use: "generate", | |
| 13 | + | Short: "Generate authentication tokens and secrets", | |
| 14 | + | Long: `Generate JWT tokens, API keys, and JWT secrets for Gordon authentication`, | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | var generateSecretCmd = &cobra.Command{ | |
| 18 | + | Use: "jwt-secret", | |
| 19 | + | Short: "Generate a secure JWT secret", | |
| 20 | + | Long: `Generate a cryptographically secure JWT secret for signing tokens`, | |
| 21 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 22 | + | secret, err := middleware.GenerateJWTSecret() | |
| 23 | + | if err != nil { | |
| 24 | + | fmt.Printf("Error generating JWT secret: %v\n", err) | |
| 25 | + | return | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | fmt.Printf("Generated JWT Secret:\n") | |
| 29 | + | fmt.Printf("%s\n\n", secret) | |
| 30 | + | fmt.Printf("Add this to your gordon.toml configuration:\n") | |
| 31 | + | fmt.Printf("[auth]\n") | |
| 32 | + | fmt.Printf("enabled = true\n") | |
| 33 | + | fmt.Printf("method = \"jwt\"\n") | |
| 34 | + | fmt.Printf("jwt_secret = \"%s\"\n", secret) | |
| 35 | + | }, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var generateTokenCmd = &cobra.Command{ | |
| 39 | + | Use: "jwt-token", | |
| 40 | + | Short: "Generate a JWT token", | |
| 41 | + | Long: `Generate a JWT token for accessing Gordon's management API`, | |
| 42 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 43 | + | username, _ := cmd.Flags().GetString("username") | |
| 44 | + | roles, _ := cmd.Flags().GetStringSlice("roles") | |
| 45 | + | jwtSecret, _ := cmd.Flags().GetString("secret") | |
| 46 | + | validFor, _ := cmd.Flags().GetDuration("valid-for") | |
| 47 | + | ||
| 48 | + | if username == "" { | |
| 49 | + | fmt.Println("Error: username is required") | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | if jwtSecret == "" { | |
| 54 | + | fmt.Println("Error: JWT secret is required (use --secret flag or generate one with 'gordon generate jwt-secret')") | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | token, err := middleware.GenerateJWTToken(username, roles, jwtSecret, validFor) | |
| 59 | + | if err != nil { | |
| 60 | + | fmt.Printf("Error generating JWT token: %v\n", err) | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fmt.Printf("Generated JWT Token for user '%s':\n", username) | |
| 65 | + | fmt.Printf("%s\n\n", token) | |
| 66 | + | fmt.Printf("Token expires in: %v\n", validFor) | |
| 67 | + | fmt.Printf("Roles: %v\n\n", roles) | |
| 68 | + | fmt.Printf("Use this token in API requests:\n") | |
| 69 | + | fmt.Printf("curl -H \"Authorization: Bearer %s\" http://localhost:8080/api/status\n", token) | |
| 70 | + | }, | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | var generateAPIKeyCmd = &cobra.Command{ | |
| 74 | + | Use: "api-key", | |
| 75 | + | Short: "Generate a secure API key", | |
| 76 | + | Long: `Generate a cryptographically secure API key for Gordon authentication`, | |
| 77 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 78 | + | apiKey, err := middleware.GenerateSecureAPIKey() | |
| 79 | + | if err != nil { | |
| 80 | + | fmt.Printf("Error generating API key: %v\n", err) | |
| 81 | + | return | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fmt.Printf("Generated API Key:\n") | |
| 85 | + | fmt.Printf("%s\n\n", apiKey) | |
| 86 | + | fmt.Printf("Add this to your gordon.toml configuration:\n") | |
| 87 | + | fmt.Printf("[auth]\n") | |
| 88 | + | fmt.Printf("enabled = true\n") | |
| 89 | + | fmt.Printf("method = \"api_key\"\n") | |
| 90 | + | fmt.Printf("api_key = \"%s\"\n\n", apiKey) | |
| 91 | + | fmt.Printf("Use this API key in requests:\n") | |
| 92 | + | fmt.Printf("curl -H \"X-API-Key: %s\" http://localhost:8080/api/status\n", apiKey) | |
| 93 | + | fmt.Printf("# OR\n") | |
| 94 | + | fmt.Printf("curl \"http://localhost:8080/api/status?api_key=%s\"\n", apiKey) | |
| 95 | + | }, | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func init() { | |
| 99 | + | rootCmd.AddCommand(generateCmd) | |
| 100 | + | generateCmd.AddCommand(generateSecretCmd) | |
| 101 | + | generateCmd.AddCommand(generateTokenCmd) | |
| 102 | + | generateCmd.AddCommand(generateAPIKeyCmd) | |
| 103 | + | ||
| 104 | + | // JWT token flags | |
| 105 | + | generateTokenCmd.Flags().StringP("username", "u", "", "Username for the token (required)") | |
| 106 | + | generateTokenCmd.Flags().StringSliceP("roles", "r", []string{"admin"}, "Roles for the token") | |
| 107 | + | generateTokenCmd.Flags().StringP("secret", "s", "", "JWT secret for signing (required)") | |
| 108 | + | generateTokenCmd.Flags().DurationP("valid-for", "t", 24*time.Hour, "Token validity duration") | |
| 109 | + | ||
| 110 | + | generateTokenCmd.MarkFlagRequired("username") | |
| 111 | + | generateTokenCmd.MarkFlagRequired("secret") | |
| 112 | + | }⏎̸ | |
D cmd/gordon.go +0 -68
| @@ -1,68 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli" | |
| 8 | - | "github.com/bnema/gordon/internal/cli/cmd" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | "github.com/bnema/gordon/internal/server" | |
| 11 | - | "github.com/spf13/cobra" | |
| 12 | - | ||
| 13 | - | _ "github.com/joho/godotenv/autoload" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | var ( | |
| 17 | - | rootCmd = &cobra.Command{Use: "gordon"} | |
| 18 | - | proxyURL = "https://gordon-proxy.bamen.dev" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | func InitializeCommands(client *cli.App, server *server.App) { | |
| 22 | - | rootCmd.AddCommand(cmd.NewRootCommand(client)) | |
| 23 | - | rootCmd.AddCommand(cmd.NewServeCommand(server)) | |
| 24 | - | rootCmd.AddCommand(cmd.NewPingCommand(client)) | |
| 25 | - | rootCmd.AddCommand(cmd.NewDeployCommand(client)) | |
| 26 | - | rootCmd.AddCommand(cmd.NewUpdateCommand(client)) | |
| 27 | - | rootCmd.AddCommand(cmd.NewPushCommand(client)) | |
| 28 | - | rootCmd.AddCommand(cmd.NewVersionCommand(client)) | |
| 29 | - | rootCmd.AddCommand(cmd.NewProxyCommand(server)) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func Execute(client *cli.App, server *server.App) { | |
| 33 | - | InitializeCommands(client, server) | |
| 34 | - | cobra.CheckErr(rootCmd.Execute()) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func ExecuteCLI(build, commit, date string) { | |
| 38 | - | versionInfo := common.GetVersionInfo(build, commit, date, proxyURL) | |
| 39 | - | ||
| 40 | - | buildInfo := &common.BuildConfig{ | |
| 41 | - | BuildVersion: build, | |
| 42 | - | BuildCommit: commit, | |
| 43 | - | BuildDate: date, | |
| 44 | - | ProxyURL: proxyURL, | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Load global configuration once before initializing the apps | |
| 48 | - | _, err := common.GetGlobalConfig(buildInfo) | |
| 49 | - | if err != nil { | |
| 50 | - | fmt.Println("Error initializing global configuration:", err) | |
| 51 | - | return | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Now initialize the client and server apps, which will use the already loaded config | |
| 55 | - | a, err := cli.NewClientApp(buildInfo) | |
| 56 | - | if err != nil { | |
| 57 | - | fmt.Println("Error initializing app:", err) | |
| 58 | - | } | |
| 59 | - | s, err := server.NewServerApp(buildInfo) | |
| 60 | - | if err != nil { | |
| 61 | - | fmt.Println("Error initializing app:", err) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Start periodic version checking in the background | |
| 65 | - | go common.CheckVersionPeriodically(versionInfo, 3*time.Hour) | |
| 66 | - | ||
| 67 | - | Execute(a, s) | |
| 68 | - | } | |
A cmd/root.go +44 -0
| @@ -0,0 +1,44 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "os" | |
| 6 | + | ||
| 7 | + | "github.com/rs/zerolog/log" | |
| 8 | + | "github.com/spf13/cobra" | |
| 9 | + | "github.com/spf13/viper" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | var cfgFile string | |
| 13 | + | ||
| 14 | + | var rootCmd = &cobra.Command{ | |
| 15 | + | Use: "gordon", | |
| 16 | + | Short: "Gordon - Container Registry & Deployment System", | |
| 17 | + | Long: `Gordon is a single-binary application that combines a container registry | |
| 18 | + | with an intelligent reverse proxy and automated deployment system.`, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func Execute() error { | |
| 22 | + | return rootCmd.Execute() | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func init() { | |
| 26 | + | cobra.OnInitialize(initConfig) | |
| 27 | + | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is ./gordon.toml)") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func initConfig() { | |
| 31 | + | if cfgFile != "" { | |
| 32 | + | viper.SetConfigFile(cfgFile) | |
| 33 | + | } else { | |
| 34 | + | log.Fatal().Msg("config file not found") | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | viper.AutomaticEnv() | |
| 38 | + | ||
| 39 | + | if err := viper.ReadInConfig(); err == nil { | |
| 40 | + | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) | |
| 41 | + | } else { | |
| 42 | + | fmt.Fprintf(os.Stderr, "Error reading config file: %v\n", err) | |
| 43 | + | } | |
| 44 | + | } | |
A cmd/start.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "os/signal" | |
| 7 | + | "sync" | |
| 8 | + | "syscall" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/rs/zerolog" | |
| 12 | + | "github.com/rs/zerolog/log" | |
| 13 | + | "github.com/spf13/cobra" | |
| 14 | + | "gordon/internal/config" | |
| 15 | + | "gordon/internal/container" | |
| 16 | + | "gordon/internal/proxy" | |
| 17 | + | "gordon/internal/registry" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | var startCmd = &cobra.Command{ | |
| 21 | + | Use: "start", | |
| 22 | + | Short: "Start the Gordon server", | |
| 23 | + | Long: `Start the container registry and reverse proxy server`, | |
| 24 | + | Run: runStart, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func init() { | |
| 28 | + | rootCmd.AddCommand(startCmd) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func runStart(cmd *cobra.Command, args []string) { | |
| 32 | + | // Configure zerolog | |
| 33 | + | zerolog.TimeFieldFormat = zerolog.TimeFormatUnix | |
| 34 | + | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) | |
| 35 | + | ||
| 36 | + | cfg, err := config.Load() | |
| 37 | + | if err != nil { | |
| 38 | + | log.Fatal().Err(err).Msg("Failed to load config") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | log.Info().Msg("Starting Gordon server...") | |
| 42 | + | log.Info().Int("registry_port", cfg.Server.RegistryPort).Msg("Registry server") | |
| 43 | + | log.Info().Int("proxy_port", cfg.Server.Port).Msg("Proxy server") | |
| 44 | + | log.Info().Str("runtime", cfg.Server.Runtime).Msg("Container runtime") | |
| 45 | + | ||
| 46 | + | // Initialize container manager | |
| 47 | + | manager, err := container.NewManager(cfg) | |
| 48 | + | if err != nil { | |
| 49 | + | log.Fatal().Err(err).Msg("Failed to initialize container manager") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // Sync existing containers | |
| 53 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 54 | + | if err := manager.SyncContainers(ctx); err != nil { | |
| 55 | + | log.Warn().Err(err).Msg("Failed to sync existing containers") | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | var wg sync.WaitGroup | |
| 59 | + | ||
| 60 | + | // Start registry server | |
| 61 | + | registryServer, err := registry.NewServer(cfg) | |
| 62 | + | if err != nil { | |
| 63 | + | log.Fatal().Err(err).Msg("Failed to initialize registry server") | |
| 64 | + | } | |
| 65 | + | wg.Add(1) | |
| 66 | + | go func() { | |
| 67 | + | defer wg.Done() | |
| 68 | + | if err := registryServer.Start(ctx); err != nil { | |
| 69 | + | log.Error().Err(err).Msg("Registry server error") | |
| 70 | + | } | |
| 71 | + | }() | |
| 72 | + | ||
| 73 | + | // Start proxy server | |
| 74 | + | proxyServer := proxy.NewServer(cfg, manager) | |
| 75 | + | wg.Add(1) | |
| 76 | + | go func() { | |
| 77 | + | defer wg.Done() | |
| 78 | + | if err := proxyServer.Start(ctx); err != nil { | |
| 79 | + | log.Error().Err(err).Msg("Proxy server error") | |
| 80 | + | } | |
| 81 | + | }() | |
| 82 | + | ||
| 83 | + | // Wait for interrupt signal | |
| 84 | + | sigChan := make(chan os.Signal, 1) | |
| 85 | + | signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) | |
| 86 | + | <-sigChan | |
| 87 | + | ||
| 88 | + | log.Info().Msg("Shutting down servers...") | |
| 89 | + | cancel() | |
| 90 | + | ||
| 91 | + | // Give servers time to shutdown gracefully | |
| 92 | + | done := make(chan struct{}) | |
| 93 | + | go func() { | |
| 94 | + | wg.Wait() | |
| 95 | + | close(done) | |
| 96 | + | }() | |
| 97 | + | ||
| 98 | + | select { | |
| 99 | + | case <-done: | |
| 100 | + | log.Info().Msg("Servers stopped gracefully") | |
| 101 | + | case <-time.After(10 * time.Second): | |
| 102 | + | log.Warn().Msg("Force shutdown after timeout") | |
| 103 | + | } | |
| 104 | + | }⏎̸ | |
D docker-compose.yml.example +0 -119
| @@ -1,119 +0,0 @@ | |||
| 1 | - | version: '3' | |
| 2 | - | ||
| 3 | - | services: | |
| 4 | - | gordon: | |
| 5 | - | image: ghcr.io/bnema/gordon:latest | |
| 6 | - | container_name: gordon | |
| 7 | - | volumes: | |
| 8 | - | - ./data:/data | |
| 9 | - | - ./data/config.yml:/config.yml # Mount config.yml directly at the root | |
| 10 | - | - ./data/blacklist.yml:/blacklist.yml # Mount blacklist.yml | |
| 11 | - | - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |
| 12 | - | # For Docker: | |
| 13 | - | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | |
| 14 | - | # For Podman (uncomment and comment the Docker line above): | |
| 15 | - | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock # For Podman | |
| 16 | - | ports: | |
| 17 | - | - "8080:8080" # HTTP API (Gordon main server) | |
| 18 | - | - "443:443" # HTTPS for reverse proxy | |
| 19 | - | - "80:80" # HTTP for reverse proxy | |
| 20 | - | environment: | |
| 21 | - | # General Configuration | |
| 22 | - | - GORDON_STORAGE_DIR=/data | |
| 23 | - | - GORDON_TOKEN=your_secure_token | |
| 24 | - | ||
| 25 | - | # HTTP Configuration | |
| 26 | - | - GORDON_HTTP_PORT=8080 # IMPORTANT: API port must be different from proxy HTTP port | |
| 27 | - | - GORDON_HTTP_DOMAIN=example.com | |
| 28 | - | - GORDON_HTTP_SUBDOMAIN=gordon | |
| 29 | - | - GORDON_HTTP_HTTPS=true | |
| 30 | - | ||
| 31 | - | # Container Engine Configuration - Docker | |
| 32 | - | - GORDON_CONTAINER_SOCK=/var/run/docker.sock | |
| 33 | - | - GORDON_CONTAINER_NETWORK=gordon | |
| 34 | - | ||
| 35 | - | # Container Engine Configuration - Podman (Method 1: Auto-detection) | |
| 36 | - | # Just specify the Podman socket path and Gordon will auto-detect Podman: | |
| 37 | - | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |
| 38 | - | ||
| 39 | - | # Container Engine Configuration - Podman (Method 2: Explicit Configuration) | |
| 40 | - | # - GORDON_CONTAINER_PODMAN=true | |
| 41 | - | # - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock | |
| 42 | - | # - GORDON_CONTAINER_SOCK=/run/podman/podman.sock | |
| 43 | - | ||
| 44 | - | # Reverse Proxy Configuration | |
| 45 | - | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 46 | - | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 47 | - | - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container) | |
| 48 | - | - GORDON_PROXY_CERT_DIR=/certs | |
| 49 | - | - GORDON_PROXY_EMAIL=admin@example.com | |
| 50 | - | - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable) | |
| 51 | - | - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable) | |
| 52 | - | ||
| 53 | - | # Session Secret for security | |
| 54 | - | - SESSION_SECRET=replace_with_secure_random_string | |
| 55 | - | ||
| 56 | - | # Runtime Environment | |
| 57 | - | - RUN_ENV=prod | |
| 58 | - | restart: always | |
| 59 | - | networks: | |
| 60 | - | - gordon | |
| 61 | - | ||
| 62 | - | networks: | |
| 63 | - | gordon: | |
| 64 | - | external: false | |
| 65 | - | ||
| 66 | - | # ------------------------------------- | |
| 67 | - | # Example for Podman Users (podman-compose) | |
| 68 | - | # ------------------------------------- | |
| 69 | - | # version: '3' | |
| 70 | - | # | |
| 71 | - | # services: | |
| 72 | - | # gordon: | |
| 73 | - | # image: ghcr.io/bnema/gordon:latest | |
| 74 | - | # container_name: gordon | |
| 75 | - | # volumes: | |
| 76 | - | # - ./data:/data | |
| 77 | - | # - ./data/config.yml:/config.yml | |
| 78 | - | # - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |
| 79 | - | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock | |
| 80 | - | # ports: | |
| 81 | - | # - "8080:8080" # HTTP API (Gordon main server) | |
| 82 | - | # - "443:443" # HTTPS for reverse proxy | |
| 83 | - | # - "80:80" # HTTP for reverse proxy | |
| 84 | - | # environment: | |
| 85 | - | # # General Configuration | |
| 86 | - | # - GORDON_STORAGE_DIR=/data | |
| 87 | - | # - GORDON_TOKEN=your_secure_token | |
| 88 | - | # | |
| 89 | - | # # HTTP Configuration | |
| 90 | - | # - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port | |
| 91 | - | # - GORDON_HTTP_DOMAIN=example.com | |
| 92 | - | # - GORDON_HTTP_SUBDOMAIN=gordon | |
| 93 | - | # - GORDON_HTTP_HTTPS=true | |
| 94 | - | # | |
| 95 | - | # # Container Engine Configuration | |
| 96 | - | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |
| 97 | - | # - GORDON_CONTAINER_NETWORK=gordon | |
| 98 | - | # | |
| 99 | - | # # Reverse Proxy Configuration | |
| 100 | - | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 101 | - | # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 102 | - | # - GORDON_PROXY_ENABLED=true # Set to false to disable the reverse proxy (Note: proxy is automatically disabled when not running in a container) | |
| 103 | - | # - GORDON_PROXY_CERT_DIR=/certs | |
| 104 | - | # - GORDON_PROXY_EMAIL=admin@example.com | |
| 105 | - | # - GORDON_PROXY_ENABLE_HTTP_LOGS=true # Enable HTTP request logging (set to false to disable) | |
| 106 | - | # - GORDON_PROXY_ENABLE_RATE_LIMIT=false # Enable rate limiting middleware (set to true to enable) | |
| 107 | - | # | |
| 108 | - | # # Session Secret for security | |
| 109 | - | # - SESSION_SECRET=replace_with_secure_random_string | |
| 110 | - | # | |
| 111 | - | # # Runtime Environment | |
| 112 | - | # - RUN_ENV=prod | |
| 113 | - | # restart: always | |
| 114 | - | # networks: | |
| 115 | - | # - gordon | |
| 116 | - | # | |
| 117 | - | # networks: | |
| 118 | - | # gordon: | |
| 119 | - | # external: false ⏎̸ | |
D docs/CONTAINER_CONFIG.md +0 -232
| @@ -1,232 +0,0 @@ | |||
| 1 | - | # Configuring Gordon in a Container | |
| 2 | - | ||
| 3 | - | This document explains how to configure Gordon when running inside a Docker container using environment variables. | |
| 4 | - | ||
| 5 | - | ## Container Detection | |
| 6 | - | ||
| 7 | - | Gordon automatically detects when it's running inside a container by checking for the presence of `/.iscontainer` or `/.dockerenv` files. When Gordon is running in a container, it: | |
| 8 | - | ||
| 9 | - | 1. Uses the root directory (`/`) as its configuration directory | |
| 10 | - | 2. Looks for a directly mounted `/config.yml` file | |
| 11 | - | 3. Reads configuration from environment variables provided through Docker or docker-compose | |
| 12 | - | ||
| 13 | - | ## Using docker-compose for Configuration | |
| 14 | - | ||
| 15 | - | The recommended way to configure Gordon in a container is through a `docker-compose.yml` file. Here's an example: | |
| 16 | - | ||
| 17 | - | ```yaml | |
| 18 | - | version: '3' | |
| 19 | - | ||
| 20 | - | services: | |
| 21 | - | gordon: | |
| 22 | - | image: ghcr.io/bnema/gordon:latest | |
| 23 | - | container_name: gordon | |
| 24 | - | volumes: | |
| 25 | - | - ./data:/data | |
| 26 | - | - ./data/config.yml:/config.yml # Mount config.yml directly at the root | |
| 27 | - | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | |
| 28 | - | ports: | |
| 29 | - | - "8080:8080" # HTTP API (Gordon main server) | |
| 30 | - | - "443:443" # HTTPS for reverse proxy | |
| 31 | - | - "80:80" # HTTP for reverse proxy | |
| 32 | - | environment: | |
| 33 | - | # General Configuration | |
| 34 | - | - GORDON_STORAGE_DIR=/data | |
| 35 | - | - GORDON_TOKEN=your_secure_token | |
| 36 | - | ||
| 37 | - | # HTTP Configuration | |
| 38 | - | - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port | |
| 39 | - | - GORDON_HTTP_DOMAIN=example.com | |
| 40 | - | - GORDON_HTTP_SUBDOMAIN=gordon | |
| 41 | - | - GORDON_HTTP_HTTPS=true | |
| 42 | - | ||
| 43 | - | # Container Engine Configuration | |
| 44 | - | - GORDON_CONTAINER_SOCK=/var/run/docker.sock | |
| 45 | - | - GORDON_CONTAINER_NETWORK=gordon | |
| 46 | - | ||
| 47 | - | # Reverse Proxy Configuration | |
| 48 | - | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 49 | - | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 50 | - | - GORDON_PROXY_CERT_DIR=/certs | |
| 51 | - | - GORDON_PROXY_EMAIL=admin@example.com | |
| 52 | - | ||
| 53 | - | # Session Secret for security | |
| 54 | - | - SESSION_SECRET=replace_with_secure_random_string | |
| 55 | - | ||
| 56 | - | # Runtime Environment | |
| 57 | - | - RUN_ENV=prod | |
| 58 | - | restart: always | |
| 59 | - | networks: | |
| 60 | - | - gordon | |
| 61 | - | ||
| 62 | - | networks: | |
| 63 | - | gordon: | |
| 64 | - | external: false | |
| 65 | - | ``` | |
| 66 | - | ||
| 67 | - | ## Port Configuration | |
| 68 | - | ||
| 69 | - | Gordon runs two HTTP servers: | |
| 70 | - | ||
| 71 | - | 1. **API Server**: Defaults to port 8080 in containers (configurable with `GORDON_HTTP_PORT`) | |
| 72 | - | 2. **Reverse Proxy**: Uses ports 80 (HTTP) and 443 (HTTPS) for the reverse proxy (configurable with `GORDON_PROXY_HTTP_PORT` and `GORDON_PROXY_PORT`) | |
| 73 | - | ||
| 74 | - | ⚠️ **Important**: The API server port (`GORDON_HTTP_PORT`) must be different from the reverse proxy HTTP port (`GORDON_PROXY_HTTP_PORT`) to avoid conflicts. By default, they are set to 8080 and 80 respectively. | |
| 75 | - | ||
| 76 | - | ## Using with Podman | |
| 77 | - | ||
| 78 | - | Gordon now automatically detects Podman based on the socket path. You can use Podman in one of two ways: | |
| 79 | - | ||
| 80 | - | ### Method 1: Implicit Podman Detection (Recommended) | |
| 81 | - | ||
| 82 | - | Simply mount the Podman socket and set the container socket path: | |
| 83 | - | ||
| 84 | - | ```yaml | |
| 85 | - | volumes: | |
| 86 | - | - /run/user/1000/podman/podman.sock:/var/run/docker.sock | |
| 87 | - | environment: | |
| 88 | - | - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |
| 89 | - | ``` | |
| 90 | - | ||
| 91 | - | Gordon will automatically detect that this is a Podman socket and enable Podman support without requiring additional configuration. | |
| 92 | - | ||
| 93 | - | ### Method 2: Explicit Podman Configuration | |
| 94 | - | ||
| 95 | - | Alternatively, you can explicitly enable Podman mode with: | |
| 96 | - | ||
| 97 | - | ```yaml | |
| 98 | - | environment: | |
| 99 | - | - GORDON_CONTAINER_PODMAN=true | |
| 100 | - | - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock | |
| 101 | - | - GORDON_CONTAINER_SOCK=/run/podman/podman.sock | |
| 102 | - | ``` | |
| 103 | - | ||
| 104 | - | ### Socket Path Auto-Detection | |
| 105 | - | ||
| 106 | - | Gordon will automatically try different common socket paths if the specified path doesn't work: | |
| 107 | - | - `/run/podman/podman.sock` (Standard path) | |
| 108 | - | - `/var/run/podman/podman.sock` (Alternative standard path) | |
| 109 | - | - `$HOME/.local/podman/podman.sock` (Some distros) | |
| 110 | - | - `/run/user/1000/podman/podman.sock` (User-specific socket) | |
| 111 | - | - `/var/run/docker.sock` (Docker fallback) | |
| 112 | - | ||
| 113 | - | ### Common Podman Issues | |
| 114 | - | ||
| 115 | - | If you encounter errors like `no such file or directory` when accessing the Podman socket: | |
| 116 | - | ||
| 117 | - | 1. **Check socket permissions**: Make sure your user has permission to access the Podman socket | |
| 118 | - | 2. **Verify socket exists**: Run `ls -la /run/podman/podman.sock` to verify the socket exists | |
| 119 | - | 3. **Enable Podman socket service**: Run `systemctl --user enable --now podman.socket` | |
| 120 | - | ||
| 121 | - | For rootless Podman, the socket might be at `/run/user/$(id -u)/podman/podman.sock`. Check with: | |
| 122 | - | ```bash | |
| 123 | - | echo "/run/user/$(id -u)/podman/podman.sock" | |
| 124 | - | ``` | |
| 125 | - | ||
| 126 | - | ## Config File Persistence | |
| 127 | - | ||
| 128 | - | To ensure your configuration persists between container restarts, mount the `config.yml` file directly: | |
| 129 | - | ||
| 130 | - | ```yaml | |
| 131 | - | volumes: | |
| 132 | - | - ./data/config.yml:/config.yml | |
| 133 | - | ``` | |
| 134 | - | ||
| 135 | - | This is important as Gordon looks for a file at `/config.yml` in the container. | |
| 136 | - | ||
| 137 | - | ## Available Environment Variables | |
| 138 | - | ||
| 139 | - | Gordon supports the following environment variables for configuration: | |
| 140 | - | ||
| 141 | - | ### General Configuration | |
| 142 | - | ||
| 143 | - | | Environment Variable | Description | Default | | |
| 144 | - | |---------------------|-------------|---------| | |
| 145 | - | | `GORDON_STORAGE_DIR` | Directory for Gordon to store data | `~/.gordon` | | |
| 146 | - | | `GORDON_TOKEN` | Authentication token | Empty | | |
| 147 | - | ||
| 148 | - | ### HTTP Configuration | |
| 149 | - | ||
| 150 | - | | Environment Variable | Description | Default | | |
| 151 | - | |---------------------|-------------|---------| | |
| 152 | - | | `GORDON_HTTP_PORT` | HTTP port for Gordon API | `8080` | | |
| 153 | - | | `GORDON_HTTP_DOMAIN` | Domain name | `localhost` | | |
| 154 | - | | `GORDON_HTTP_SUBDOMAIN` | Subdomain | Empty | | |
| 155 | - | | `GORDON_HTTP_BACKEND_URL` | Backend URL | Empty | | |
| 156 | - | | `GORDON_HTTP_HTTPS` | Enable HTTPS | `true` | | |
| 157 | - | ||
| 158 | - | ### Admin Configuration | |
| 159 | - | ||
| 160 | - | | Environment Variable | Description | Default | | |
| 161 | - | |---------------------|-------------|---------| | |
| 162 | - | | `GORDON_ADMIN_PATH` | Admin panel path | `/admin` | | |
| 163 | - | ||
| 164 | - | ### Container Engine Configuration | |
| 165 | - | ||
| 166 | - | | Environment Variable | Description | Default | | |
| 167 | - | |---------------------|-------------|---------| | |
| 168 | - | | `GORDON_CONTAINER_SOCK` | Docker socket path | `/var/run/docker.sock` | | |
| 169 | - | | `GORDON_CONTAINER_PODMAN_SOCK` | Podman socket path | `/run/podman/podman.sock` | | |
| 170 | - | | `GORDON_CONTAINER_PODMAN` | Use Podman instead of Docker | `true` | | |
| 171 | - | | `GORDON_CONTAINER_NETWORK` | Container network | `gordon` | | |
| 172 | - | ||
| 173 | - | ### Reverse Proxy Configuration | |
| 174 | - | ||
| 175 | - | | Environment Variable | Description | Default | | |
| 176 | - | |---------------------|-------------|---------| | |
| 177 | - | | `GORDON_PROXY_PORT` | HTTPS port for reverse proxy | `443` | | |
| 178 | - | | `GORDON_PROXY_HTTP_PORT` | HTTP port for reverse proxy | `80` | | |
| 179 | - | | `GORDON_PROXY_CERT_DIR` | Directory for Let's Encrypt certificates | `/certs` | | |
| 180 | - | | `GORDON_PROXY_AUTO_RENEW` | Auto-renew certificates | `true` | | |
| 181 | - | | `GORDON_PROXY_RENEW_BEFORE` | Days before expiry to renew certificates | `30` | | |
| 182 | - | | `GORDON_PROXY_LETSENCRYPT_MODE` | Let's Encrypt mode (`staging` or `production`) | `staging` | | |
| 183 | - | | `GORDON_PROXY_EMAIL` | Email for Let's Encrypt | Empty | | |
| 184 | - | | `GORDON_PROXY_CACHE_SIZE` | Certificate cache size | `1000` | | |
| 185 | - | | `GORDON_PROXY_GRACE_PERIOD` | Shutdown grace period in seconds | `30` | | |
| 186 | - | | `GORDON_PROXY_ENABLE_LOGS` | Controls whether HTTP request logs are enabled for the proxy | `true` | | |
| 187 | - | ||
| 188 | - | ### Other Configuration | |
| 189 | - | ||
| 190 | - | | Environment Variable | Description | Default | | |
| 191 | - | |---------------------|-------------|---------| | |
| 192 | - | | `SESSION_SECRET` | Secret for session encryption | Required | | |
| 193 | - | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | |
| 194 | - | ||
| 195 | - | ## Default Values for Configuration | |
| 196 | - | ||
| 197 | - | Gordon now automatically applies default values for critical configuration settings that are missing or set to zero values in your config.yml. This ensures that your reverse proxy and Let's Encrypt integration work properly without requiring you to manually specify every field. | |
| 198 | - | ||
| 199 | - | ### Important Default Values Applied | |
| 200 | - | ||
| 201 | - | If not specified in your config.yml, Gordon will automatically apply these recommended values: | |
| 202 | - | ||
| 203 | - | | Setting | Default Value | Why It's Important | | |
| 204 | - | |---------|--------------|---------------------| | |
| 205 | - | | `ReverseProxy.RenewBefore` | 30 days | Ensures certificates are renewed well before expiry | | |
| 206 | - | | `ReverseProxy.CacheSize` | 1000 entries | Optimizes performance when serving multiple domains | | |
| 207 | - | | `ReverseProxy.GracePeriod` | 30 seconds | Allows for graceful server shutdowns | | |
| 208 | - | | `ReverseProxy.AutoRenew` | true | Prevents certificate expiration issues | | |
| 209 | - | | `ReverseProxy.EnableLogs` | true | Controls whether HTTP request logs are enabled for the proxy | | |
| 210 | - | | `ReverseProxy.LetsEncryptMode` | staging | Safe default for testing; change to "production" for real certificates | | |
| 211 | - | ||
| 212 | - | This behavior prevents issues that could occur with zero values like: | |
| 213 | - | - Certificates only being renewed at the last moment (or not at all) | |
| 214 | - | - Poor performance due to disabled caching | |
| 215 | - | - Abrupt connection termination during shutdowns | |
| 216 | - | ||
| 217 | - | These defaults are applied when: | |
| 218 | - | 1. A new configuration file is created | |
| 219 | - | 2. An existing configuration file is loaded but is missing these values | |
| 220 | - | 3. The values are explicitly set to zero or empty in your configuration | |
| 221 | - | ||
| 222 | - | You can always override these defaults by explicitly setting values in your config.yml or through environment variables. | |
| 223 | - | ||
| 224 | - | ## Secrets Management | |
| 225 | - | ||
| 226 | - | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | |
| 227 | - | ||
| 228 | - | ## Persistence | |
| 229 | - | ||
| 230 | - | To ensure your data persists between container restarts, mount a volume to the `/data` directory or the path specified in `GORDON_STORAGE_DIR`: | |
| 231 | - | ||
| 232 | - | ```⏎̸ | |
M go.mod +37 -254
| @@ -1,274 +1,57 @@ | |||
| 1 | - | module github.com/bnema/gordon | |
| 1 | + | module gordon | |
| 2 | 2 | ||
| 3 | - | go 1.24.1 | |
| 3 | + | go 1.24.3 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| 7 | - | github.com/Masterminds/semver/v3 v3.3.1 | |
| 8 | - | github.com/a-h/templ v0.3.833 | |
| 9 | - | github.com/charmbracelet/bubbles v0.20.0 | |
| 10 | - | github.com/charmbracelet/lipgloss v1.0.0 | |
| 11 | - | github.com/charmbracelet/log v0.4.0 | |
| 12 | - | github.com/docker/docker v28.0.1+incompatible | |
| 6 | + | github.com/docker/docker v28.2.2+incompatible | |
| 13 | 7 | github.com/docker/go-connections v0.5.0 | |
| 14 | - | github.com/fatih/color v1.18.0 | |
| 15 | - | github.com/go-acme/lego/v4 v4.22.2 | |
| 16 | - | github.com/gorilla/sessions v1.4.0 | |
| 17 | - | github.com/joho/godotenv v1.5.1 | |
| 18 | - | github.com/labstack/echo-contrib v0.17.2 | |
| 19 | - | github.com/labstack/echo/v4 v4.13.3 | |
| 20 | - | github.com/microcosm-cc/bluemonday v1.0.27 | |
| 8 | + | github.com/rs/zerolog v1.34.0 | |
| 21 | 9 | github.com/spf13/cobra v1.9.1 | |
| 22 | - | github.com/starskey-io/starskey v0.1.8 | |
| 23 | - | gopkg.in/yaml.v3 v3.0.1 | |
| 24 | - | modernc.org/sqlite v1.36.0 | |
| 10 | + | github.com/spf13/viper v1.20.1 | |
| 25 | 11 | ) | |
| 26 | 12 | ||
| 27 | - | require github.com/PuerkitoBio/goquery v1.10.2 // indirect | |
| 28 | - | ||
| 29 | 13 | require ( | |
| 30 | - | cloud.google.com/go/auth v0.13.0 // indirect | |
| 31 | - | cloud.google.com/go/auth/oauth2adapt v0.2.6 // indirect | |
| 32 | - | cloud.google.com/go/compute/metadata v0.6.0 // indirect | |
| 33 | - | github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect | |
| 34 | - | github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect | |
| 35 | - | github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0 // indirect | |
| 36 | - | github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0 // indirect | |
| 37 | - | github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect | |
| 38 | - | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect | |
| 39 | - | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect | |
| 40 | - | github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect | |
| 41 | - | github.com/Azure/go-autorest v14.2.0+incompatible // indirect | |
| 42 | - | github.com/Azure/go-autorest/autorest v0.11.29 // indirect | |
| 43 | - | github.com/Azure/go-autorest/autorest/adal v0.9.22 // indirect | |
| 44 | - | github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect | |
| 45 | - | github.com/Azure/go-autorest/autorest/azure/cli v0.4.6 // indirect | |
| 46 | - | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect | |
| 47 | - | github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect | |
| 48 | - | github.com/Azure/go-autorest/logger v0.2.1 // indirect | |
| 49 | - | github.com/Azure/go-autorest/tracing v0.6.0 // indirect | |
| 50 | - | github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 // indirect | |
| 51 | - | github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect | |
| 52 | - | github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect | |
| 53 | - | github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect | |
| 54 | - | github.com/aliyun/alibaba-cloud-sdk-go v1.63.72 // indirect | |
| 55 | - | github.com/andybalholm/brotli v1.1.0 // indirect | |
| 56 | - | github.com/aws/aws-sdk-go-v2 v1.32.7 // indirect | |
| 57 | - | github.com/aws/aws-sdk-go-v2/config v1.28.7 // indirect | |
| 58 | - | github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect | |
| 59 | - | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect | |
| 60 | - | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect | |
| 61 | - | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect | |
| 62 | - | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect | |
| 63 | - | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect | |
| 64 | - | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect | |
| 65 | - | github.com/aws/aws-sdk-go-v2/service/lightsail v1.42.8 // indirect | |
| 66 | - | github.com/aws/aws-sdk-go-v2/service/route53 v1.46.4 // indirect | |
| 67 | - | github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect | |
| 68 | - | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect | |
| 69 | - | github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 // indirect | |
| 70 | - | github.com/aws/smithy-go v1.22.1 // indirect | |
| 71 | - | github.com/benbjohnson/clock v1.3.0 // indirect | |
| 72 | - | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect | |
| 73 | - | github.com/cenkalti/backoff/v4 v4.3.0 // indirect | |
| 74 | - | github.com/cespare/xxhash/v2 v2.3.0 // indirect | |
| 75 | - | github.com/civo/civogo v0.3.11 // indirect | |
| 76 | - | github.com/cli/browser v1.3.0 // indirect | |
| 77 | - | github.com/cloudflare/cloudflare-go v0.112.0 // indirect | |
| 78 | - | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect | |
| 79 | - | github.com/dimchansky/utfbom v1.1.1 // indirect | |
| 80 | - | github.com/dnsimple/dnsimple-go v1.7.0 // indirect | |
| 81 | - | github.com/exoscale/egoscale/v3 v3.1.7 // indirect | |
| 82 | - | github.com/fatih/structs v1.1.0 // indirect | |
| 83 | - | github.com/fsnotify/fsnotify v1.8.0 // indirect | |
| 84 | - | github.com/gabriel-vasile/mimetype v1.4.2 // indirect | |
| 85 | - | github.com/ghodss/yaml v1.0.0 // indirect | |
| 86 | - | github.com/go-errors/errors v1.0.1 // indirect | |
| 87 | - | github.com/go-jose/go-jose/v4 v4.0.4 // indirect | |
| 88 | - | github.com/go-playground/locales v0.14.1 // indirect | |
| 89 | - | github.com/go-playground/universal-translator v0.18.1 // indirect | |
| 90 | - | github.com/go-playground/validator/v10 v10.16.0 // indirect | |
| 91 | - | github.com/go-resty/resty/v2 v2.16.2 // indirect | |
| 92 | - | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect | |
| 93 | - | github.com/goccy/go-json v0.10.4 // indirect | |
| 94 | - | github.com/gofrs/flock v0.12.1 // indirect | |
| 95 | - | github.com/golang-jwt/jwt/v4 v4.5.1 // indirect | |
| 96 | - | github.com/golang-jwt/jwt/v5 v5.2.2 // direct | |
| 97 | - | github.com/google/go-querystring v1.1.0 // indirect | |
| 98 | - | github.com/google/s2a-go v0.1.8 // indirect | |
| 99 | - | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect | |
| 100 | - | github.com/googleapis/gax-go/v2 v2.14.0 // indirect | |
| 101 | - | github.com/gophercloud/gophercloud v1.14.1 // indirect | |
| 102 | - | github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect | |
| 103 | - | github.com/hashicorp/errwrap v1.0.0 // indirect | |
| 104 | - | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect | |
| 105 | - | github.com/hashicorp/go-multierror v1.1.1 // indirect | |
| 106 | - | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect | |
| 107 | - | github.com/hashicorp/go-uuid v1.0.3 // indirect | |
| 108 | - | github.com/hashicorp/hcl v1.0.0 // indirect | |
| 109 | - | github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.128 // indirect | |
| 110 | - | github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect | |
| 111 | - | github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect | |
| 112 | - | github.com/jmespath/go-jmespath v0.4.0 // indirect | |
| 113 | - | github.com/json-iterator/go v1.1.12 // indirect | |
| 114 | - | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect | |
| 115 | - | github.com/klauspost/compress v1.18.0 // indirect | |
| 116 | - | github.com/klauspost/cpuid/v2 v2.2.10 // indirect | |
| 117 | - | github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect | |
| 118 | - | github.com/kylelemons/godebug v1.1.0 // indirect | |
| 119 | - | github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect | |
| 120 | - | github.com/labbsr0x/goh v1.0.1 // indirect | |
| 121 | - | github.com/leodido/go-urn v1.2.4 // indirect | |
| 122 | - | github.com/linode/linodego v1.44.0 // indirect | |
| 123 | - | github.com/liquidweb/liquidweb-cli v0.6.9 // indirect | |
| 124 | - | github.com/liquidweb/liquidweb-go v1.6.4 // indirect | |
| 125 | - | github.com/magiconair/properties v1.8.7 // indirect | |
| 126 | - | github.com/miekg/dns v1.1.62 // indirect | |
| 127 | - | github.com/mimuret/golang-iij-dpf v0.9.1 // indirect | |
| 128 | - | github.com/mitchellh/go-homedir v1.1.0 // indirect | |
| 129 | - | github.com/mitchellh/mapstructure v1.5.0 // indirect | |
| 130 | - | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect | |
| 131 | - | github.com/modern-go/reflect2 v1.0.2 // indirect | |
| 132 | - | github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect | |
| 133 | - | github.com/natefinch/atomic v1.0.1 // indirect | |
| 134 | - | github.com/nrdcg/auroradns v1.1.0 // indirect | |
| 135 | - | github.com/nrdcg/bunny-go v0.0.0-20240207213615-dde5bf4577a3 // indirect | |
| 136 | - | github.com/nrdcg/desec v0.10.0 // indirect | |
| 137 | - | github.com/nrdcg/dnspod-go v0.4.0 // indirect | |
| 138 | - | github.com/nrdcg/freemyip v0.3.0 // indirect | |
| 139 | - | github.com/nrdcg/goacmedns v0.2.0 // indirect | |
| 140 | - | github.com/nrdcg/goinwx v0.10.0 // indirect | |
| 141 | - | github.com/nrdcg/mailinabox v0.2.0 // indirect | |
| 142 | - | github.com/nrdcg/namesilo v0.2.1 // indirect | |
| 143 | - | github.com/nrdcg/nodion v0.1.0 // indirect | |
| 144 | - | github.com/nrdcg/porkbun v0.4.0 // indirect | |
| 145 | - | github.com/nzdjb/go-metaname v1.0.0 // indirect | |
| 146 | - | github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b // indirect | |
| 147 | - | github.com/oracle/oci-go-sdk/v65 v65.81.1 // indirect | |
| 148 | - | github.com/ovh/go-ovh v1.6.0 // indirect | |
| 149 | - | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect | |
| 150 | - | github.com/pelletier/go-toml/v2 v2.1.0 // indirect | |
| 151 | - | github.com/peterhellberg/link v1.2.0 // indirect | |
| 152 | - | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect | |
| 153 | - | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect | |
| 154 | - | github.com/pquerna/otp v1.4.0 // indirect | |
| 155 | - | github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect | |
| 156 | - | github.com/sacloud/api-client-go v0.2.10 // indirect | |
| 157 | - | github.com/sacloud/go-http v0.1.8 // indirect | |
| 158 | - | github.com/sacloud/iaas-api-go v1.14.0 // indirect | |
| 159 | - | github.com/sacloud/packages-go v0.0.10 // indirect | |
| 160 | - | github.com/sagikazarmark/locafero v0.4.0 // indirect | |
| 161 | - | github.com/sagikazarmark/slog-shim v0.1.0 // indirect | |
| 162 | - | github.com/scaleway/scaleway-sdk-go v1.0.0-beta.30 // indirect | |
| 163 | - | github.com/selectel/domains-go v1.1.0 // indirect | |
| 164 | - | github.com/selectel/go-selvpcclient/v3 v3.2.1 // indirect | |
| 165 | - | github.com/shopspring/decimal v1.3.1 // indirect | |
| 166 | - | github.com/sirupsen/logrus v1.9.3 // indirect | |
| 167 | - | github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect | |
| 168 | - | github.com/softlayer/softlayer-go v1.1.7 // indirect | |
| 169 | - | github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect | |
| 170 | - | github.com/sony/gobreaker v0.5.0 // indirect | |
| 171 | - | github.com/sourcegraph/conc v0.3.0 // indirect | |
| 172 | - | github.com/spf13/afero v1.11.0 // indirect | |
| 173 | - | github.com/spf13/cast v1.6.0 // indirect | |
| 174 | - | github.com/spf13/viper v1.18.2 // indirect | |
| 175 | - | github.com/stretchr/testify v1.10.0 // indirect | |
| 176 | - | github.com/subosito/gotenv v1.6.0 // indirect | |
| 177 | - | github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.1065 // indirect | |
| 178 | - | github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.0.1065 // indirect | |
| 179 | - | github.com/tjfoc/gmsm v1.4.1 // indirect | |
| 180 | - | github.com/transip/gotransip/v6 v6.26.0 // indirect | |
| 181 | - | github.com/ultradns/ultradns-go-sdk v1.8.0-20241010134910-243eeec // indirect | |
| 182 | - | github.com/vinyldns/go-vinyldns v0.9.16 // indirect | |
| 183 | - | github.com/volcengine/volc-sdk-golang v1.0.189 // indirect | |
| 184 | - | github.com/vultr/govultr/v3 v3.9.1 // indirect | |
| 185 | - | github.com/yandex-cloud/go-genproto v0.0.0-20241220122821-aeb3b05efd1c // indirect | |
| 186 | - | github.com/yandex-cloud/go-sdk v0.0.0-20241220131134-2393e243c134 // indirect | |
| 187 | - | github.com/zeebo/xxh3 v1.0.2 // indirect | |
| 188 | - | go.mongodb.org/mongo-driver v1.17.3 // indirect | |
| 189 | - | go.opentelemetry.io/auto/sdk v1.1.0 // indirect | |
| 190 | - | go.uber.org/atomic v1.11.0 // indirect | |
| 191 | - | go.uber.org/multierr v1.9.0 // indirect | |
| 192 | - | go.uber.org/ratelimit v0.3.0 // indirect | |
| 193 | - | golang.org/x/mod v0.24.0 // indirect | |
| 194 | - | golang.org/x/oauth2 v0.24.0 // indirect | |
| 195 | - | golang.org/x/tools v0.31.0 // indirect | |
| 196 | - | google.golang.org/api v0.214.0 // indirect | |
| 197 | - | google.golang.org/genproto v0.0.0-20241021214115-324edc3d5d38 // indirect | |
| 198 | - | google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect | |
| 199 | - | google.golang.org/grpc v1.69.2 // indirect | |
| 200 | - | google.golang.org/protobuf v1.36.1 // indirect | |
| 201 | - | gopkg.in/ini.v1 v1.67.0 // indirect | |
| 202 | - | gopkg.in/ns1/ns1-go.v2 v2.13.0 // indirect | |
| 203 | - | gopkg.in/yaml.v2 v2.4.0 // indirect | |
| 204 | - | ) | |
| 205 | - | ||
| 206 | - | require ( | |
| 207 | - | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| 208 | - | github.com/charmbracelet/bubbletea v1.3.4 // direct | |
| 209 | - | github.com/charmbracelet/x/ansi v0.8.0 // indirect | |
| 210 | - | github.com/charmbracelet/x/term v0.2.1 // indirect | |
| 14 | + | github.com/Microsoft/go-winio v0.4.14 // indirect | |
| 15 | + | github.com/containerd/errdefs v1.0.0 // indirect | |
| 16 | + | github.com/containerd/errdefs/pkg v0.3.0 // indirect | |
| 211 | 17 | github.com/containerd/log v0.1.0 // indirect | |
| 212 | - | github.com/creack/pty v1.1.18 // indirect | |
| 213 | 18 | github.com/distribution/reference v0.6.0 // indirect | |
| 214 | - | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 215 | - | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 19 | + | github.com/docker/go-units v0.5.0 // indirect | |
| 216 | 20 | github.com/felixge/httpsnoop v1.0.4 // indirect | |
| 217 | - | github.com/go-logfmt/logfmt v0.6.0 // indirect | |
| 21 | + | github.com/fsnotify/fsnotify v1.8.0 // indirect | |
| 218 | 22 | github.com/go-logr/logr v1.4.2 // indirect | |
| 219 | 23 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 24 | + | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect | |
| 25 | + | github.com/gogo/protobuf v1.3.2 // indirect | |
| 26 | + | github.com/golang-jwt/jwt/v5 v5.2.2 // direct | |
| 220 | 27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 221 | - | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect | |
| 222 | - | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect | |
| 223 | - | github.com/mattn/go-localereader v0.0.1 // indirect | |
| 224 | - | github.com/mattn/go-runewidth v0.0.16 // indirect | |
| 225 | - | github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect | |
| 28 | + | github.com/mattn/go-colorable v0.1.13 // indirect | |
| 29 | + | github.com/mattn/go-isatty v0.0.19 // indirect | |
| 226 | 30 | github.com/moby/docker-image-spec v1.3.1 // indirect | |
| 227 | - | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 228 | - | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 229 | - | github.com/muesli/termenv v0.16.0 // indirect | |
| 230 | - | github.com/ncruces/go-strftime v0.1.9 // indirect | |
| 231 | - | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 232 | - | github.com/rivo/uniseg v0.4.7 // indirect | |
| 233 | - | github.com/spf13/pflag v1.0.6 // indirect | |
| 234 | - | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect | |
| 235 | - | go.opentelemetry.io/otel v1.35.0 // indirect | |
| 236 | - | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect | |
| 237 | - | go.opentelemetry.io/otel/metric v1.35.0 // indirect | |
| 238 | - | go.opentelemetry.io/otel/trace v1.35.0 // indirect | |
| 239 | - | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // direct | |
| 240 | - | golang.org/x/sync v0.12.0 // indirect | |
| 241 | - | golang.org/x/term v0.30.0 // indirect | |
| 242 | - | google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 // indirect | |
| 243 | - | modernc.org/libc v1.61.13 // indirect | |
| 244 | - | modernc.org/mathutil v1.7.1 // indirect | |
| 245 | - | modernc.org/memory v1.8.2 // indirect | |
| 246 | - | ) | |
| 247 | - | ||
| 248 | - | require ( | |
| 249 | - | github.com/Microsoft/go-winio v0.6.2 // indirect | |
| 250 | - | github.com/andybalholm/cascadia v1.3.3 // indirect | |
| 251 | - | github.com/aymerick/douceur v0.2.0 // indirect | |
| 252 | - | github.com/docker/go-units v0.5.0 | |
| 253 | - | github.com/gogo/protobuf v1.3.2 // indirect | |
| 254 | - | github.com/google/uuid v1.6.0 // direct | |
| 255 | - | github.com/gorilla/context v1.1.2 // indirect | |
| 256 | - | github.com/gorilla/css v1.0.1 // indirect | |
| 257 | - | github.com/gorilla/securecookie v1.1.2 // indirect | |
| 258 | - | github.com/labstack/gommon v0.4.2 // indirect | |
| 259 | - | github.com/mattn/go-colorable v0.1.14 // indirect | |
| 260 | - | github.com/mattn/go-isatty v0.0.20 // indirect | |
| 261 | - | github.com/moby/term v0.5.0 // indirect | |
| 31 | + | github.com/moby/sys/atomicwriter v0.1.0 // indirect | |
| 32 | + | github.com/moby/term v0.5.2 // indirect | |
| 262 | 33 | github.com/morikuni/aec v1.0.0 // indirect | |
| 263 | 34 | github.com/opencontainers/go-digest v1.0.0 // indirect | |
| 264 | 35 | github.com/opencontainers/image-spec v1.1.1 // indirect | |
| 36 | + | github.com/pelletier/go-toml/v2 v2.2.3 // indirect | |
| 265 | 37 | github.com/pkg/errors v0.9.1 // indirect | |
| 266 | - | github.com/valyala/bytebufferpool v1.0.0 // indirect | |
| 267 | - | github.com/valyala/fasttemplate v1.2.2 // indirect | |
| 268 | - | golang.org/x/crypto v0.36.0 // indirect | |
| 269 | - | golang.org/x/net v0.37.0 // indirect | |
| 270 | - | golang.org/x/sys v0.31.0 // indirect | |
| 271 | - | golang.org/x/text v0.23.0 // indirect | |
| 272 | - | golang.org/x/time v0.11.0 // direct | |
| 273 | - | gotest.tools/v3 v3.5.0 // indirect | |
| 38 | + | github.com/sagikazarmark/locafero v0.7.0 // indirect | |
| 39 | + | github.com/sourcegraph/conc v0.3.0 // indirect | |
| 40 | + | github.com/spf13/afero v1.12.0 // indirect | |
| 41 | + | github.com/spf13/cast v1.7.1 // indirect | |
| 42 | + | github.com/spf13/pflag v1.0.6 // indirect | |
| 43 | + | github.com/subosito/gotenv v1.6.0 // indirect | |
| 44 | + | go.opentelemetry.io/auto/sdk v1.1.0 // indirect | |
| 45 | + | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect | |
| 46 | + | go.opentelemetry.io/otel v1.36.0 // indirect | |
| 47 | + | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect | |
| 48 | + | go.opentelemetry.io/otel/metric v1.36.0 // indirect | |
| 49 | + | go.opentelemetry.io/otel/sdk v1.36.0 // indirect | |
| 50 | + | go.opentelemetry.io/otel/trace v1.36.0 // indirect | |
| 51 | + | go.uber.org/atomic v1.9.0 // indirect | |
| 52 | + | go.uber.org/multierr v1.9.0 // indirect | |
| 53 | + | golang.org/x/sys v0.33.0 // indirect | |
| 54 | + | golang.org/x/text v0.25.0 // indirect | |
| 55 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 56 | + | gotest.tools/v3 v3.5.2 // indirect | |
| 274 | 57 | ) | |
M go.sum +79 -1472 generated file: collapsed
A gordon.hcl +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | server { | |
| 2 | + | port = 8080 | |
| 3 | + | registry_port = 5000 | |
| 4 | + | runtime = "docker" | |
| 5 | + | ssl_email = "admin@example.com" | |
| 6 | + | data_dir = "./data" | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | routes = { | |
| 10 | + | "example.com" = "nginx:latest" | |
| 11 | + | "api.example.com" = "myapi:latest" | |
| 12 | + | "http://dev.example.com" = "myblog:latest" | |
| 13 | + | }⏎̸ | |
A gordon.toml +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | [server] | |
| 2 | + | port = 8080 | |
| 3 | + | registry_port = 5000 | |
| 4 | + | runtime = "docker" | |
| 5 | + | ssl_email = "admin@example.com" | |
| 6 | + | data_dir = "./data" | |
| 7 | + | ||
| 8 | + | # Authentication and Security Configuration | |
| 9 | + | [auth] | |
| 10 | + | enabled = false # Set to true to enable authentication | |
| 11 | + | method = "basic" # Authentication method: "jwt", "api_key", or "basic" | |
| 12 | + | ||
| 13 | + | # JWT Authentication (recommended for production) | |
| 14 | + | jwt_secret = "" # Generate with: gordon generate jwt-secret | |
| 15 | + | ||
| 16 | + | # API Key Authentication (simple but secure) | |
| 17 | + | api_key = "" # Generate with: gordon generate api-key | |
| 18 | + | ||
| 19 | + | # Basic Authentication (least secure, use only for development) | |
| 20 | + | username = "" # Username for Basic Auth | |
| 21 | + | password = "" # Password for Basic Auth | |
| 22 | + | ||
| 23 | + | # Registry Authentication | |
| 24 | + | registry_auth = false # Enable authentication for Docker registry push/pull | |
| 25 | + | ||
| 26 | + | # IP Whitelist (additional security layer) | |
| 27 | + | allowed_ips = ["127.0.0.1", "::1"] # Whitelist of IPs allowed to access management API | |
| 28 | + | ||
| 29 | + | [routes] | |
| 30 | + | "example.com" = "nginx:latest" | |
| 31 | + | "api.example.com" = "myapi:latest" | |
| 32 | + | "http://dev.example.com" = "myblog:latest"⏎̸ | |
A gordon.yaml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | server: | |
| 2 | + | port: 8080 | |
| 3 | + | registry_port: 5000 | |
| 4 | + | runtime: docker | |
| 5 | + | ssl_email: admin@example.com | |
| 6 | + | data_dir: ./data | |
| 7 | + | ||
| 8 | + | routes: | |
| 9 | + | "example.com": "nginx:latest" | |
| 10 | + | "api.example.com": "myapi:latest" | |
| 11 | + | "http://dev.example.com": "myblog:latest"⏎̸ | |
D internal/cli/auth/device_flow.go +0 -124
| @@ -1,124 +0,0 @@ | |||
| 1 | - | // internal/cli/auth/device_flow.go | |
| 2 | - | package auth | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | "net/url" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/cli" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func DeviceFlowAuth(a *cli.App) (token string, err error) { | |
| 16 | - | fmt.Println("Starting device flow authentication...") | |
| 17 | - | ||
| 18 | - | // Request device code | |
| 19 | - | deviceCode, err := requestDeviceCode(a) | |
| 20 | - | if err != nil { | |
| 21 | - | return "", fmt.Errorf("error requesting device code: %w", err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | fmt.Printf("Please go to %v and enter the code: %v\n", deviceCode["verification_uri"], deviceCode["user_code"]) | |
| 25 | - | ||
| 26 | - | // Poll for access token | |
| 27 | - | interval := 5 // Default to 5 seconds if not provided | |
| 28 | - | if i, ok := deviceCode["interval"].(float64); ok { | |
| 29 | - | interval = int(i) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | token, err = pollForAccessToken(a, deviceCode["device_code"].(string), interval) | |
| 33 | - | if err != nil { | |
| 34 | - | return "", fmt.Errorf("error polling for access token: %w", err) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // Save the obtained JWT into the JWTSecret field in the config | |
| 38 | - | a.Config.General.JwtToken = token | |
| 39 | - | err = a.Config.SaveConfig() // Save the updated config | |
| 40 | - | if err != nil { | |
| 41 | - | // Log the error but don't necessarily fail the whole flow, | |
| 42 | - | // as the token might still be usable in memory for the current session. | |
| 43 | - | fmt.Printf("\nWarning: Failed to save token to config file: %v\n", err) | |
| 44 | - | fmt.Println("Authentication successful, but token might not persist.") | |
| 45 | - | } else { | |
| 46 | - | fmt.Println("Authentication successful! Token saved to configuration.") | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | return token, nil | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | func requestDeviceCode(a *cli.App) (map[string]interface{}, error) { | |
| 53 | - | resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/code", url.Values{}) | |
| 54 | - | if err != nil { | |
| 55 | - | return nil, fmt.Errorf("error making request: %w", err) | |
| 56 | - | } | |
| 57 | - | defer resp.Body.Close() | |
| 58 | - | ||
| 59 | - | body, err := io.ReadAll(resp.Body) | |
| 60 | - | if err != nil { | |
| 61 | - | return nil, fmt.Errorf("error reading response body: %w", err) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | if resp.StatusCode != http.StatusOK { | |
| 65 | - | return nil, fmt.Errorf("server returned non-200 status code: %d. Body: %s", resp.StatusCode, string(body)) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | var deviceCode map[string]interface{} | |
| 69 | - | if err := json.Unmarshal(body, &deviceCode); err != nil { | |
| 70 | - | return nil, fmt.Errorf("error parsing JSON response: %w", err) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // Check for required fields | |
| 74 | - | requiredFields := []string{"verification_uri", "user_code", "device_code", "interval"} | |
| 75 | - | for _, field := range requiredFields { | |
| 76 | - | if _, ok := deviceCode[field]; !ok { | |
| 77 | - | return nil, fmt.Errorf("missing required field in response: %s", field) | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | return deviceCode, nil | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func pollForAccessToken(a *cli.App, deviceCode string, interval int) (string, error) { | |
| 85 | - | maxAttempts := 60 // Maximum number of attempts (5 minutes with 5-second intervals) | |
| 86 | - | for attempt := 0; attempt < maxAttempts; attempt++ { | |
| 87 | - | time.Sleep(time.Duration(interval) * time.Second) | |
| 88 | - | ||
| 89 | - | resp, err := http.PostForm(a.Config.GetBackendURL()+"/api/device/token", url.Values{ | |
| 90 | - | "device_code": {deviceCode}, | |
| 91 | - | }) | |
| 92 | - | if err != nil { | |
| 93 | - | continue | |
| 94 | - | } | |
| 95 | - | defer resp.Body.Close() | |
| 96 | - | ||
| 97 | - | body, err := io.ReadAll(resp.Body) | |
| 98 | - | if err != nil { | |
| 99 | - | continue | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | var tokenResp map[string]interface{} | |
| 103 | - | if err := json.Unmarshal(body, &tokenResp); err != nil { | |
| 104 | - | continue | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // Expect "jwt_token" instead of "access_token" | |
| 108 | - | if jwtToken, ok := tokenResp["jwt_token"].(string); ok && jwtToken != "" { | |
| 109 | - | fmt.Println() // Print a newline after successful polling dots | |
| 110 | - | return jwtToken, nil | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | if errorMsg, ok := tokenResp["error"].(string); ok && errorMsg == "authorization_pending" { | |
| 114 | - | // Optionally, you can print a dot to show progress without cluttering the output | |
| 115 | - | fmt.Print(".") | |
| 116 | - | continue | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // If we get here, there's an unexpected response but we can continue to try | |
| 120 | - | // fmt.Printf("Unexpected response from server (attempt %d): %v\n", attempt+1, tokenResp) | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | return "", fmt.Errorf("max attempts reached, failed to obtain access token") | |
| 124 | - | } | |
D internal/cli/auth/token_cache.go +0 -87
| @@ -1,87 +0,0 @@ | |||
| 1 | - | package auth | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "sync" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/db" | |
| 8 | - | "github.com/charmbracelet/log" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | type TokenCache struct { | |
| 12 | - | cache sync.Map | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | type cacheEntry struct { | |
| 16 | - | token string | |
| 17 | - | expiration time.Time | |
| 18 | - | githubUser *db.GithubUserInfo | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | var ( | |
| 22 | - | defaultCache *TokenCache | |
| 23 | - | once sync.Once | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // GetTokenCache returns a singleton instance of TokenCache | |
| 27 | - | func GetTokenCache() *TokenCache { | |
| 28 | - | once.Do(func() { | |
| 29 | - | defaultCache = &TokenCache{} | |
| 30 | - | log.Debug("initialized token cache singleton") | |
| 31 | - | }) | |
| 32 | - | return defaultCache | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // GetWithUser retrieves a token and GitHub user info from the cache | |
| 36 | - | func (tc *TokenCache) GetWithUser(key string) (string, *db.GithubUserInfo, bool) { | |
| 37 | - | if value, ok := tc.cache.Load(key); ok { | |
| 38 | - | entry := value.(cacheEntry) | |
| 39 | - | if time.Now().Before(entry.expiration) { | |
| 40 | - | log.Debug("cache hit", "key", key, "expires_in", time.Until(entry.expiration)) | |
| 41 | - | return entry.token, entry.githubUser, true | |
| 42 | - | } | |
| 43 | - | log.Debug("cache entry expired", "key", key) | |
| 44 | - | tc.cache.Delete(key) | |
| 45 | - | } | |
| 46 | - | log.Debug("cache miss", "key", key) | |
| 47 | - | return "", nil, false | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Get retrieves just the token validation status | |
| 51 | - | func (tc *TokenCache) Get(key string) (bool, bool) { | |
| 52 | - | if value, ok := tc.cache.Load(key); ok { | |
| 53 | - | entry := value.(cacheEntry) | |
| 54 | - | if time.Now().Before(entry.expiration) { | |
| 55 | - | log.Debug("cache hit (validation status)", "key", key, "expires_in", time.Until(entry.expiration)) | |
| 56 | - | return true, true | |
| 57 | - | } | |
| 58 | - | log.Debug("cache entry expired (validation status)", "key", key) | |
| 59 | - | tc.cache.Delete(key) | |
| 60 | - | } | |
| 61 | - | log.Debug("cache miss (validation status)", "key", key) | |
| 62 | - | return false, false | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // SetWithUser stores a token and GitHub user info in the cache | |
| 66 | - | func (tc *TokenCache) SetWithUser(key string, token string, user *db.GithubUserInfo, duration time.Duration) { | |
| 67 | - | tc.cache.Store(key, cacheEntry{ | |
| 68 | - | token: token, | |
| 69 | - | githubUser: user, | |
| 70 | - | expiration: time.Now().Add(duration), | |
| 71 | - | }) | |
| 72 | - | log.Debug("stored token with user info in cache", | |
| 73 | - | "key", key, | |
| 74 | - | "duration", duration, | |
| 75 | - | "github_user", user.Login) | |
| 76 | - | } | |
| 77 | - | ||
| 78 | - | // Set stores just the token validation status | |
| 79 | - | func (tc *TokenCache) Set(key string, duration time.Duration) { | |
| 80 | - | tc.cache.Store(key, cacheEntry{ | |
| 81 | - | token: key, | |
| 82 | - | expiration: time.Now().Add(duration), | |
| 83 | - | }) | |
| 84 | - | log.Debug("stored token validation status in cache", | |
| 85 | - | "key", key, | |
| 86 | - | "duration", duration) | |
| 87 | - | } | |
D internal/cli/auth/token_check.go +0 -64
| @@ -1,64 +0,0 @@ | |||
| 1 | - | package auth | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | "strings" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/charmbracelet/log" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // IsValidToken validates a GitHub token | |
| 12 | - | func IsValidGitHubToken(token string) bool { | |
| 13 | - | // Use the existing token cache | |
| 14 | - | tokenCache := GetTokenCache() | |
| 15 | - | ||
| 16 | - | // Check if token exists in cache | |
| 17 | - | if _, _, found := tokenCache.GetWithUser(token); found { | |
| 18 | - | log.Debug("token found in cache") | |
| 19 | - | return true | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // If not in cache, validate with GitHub | |
| 23 | - | client := &http.Client{Timeout: 10 * time.Second} | |
| 24 | - | req, err := http.NewRequest("GET", "https://api.github.com/user", nil) | |
| 25 | - | if err != nil { | |
| 26 | - | log.Error("failed to create GitHub API request", "error", err) | |
| 27 | - | return false | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | req.Header.Set("Authorization", "token "+token) | |
| 31 | - | req.Header.Set("Accept", "application/vnd.github.v3+json") | |
| 32 | - | ||
| 33 | - | resp, err := client.Do(req) | |
| 34 | - | if err != nil { | |
| 35 | - | log.Error("GitHub API request failed", "error", err) | |
| 36 | - | return false | |
| 37 | - | } | |
| 38 | - | defer resp.Body.Close() | |
| 39 | - | ||
| 40 | - | if resp.StatusCode != http.StatusOK { | |
| 41 | - | log.Warn("invalid GitHub token", "status", resp.Status) | |
| 42 | - | return false | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return true | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // ExtractAndValidateToken extracts and validates a token from an Authorization header | |
| 49 | - | func ExtractAndValidateToken(authHeader string) (string, bool) { | |
| 50 | - | if authHeader == "" { | |
| 51 | - | return "", false | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | token := strings.TrimPrefix(authHeader, "Bearer ") | |
| 55 | - | if token == "" { | |
| 56 | - | return "", false | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | if !IsValidGitHubToken(token) { | |
| 60 | - | return "", false | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | return token, true | |
| 64 | - | } | |
D internal/cli/cmd/deploy.go +0 -252
| @@ -1,252 +0,0 @@ | |||
| 1 | - | // gordon/internal/cli/cmd/deploy.go | |
| 2 | - | ||
| 3 | - | package cmd | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "encoding/json" | |
| 9 | - | "fmt" | |
| 10 | - | "io" | |
| 11 | - | "net/http" | |
| 12 | - | "os" | |
| 13 | - | "strings" | |
| 14 | - | "time" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/internal/cli" | |
| 17 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 18 | - | "github.com/bnema/gordon/internal/common" | |
| 19 | - | "github.com/bnema/gordon/pkg/logger" | |
| 20 | - | "github.com/spf13/cobra" | |
| 21 | - | ) | |
| 22 | - | ||
| 23 | - | // No need for logger initialization here as it's handled by the centralized logger | |
| 24 | - | ||
| 25 | - | func NewDeployCommand(a *cli.App) *cobra.Command { | |
| 26 | - | var port string | |
| 27 | - | var targetDomain string | |
| 28 | - | var skipProxySetup bool | |
| 29 | - | ||
| 30 | - | deployCmd := &cobra.Command{ | |
| 31 | - | Use: "deploy [image:tag]", | |
| 32 | - | Short: "Deploy an image to your remote Gordon instance", | |
| 33 | - | Args: cobra.ExactArgs(1), | |
| 34 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 35 | - | if err := handler.FieldCheck(a); err != nil { | |
| 36 | - | logger.Error("Field check failed", "error", err) | |
| 37 | - | os.Exit(1) | |
| 38 | - | } | |
| 39 | - | }, | |
| 40 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 41 | - | imageName := args[0] | |
| 42 | - | logger.Info("Pushing image", "image", imageName) | |
| 43 | - | ||
| 44 | - | if err := validateDeployInputs(imageName, port, targetDomain); err != nil { | |
| 45 | - | logger.Error("Validation failed", "error", err) | |
| 46 | - | return | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 50 | - | if err != nil { | |
| 51 | - | logger.Error("Error exporting image", "error", err) | |
| 52 | - | return | |
| 53 | - | } | |
| 54 | - | defer reader.Close() | |
| 55 | - | ||
| 56 | - | sizeInMB := float64(actualSize) / 1024 / 1024 | |
| 57 | - | ||
| 58 | - | logger.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 59 | - | ||
| 60 | - | if err := deployImage(a, reader, imageName, port, targetDomain, skipProxySetup); err != nil { | |
| 61 | - | os.Exit(1) | |
| 62 | - | } | |
| 63 | - | }, | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") | |
| 67 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy") | |
| 68 | - | deployCmd.Flags().BoolVar(&skipProxySetup, "skip-proxy", false, "Skip proxy setup (create container only)") | |
| 69 | - | ||
| 70 | - | return deployCmd | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | func deployImage(a *cli.App, reader io.Reader, imageName, port, targetDomain string, skipProxySetup bool) error { | |
| 74 | - | // Check authentication early | |
| 75 | - | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 76 | - | logger.Error("Authentication check failed", "error", err) | |
| 77 | - | return err | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | logger.Info("Deploying image", "image", imageName) | |
| 81 | - | // Check for conflicts first | |
| 82 | - | resp, err := checkDeployConflict(a, targetDomain, port) | |
| 83 | - | if err != nil { | |
| 84 | - | return fmt.Errorf("conflict check failed: %w", err) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | var shortID string | |
| 88 | - | if len(resp.ContainerID) >= 12 { | |
| 89 | - | shortID = resp.ContainerID[:12] | |
| 90 | - | } else { | |
| 91 | - | shortID = resp.ContainerID // Use full ID if less than 12 chars | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // If there's a conflict, handle it before proceeding | |
| 95 | - | if !resp.Success && resp.ContainerID != "" { | |
| 96 | - | logger.Warn("Container already exists", | |
| 97 | - | "name", resp.ContainerName, | |
| 98 | - | "id", shortID, | |
| 99 | - | "state", resp.State, | |
| 100 | - | "uptime", resp.RunningTime) | |
| 101 | - | ||
| 102 | - | if err := handler.HandleExistingContainer(a, resp); err != nil { | |
| 103 | - | if strings.Contains(err.Error(), "cancelled by user") { | |
| 104 | - | return fmt.Errorf("deployment cancelled by user") | |
| 105 | - | } | |
| 106 | - | return fmt.Errorf("failed to handle existing container: %w", err) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | } | |
| 110 | - | ||
| 111 | - | var buf bytes.Buffer | |
| 112 | - | size, err := io.Copy(&buf, reader) | |
| 113 | - | if err != nil { | |
| 114 | - | return fmt.Errorf("failed to read image data: %w", err) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | imageReader := bytes.NewReader(buf.Bytes()) | |
| 118 | - | sizeInMB := float64(size) / 1024 / 1024 | |
| 119 | - | logger.Info("Attempting to deploy...", | |
| 120 | - | "image", imageName, | |
| 121 | - | "size", fmt.Sprintf("%.2fMB", sizeInMB), | |
| 122 | - | "port", port, | |
| 123 | - | "target", targetDomain, | |
| 124 | - | ) | |
| 125 | - | ||
| 126 | - | headers := http.Header{ | |
| 127 | - | "X-Image-Name": {imageName}, | |
| 128 | - | "X-Ports": {port}, | |
| 129 | - | "X-Target-Domain": {targetDomain}, | |
| 130 | - | "Content-Type": {"application/octet-stream"}, | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | // Add skip proxy setup header if enabled | |
| 134 | - | if skipProxySetup { | |
| 135 | - | headers.Add("X-Skip-Proxy-Setup", "true") | |
| 136 | - | logger.Info("Skipping proxy setup as requested") | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | chunkedClient := handler.NewChunkedClient(a) | |
| 140 | - | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 141 | - | defer cancel() | |
| 142 | - | ||
| 143 | - | chunkResp, err := chunkedClient.SendFileAsChunks(ctx, "/deploy/chunked", headers, imageReader, size, imageName) | |
| 144 | - | if err != nil { | |
| 145 | - | return fmt.Errorf("failed to deploy image: %w", err) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | if chunkResp == nil { | |
| 149 | - | return fmt.Errorf("received nil response from server") | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | var deployResp common.DeployResponse | |
| 153 | - | if err := json.Unmarshal(chunkResp.Body, &deployResp); err != nil { | |
| 154 | - | return fmt.Errorf("failed to parse deployment response: %w", err) | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | return waitForDeployment(deployResp.Domain, deployResp.ContainerID) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | func waitForDeployment(domain string, containerID string) error { | |
| 161 | - | // If domain is empty, it means proxy setup was skipped | |
| 162 | - | if domain == "" { | |
| 163 | - | logger.Info("Deployment successful (proxy setup skipped)", | |
| 164 | - | "container_id", containerID) | |
| 165 | - | return nil | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | client := &http.Client{Timeout: 10 * time.Second} | |
| 169 | - | maxRetries := 10 | |
| 170 | - | retryInterval := time.Second | |
| 171 | - | ||
| 172 | - | var shortContainerID string | |
| 173 | - | if containerID != "" && len(containerID) >= 12 { | |
| 174 | - | shortContainerID = containerID[:12] | |
| 175 | - | } else { | |
| 176 | - | shortContainerID = containerID | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | logger.Info("Waiting for deployment to be reachable", | |
| 180 | - | "domain", domain, | |
| 181 | - | "container_id", shortContainerID) | |
| 182 | - | ||
| 183 | - | for i := 0; i < maxRetries; i++ { | |
| 184 | - | resp, err := client.Get(domain) | |
| 185 | - | if err == nil { | |
| 186 | - | defer resp.Body.Close() | |
| 187 | - | if resp.StatusCode == http.StatusOK { | |
| 188 | - | logger.Info("Deployment successful", | |
| 189 | - | "domain", domain, | |
| 190 | - | "container_id", shortContainerID) | |
| 191 | - | return nil | |
| 192 | - | } | |
| 193 | - | body, _ := io.ReadAll(resp.Body) | |
| 194 | - | if strings.Contains(string(body), "failed to create container:") { | |
| 195 | - | return fmt.Errorf("deployment failed: %s", string(body)) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | logger.Warn("Deployment not ready yet, retrying", | |
| 199 | - | "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 200 | - | time.Sleep(retryInterval) | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | func validateDeployInputs(imageName, port, targetDomain string) error { | |
| 207 | - | if err := handler.ValidateImageName(imageName); err != nil { | |
| 208 | - | return err | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | if err := handler.EnsureImageTag(&imageName); err != nil { | |
| 212 | - | return fmt.Errorf("failed to ensure image tag: %w", err) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | if port == "" { | |
| 216 | - | return fmt.Errorf("port is required") | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | return handler.ValidateTargetDomain(targetDomain) | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func checkDeployConflict(a *cli.App, targetDomain string, port string) (*common.DeployResponse, error) { | |
| 223 | - | ||
| 224 | - | // Create request to check-conflict endpoint with both domain and port parameters | |
| 225 | - | reqUrl := fmt.Sprintf("%s/api/deploy/check-conflict?domain=%s&port=%s", | |
| 226 | - | a.Config.Http.BackendURL, targetDomain, port) | |
| 227 | - | ||
| 228 | - | req, err := http.NewRequest("GET", reqUrl, nil) | |
| 229 | - | if err != nil { | |
| 230 | - | return nil, fmt.Errorf("failed to create conflict check request: %w", err) | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | // Set auth header | |
| 234 | - | req.Header.Set("Authorization", "Bearer "+a.Config.General.JwtToken) | |
| 235 | - | ||
| 236 | - | // Send request | |
| 237 | - | client := &http.Client{ | |
| 238 | - | Timeout: 10 * time.Second, | |
| 239 | - | } | |
| 240 | - | resp, err := client.Do(req) | |
| 241 | - | if err != nil { | |
| 242 | - | return nil, fmt.Errorf("failed to send conflict check request: %w", err) | |
| 243 | - | } | |
| 244 | - | defer resp.Body.Close() | |
| 245 | - | ||
| 246 | - | var conflictResp common.DeployResponse | |
| 247 | - | if err := json.NewDecoder(resp.Body).Decode(&conflictResp); err != nil { | |
| 248 | - | return nil, fmt.Errorf("failed to parse conflict check response: %w", err) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | return &conflictResp, nil | |
| 252 | - | } | |
D internal/cli/cmd/ping.go +0 -50
| @@ -1,50 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "os" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/cli" | |
| 9 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 10 | - | "github.com/bnema/gordon/pkg/humanize" | |
| 11 | - | ||
| 12 | - | "github.com/spf13/cobra" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func NewPingCommand(a *cli.App) *cobra.Command { | |
| 16 | - | // handler.FieldCheck(a) | |
| 17 | - | return &cobra.Command{ | |
| 18 | - | Use: "ping", | |
| 19 | - | Short: "Send a ping request to your remote Gordon instance", | |
| 20 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 21 | - | if err := handler.FieldCheck(a); err != nil { | |
| 22 | - | fmt.Println("Field check failed:", err) | |
| 23 | - | os.Exit(1) | |
| 24 | - | } | |
| 25 | - | }, | |
| 26 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 27 | - | pingResp, err := handler.PerformPingRequest(a) | |
| 28 | - | if err != nil { | |
| 29 | - | fmt.Println("Error:", err) | |
| 30 | - | return | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // Parse the uptime duration string | |
| 34 | - | duration, err := time.ParseDuration(pingResp.Uptime) | |
| 35 | - | if err != nil { | |
| 36 | - | fmt.Println("Error parsing uptime duration:", err) | |
| 37 | - | return | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Calculate the start time of the server | |
| 41 | - | startTime := time.Now().Add(-duration) | |
| 42 | - | ||
| 43 | - | // Use humanize to get a human-readable representation of the start time | |
| 44 | - | humanizedUptime := humanize.TimeAgo(startTime) | |
| 45 | - | ||
| 46 | - | // Print the information | |
| 47 | - | fmt.Printf("Pong!\nServer up since: %s\nServer version: %s\n", humanizedUptime, pingResp.Version) | |
| 48 | - | }, | |
| 49 | - | } | |
| 50 | - | } | |
D internal/cli/cmd/proxy.go +0 -196
| @@ -1,196 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 8 | - | "github.com/bnema/gordon/internal/proxy" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/bnema/gordon/pkg/docker" | |
| 11 | - | "github.com/charmbracelet/log" | |
| 12 | - | "github.com/spf13/cobra" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | // NewProxyCommand creates a new proxy command | |
| 16 | - | func NewProxyCommand(a *server.App) *cobra.Command { | |
| 17 | - | var ( | |
| 18 | - | domain string | |
| 19 | - | containerID string | |
| 20 | - | containerIP string | |
| 21 | - | containerPort string | |
| 22 | - | protocol string | |
| 23 | - | path string | |
| 24 | - | remove bool | |
| 25 | - | update bool | |
| 26 | - | force bool | |
| 27 | - | ) | |
| 28 | - | ||
| 29 | - | proxyCmd := &cobra.Command{ | |
| 30 | - | Use: "proxy", | |
| 31 | - | Short: "Manage the reverse proxy routes", | |
| 32 | - | Long: "Add, remove, or list proxy routes for domains to containers", | |
| 33 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 34 | - | // Initialize the database if not already initialized | |
| 35 | - | _, err := server.InitializeDB(a) | |
| 36 | - | if err != nil { | |
| 37 | - | log.Fatal("Failed to initialize database:", err) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Register the server instance for global signal handling | |
| 41 | - | handler.SetGlobalServer(a) | |
| 42 | - | ||
| 43 | - | // Create a new proxy instance | |
| 44 | - | p, err := proxy.NewProxy(a) | |
| 45 | - | if err != nil { | |
| 46 | - | log.Fatal("Failed to create proxy:", err) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // If update flag is set, update a container's IP address | |
| 50 | - | if update { | |
| 51 | - | if containerID == "" || containerIP == "" { | |
| 52 | - | log.Fatal("Container ID and IP address are required for update") | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // Get container info to verify the IP | |
| 56 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 57 | - | if err != nil { | |
| 58 | - | log.Warn("Failed to get container info, using provided IP", "error", err) | |
| 59 | - | } else { | |
| 60 | - | // Look for the network specified in the config | |
| 61 | - | networkName := a.Config.ContainerEngine.Network | |
| 62 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 63 | - | if networkSettings.IPAddress != containerIP { | |
| 64 | - | log.Warn("Provided IP doesn't match container's actual IP", | |
| 65 | - | "provided", containerIP, | |
| 66 | - | "actual", networkSettings.IPAddress) | |
| 67 | - | if !force { | |
| 68 | - | log.Info("Using actual container IP instead of provided IP", | |
| 69 | - | "container_id", containerID, | |
| 70 | - | "new_ip", networkSettings.IPAddress) | |
| 71 | - | containerIP = networkSettings.IPAddress | |
| 72 | - | } else { | |
| 73 | - | log.Warn("Force flag is set, using provided IP despite mismatch") | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Update the route | |
| 80 | - | err = p.ForceUpdateRouteIP(domain, containerIP) | |
| 81 | - | if err != nil { | |
| 82 | - | log.Fatal("Failed to update route IP:", err) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | log.Info("Route IP updated successfully") | |
| 86 | - | ||
| 87 | - | // Close the database connection | |
| 88 | - | if err := a.Shutdown(); err != nil { | |
| 89 | - | log.Error("Error closing database", "error", err) | |
| 90 | - | } | |
| 91 | - | return | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // If remove flag is set, remove a route | |
| 95 | - | if remove { | |
| 96 | - | if domain == "" { | |
| 97 | - | log.Fatal("Domain is required for removal") | |
| 98 | - | } | |
| 99 | - | if err := p.RemoveRoute(domain); err != nil { | |
| 100 | - | log.Fatal("Failed to remove route:", err) | |
| 101 | - | } | |
| 102 | - | log.Info("Route removed successfully") | |
| 103 | - | ||
| 104 | - | // Close the database connection | |
| 105 | - | if err := a.Shutdown(); err != nil { | |
| 106 | - | log.Error("Error closing database", "error", err) | |
| 107 | - | } | |
| 108 | - | return | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // If domain and containerID are provided, add a route | |
| 112 | - | if domain != "" && containerID != "" { | |
| 113 | - | if containerPort == "" { | |
| 114 | - | log.Fatal("Container port is required") | |
| 115 | - | } | |
| 116 | - | if protocol == "" { | |
| 117 | - | protocol = "http" | |
| 118 | - | } | |
| 119 | - | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | |
| 120 | - | log.Fatal("Failed to add route:", err) | |
| 121 | - | } | |
| 122 | - | log.Info("Route added successfully") | |
| 123 | - | ||
| 124 | - | // Close the database connection | |
| 125 | - | if err := a.Shutdown(); err != nil { | |
| 126 | - | log.Error("Error closing database", "error", err) | |
| 127 | - | } | |
| 128 | - | return | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // If no flags are set, list all routes | |
| 132 | - | if err := p.Reload(); err != nil { | |
| 133 | - | log.Fatal("Failed to load routes:", err) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | // List routes from the database | |
| 137 | - | // Use the query from the instantiated proxy queries | |
| 138 | - | rows, err := a.DB.Query(p.Queries.GetAllRoutes) | |
| 139 | - | if err != nil { | |
| 140 | - | log.Fatal("Failed to query routes:", err) | |
| 141 | - | } | |
| 142 | - | defer rows.Close() | |
| 143 | - | ||
| 144 | - | fmt.Println("--- Proxy Routes ---") | |
| 145 | - | fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n", "DOMAIN", "CONTAINER ID", "IP", "PORT", "ACTIVE") | |
| 146 | - | fmt.Println(strings.Repeat("-", 75)) | |
| 147 | - | ||
| 148 | - | var count int | |
| 149 | - | for rows.Next() { | |
| 150 | - | // Adjust variables to match the columns selected by GetAllRoutes | |
| 151 | - | var id, domain, containerID, containerIP, containerPort, protocol, path string | |
| 152 | - | var active bool | |
| 153 | - | // Adjust scan targets | |
| 154 | - | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |
| 155 | - | log.Fatal("Failed to scan row:", err) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // Format container ID to show just first 12 chars | |
| 159 | - | shortID := containerID | |
| 160 | - | if len(containerID) > 12 { | |
| 161 | - | shortID = containerID[:12] | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Format active status | |
| 165 | - | activeStr := "✓" | |
| 166 | - | if !active { | |
| 167 | - | activeStr = "✗" | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | fmt.Printf("%-30s %-12s %-15s %-6s %-7s\n", | |
| 171 | - | domain, shortID, containerIP, containerPort, activeStr) | |
| 172 | - | count++ | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | fmt.Println(strings.Repeat("-", 75)) | |
| 176 | - | fmt.Printf("Total routes: %d\n", count) | |
| 177 | - | ||
| 178 | - | // Close the database connection | |
| 179 | - | if err := a.Shutdown(); err != nil { | |
| 180 | - | log.Error("Error closing database", "error", err) | |
| 181 | - | } | |
| 182 | - | }, | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name") | |
| 186 | - | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | |
| 187 | - | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | |
| 188 | - | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | |
| 189 | - | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "", "Protocol (http or https)") | |
| 190 | - | proxyCmd.Flags().StringVarP(&path, "path", "a", "", "Path prefix") | |
| 191 | - | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove a route") | |
| 192 | - | proxyCmd.Flags().BoolVarP(&update, "update", "u", false, "Update a container's IP address") | |
| 193 | - | proxyCmd.Flags().BoolVarP(&force, "force", "f", false, "Force removal of a route") | |
| 194 | - | ||
| 195 | - | return proxyCmd | |
| 196 | - | } | |
D internal/cli/cmd/push.go +0 -136
| @@ -1,136 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "strings" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/cli" | |
| 15 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 16 | - | "github.com/bnema/gordon/internal/common" | |
| 17 | - | "github.com/charmbracelet/log" | |
| 18 | - | "github.com/spf13/cobra" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | func init() { | |
| 22 | - | log.SetReportTimestamp(true) | |
| 23 | - | log.SetTimeFormat("15:04") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func NewPushCommand(a *cli.App) *cobra.Command { | |
| 27 | - | var port string | |
| 28 | - | ||
| 29 | - | pushCmd := &cobra.Command{ | |
| 30 | - | Use: "push [image:tag]", | |
| 31 | - | Short: "Push an image to your remote Gordon instance", | |
| 32 | - | Args: cobra.ExactArgs(1), | |
| 33 | - | PreRun: func(cmd *cobra.Command, args []string) { | |
| 34 | - | if err := handler.FieldCheck(a); err != nil { | |
| 35 | - | log.Error("Field check failed", "error", err) | |
| 36 | - | os.Exit(1) | |
| 37 | - | } | |
| 38 | - | }, | |
| 39 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 40 | - | imageName := args[0] | |
| 41 | - | log.Info("Pushing image", "image", imageName) | |
| 42 | - | ||
| 43 | - | if err := validatePushInputs(imageName); err != nil { | |
| 44 | - | log.Error("Validation failed", "error", err) | |
| 45 | - | return | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | reader, actualSize, err := handler.ExportDockerImage(imageName) | |
| 49 | - | if err != nil { | |
| 50 | - | log.Error("Error exporting image", "error", err) | |
| 51 | - | return | |
| 52 | - | } | |
| 53 | - | defer reader.Close() | |
| 54 | - | ||
| 55 | - | sizeInMB := float64(actualSize) / 1024 / 1024 | |
| 56 | - | ||
| 57 | - | log.Info("Image exported successfully", "image", imageName, "size", fmt.Sprintf("%.2fMB", sizeInMB)) | |
| 58 | - | ||
| 59 | - | if err := pushImage(a, reader, imageName); err != nil { | |
| 60 | - | os.Exit(1) | |
| 61 | - | } | |
| 62 | - | }, | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | pushCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container (e.g., 8080:80/tcp)") | |
| 66 | - | ||
| 67 | - | return pushCmd | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func pushImage(a *cli.App, reader io.Reader, imageName string) error { | |
| 71 | - | // Check authentication early | |
| 72 | - | if err := handler.CheckAndRefreshAuth(a); err != nil { | |
| 73 | - | log.Error("Authentication check failed", "error", err) | |
| 74 | - | return err | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | var buf bytes.Buffer | |
| 78 | - | size, err := io.Copy(&buf, reader) | |
| 79 | - | if err != nil { | |
| 80 | - | return fmt.Errorf("failed to read image data: %w", err) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | imageReader := bytes.NewReader(buf.Bytes()) | |
| 84 | - | sizeInMB := float64(size) / 1024 / 1024 | |
| 85 | - | log.Info("Attempting to push...", | |
| 86 | - | "image", imageName, | |
| 87 | - | "size", fmt.Sprintf("%.2fMB", sizeInMB), | |
| 88 | - | ) | |
| 89 | - | ||
| 90 | - | headers := http.Header{ | |
| 91 | - | "X-Image-Name": {imageName}, | |
| 92 | - | "Content-Type": {"application/octet-stream"}, | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | chunkedClient := handler.NewChunkedClient(a) | |
| 96 | - | ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute) | |
| 97 | - | defer cancel() | |
| 98 | - | ||
| 99 | - | resp, err := chunkedClient.SendFileAsChunks(ctx, "/push/chunked", headers, imageReader, size, imageName) | |
| 100 | - | if err != nil { | |
| 101 | - | log.Error("Failed to send chunks", "error", err) | |
| 102 | - | return fmt.Errorf("failed to send chunks: %w", err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | if resp == nil { | |
| 106 | - | return fmt.Errorf("received nil response from server") | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | var pushResponse common.PushResponse | |
| 110 | - | if err := json.Unmarshal(resp.Body, &pushResponse); err != nil { | |
| 111 | - | log.Error("Error parsing response", "error", err) | |
| 112 | - | return fmt.Errorf("failed to parse response: %w", err) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | if !pushResponse.Success { | |
| 116 | - | log.Error("Push failed", "resp", pushResponse.Message) | |
| 117 | - | return fmt.Errorf("%s", pushResponse.Message) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // Remove the double quotes from the message | |
| 121 | - | message := strings.Trim(pushResponse.Message, "\"") | |
| 122 | - | log.Info(message) | |
| 123 | - | ||
| 124 | - | if pushResponse.CreateContainerURL != "" { | |
| 125 | - | log.Info("Container creation available", "url", pushResponse.CreateContainerURL) | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | func validatePushInputs(imageName string) error { | |
| 132 | - | if err := handler.ValidateImageName(imageName); err != nil { | |
| 133 | - | return err | |
| 134 | - | } | |
| 135 | - | return handler.EnsureImageTag(&imageName) | |
| 136 | - | } | |
D internal/cli/cmd/root.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | // the root command is the entrypoint for the gordon cli (default: client) | |
| 2 | - | package cmd | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "fmt" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli" | |
| 8 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 9 | - | "github.com/fatih/color" | |
| 10 | - | "github.com/spf13/cobra" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // NewRootCommand creates a new root command | |
| 14 | - | func NewRootCommand(a *cli.App) *cobra.Command { | |
| 15 | - | return &cobra.Command{ | |
| 16 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 17 | - | ||
| 18 | - | backendVersion := getBackendVersion(a) | |
| 19 | - | color.Green("Gordon %s", a.Config.GetVersion()) | |
| 20 | - | color.Blue("Gordon Backend %s", backendVersion) | |
| 21 | - | ||
| 22 | - | if backendVersion != a.Config.GetVersion() { | |
| 23 | - | color.Yellow("A new version of Gordon is available") | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | fmt.Println() | |
| 27 | - | fmt.Println("Use \"gordon --help\" for more information about a command.") | |
| 28 | - | }, | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func getBackendVersion(a *cli.App) string { | |
| 33 | - | pingResp, err := handler.PerformPingRequest(a) | |
| 34 | - | if err != nil { | |
| 35 | - | return fmt.Sprintf("Error: %s", err) | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | return pingResp.Version | |
| 39 | - | } | |
D internal/cli/cmd/serve.go +0 -57
| @@ -1,57 +0,0 @@ | |||
| 1 | - | // the serve command is used to start the gordon server | |
| 2 | - | // Path: internal/cli/cmd/serve.go | |
| 3 | - | package cmd | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "fmt" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | "github.com/bnema/gordon/internal/server" | |
| 11 | - | "github.com/bnema/gordon/pkg/docker" | |
| 12 | - | "github.com/bnema/gordon/pkg/logger" | |
| 13 | - | "github.com/spf13/cobra" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // NewServeCommand creates a new serve command | |
| 17 | - | func NewServeCommand(a *server.App) *cobra.Command { | |
| 18 | - | defaultport := "1323" | |
| 19 | - | var port string | |
| 20 | - | ||
| 21 | - | // if the server is running in a docker container, the default port is 8080 | |
| 22 | - | // (changed from 80 to avoid conflict with the reverse proxy) | |
| 23 | - | if docker.IsRunningInContainer() { | |
| 24 | - | defaultport = "8080" | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // if no flags -p or --port are specified, the default port is used | |
| 28 | - | serveCmd := &cobra.Command{ | |
| 29 | - | Use: "serve", | |
| 30 | - | Short: "Start a new Gordon server instance", | |
| 31 | - | Args: cobra.NoArgs, | |
| 32 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 33 | - | // Initialize Docker client here, before starting the server | |
| 34 | - | if err := common.DockerInit(&a.Config.ContainerEngine); err != nil { | |
| 35 | - | fmt.Printf("Warning: %s\n", err) | |
| 36 | - | fmt.Println("Continuing with Docker/Podman functionality disabled") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // handler.StartServer will use the value of port, which will be the default | |
| 40 | - | // value if no flag is specified | |
| 41 | - | a.Config.Http.Port = port | |
| 42 | - | err := handler.StartServer(a, port) | |
| 43 | - | if err != nil { | |
| 44 | - | logger.Error("Server error", "error", err) | |
| 45 | - | // Ensure database is closed on error | |
| 46 | - | if err := a.Shutdown(); err != nil { | |
| 47 | - | logger.Error("Error during shutdown after server error", "error", err) | |
| 48 | - | } | |
| 49 | - | panic(err) | |
| 50 | - | } | |
| 51 | - | }, | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Attach the flag to serveCmd and store its value in the variable port | |
| 55 | - | serveCmd.Flags().StringVarP(&port, "port", "p", defaultport, "port to listen on") | |
| 56 | - | return serveCmd | |
| 57 | - | } | |
D internal/cli/cmd/update.go +0 -473
| @@ -1,473 +0,0 @@ | |||
| 1 | - | // This upload feature is based on pocketbase's ghupdate command. | |
| 2 | - | // Thank you for your work, @Gani Georgiev | |
| 3 | - | // Check the original code here: https://github.com/pocketbase/pocketbase/blob/master/plugins/ghupdate/ghupdate.go | |
| 4 | - | package cmd | |
| 5 | - | ||
| 6 | - | import ( | |
| 7 | - | "archive/zip" | |
| 8 | - | "context" | |
| 9 | - | "encoding/json" | |
| 10 | - | "errors" | |
| 11 | - | "fmt" | |
| 12 | - | "io" | |
| 13 | - | "log" | |
| 14 | - | "net/http" | |
| 15 | - | "os" | |
| 16 | - | "path/filepath" | |
| 17 | - | "runtime" | |
| 18 | - | "strconv" | |
| 19 | - | "strings" | |
| 20 | - | ||
| 21 | - | "github.com/AlecAivazis/survey/v2" | |
| 22 | - | "github.com/bnema/gordon/internal/cli" | |
| 23 | - | "github.com/bnema/gordon/internal/cli/handler" | |
| 24 | - | "github.com/bnema/gordon/pkg/docker" | |
| 25 | - | "github.com/fatih/color" | |
| 26 | - | "github.com/spf13/cobra" | |
| 27 | - | ) | |
| 28 | - | ||
| 29 | - | type HttpClient interface { | |
| 30 | - | Do(req *http.Request) (*http.Response, error) | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | type Config struct { | |
| 34 | - | Owner string | |
| 35 | - | Repo string | |
| 36 | - | ArchiveExecutable string | |
| 37 | - | Context context.Context | |
| 38 | - | HttpClient HttpClient | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | var pluginInstance *plugin | |
| 42 | - | ||
| 43 | - | type plugin struct { | |
| 44 | - | app *cli.App | |
| 45 | - | currentVersion string | |
| 46 | - | config Config | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | func NewUpdateCommand(a *cli.App) *cobra.Command { | |
| 50 | - | ||
| 51 | - | pluginInstance = &plugin{ | |
| 52 | - | app: a, | |
| 53 | - | currentVersion: a.Config.Build.BuildVersion, | |
| 54 | - | config: Config{ | |
| 55 | - | Owner: "bnema", | |
| 56 | - | Repo: "gordon", | |
| 57 | - | ArchiveExecutable: "gordon", | |
| 58 | - | Context: context.Background(), | |
| 59 | - | HttpClient: http.DefaultClient, | |
| 60 | - | }, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | if pluginInstance.app != a { | |
| 64 | - | panic("app instance is not the same") | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | if a == nil { | |
| 68 | - | panic("app instance is nil") | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | command := &cobra.Command{ | |
| 72 | - | Use: "update", | |
| 73 | - | Short: "Update the Gordon executable", | |
| 74 | - | SilenceErrors: true, | |
| 75 | - | SilenceUsage: true, | |
| 76 | - | RunE: func(command *cobra.Command, args []string) error { | |
| 77 | - | var needConfirm bool | |
| 78 | - | if docker.IsRunningInContainer() { | |
| 79 | - | needConfirm = true | |
| 80 | - | color.Yellow("It seems that you are in a Docker container.") | |
| 81 | - | color.Yellow("The update command may not work as expected in this context because usually the version of the app is managed by the container image itself.") | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | if needConfirm { | |
| 85 | - | confirm := false | |
| 86 | - | prompt := &survey.Confirm{ | |
| 87 | - | Message: "Do you want to proceed with the update?", | |
| 88 | - | } | |
| 89 | - | survey.AskOne(prompt, &confirm) | |
| 90 | - | if !confirm { | |
| 91 | - | fmt.Println("The command has been cancelled.") | |
| 92 | - | return nil | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Vérifier si l'utilisateur est root | |
| 97 | - | if isRoot() { | |
| 98 | - | color.Yellow("Running update as root. Skipping configuration check.") | |
| 99 | - | return pluginInstance.update() | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // Si non root, effectuer la vérification des champs | |
| 103 | - | if err := handler.FieldCheck(a); err != nil { | |
| 104 | - | return fmt.Errorf("field check failed: %w", err) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | return pluginInstance.update() | |
| 108 | - | }, | |
| 109 | - | } | |
| 110 | - | return command | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (p *plugin) update() error { | |
| 114 | - | color.Yellow("Fetching release information...") | |
| 115 | - | ||
| 116 | - | latest, err := fetchLatestRelease( | |
| 117 | - | p.config.Context, | |
| 118 | - | p.config.HttpClient, | |
| 119 | - | p.config.Owner, | |
| 120 | - | p.config.Repo, | |
| 121 | - | ) | |
| 122 | - | if err != nil { | |
| 123 | - | return err | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | if compareVersions(strings.TrimPrefix(p.currentVersion, "v"), strings.TrimPrefix(latest.Tag, "v")) <= 0 { | |
| 127 | - | color.Green("You already have the latest version %s.", p.currentVersion) | |
| 128 | - | return nil | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Prompt user for update | |
| 132 | - | confirm := false | |
| 133 | - | prompt := &survey.Confirm{ | |
| 134 | - | Message: fmt.Sprintf("A new version (%s) is available. Do you want to update?", latest.Tag), | |
| 135 | - | } | |
| 136 | - | survey.AskOne(prompt, &confirm) | |
| 137 | - | if !confirm { | |
| 138 | - | fmt.Println("Update cancelled by user.") | |
| 139 | - | return nil | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | suffix := archiveSuffix(runtime.GOOS, runtime.GOARCH) | |
| 143 | - | if suffix == "" { | |
| 144 | - | return errors.New("unsupported platform") | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | asset, err := latest.findAssetBySuffix(suffix) | |
| 148 | - | if err != nil { | |
| 149 | - | return err | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // temporary file is under common.Config.General.StorageDir | |
| 153 | - | releaseDir := filepath.Join(p.app.Config.General.StorageDir, "update") | |
| 154 | - | defer os.RemoveAll(releaseDir) | |
| 155 | - | ||
| 156 | - | color.Yellow("Downloading %s...", asset.Name) | |
| 157 | - | ||
| 158 | - | // download the release asset | |
| 159 | - | assetZip := filepath.Join(releaseDir, asset.Name) | |
| 160 | - | if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetZip); err != nil { | |
| 161 | - | return err | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | color.Yellow("Extracting %s...", asset.Name) | |
| 165 | - | ||
| 166 | - | extractDir := filepath.Join(releaseDir, "extracted_"+asset.Name) | |
| 167 | - | defer os.RemoveAll(extractDir) | |
| 168 | - | ||
| 169 | - | if err := extractZip(assetZip, extractDir); err != nil { | |
| 170 | - | return err | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | color.Yellow("Replacing the executable...") | |
| 174 | - | ||
| 175 | - | oldExec, err := os.Executable() | |
| 176 | - | if err != nil { | |
| 177 | - | return err | |
| 178 | - | } | |
| 179 | - | renamedOldExec := oldExec + ".old" | |
| 180 | - | defer os.Remove(renamedOldExec) | |
| 181 | - | ||
| 182 | - | // Check if the user has write permissions | |
| 183 | - | if err := checkWritePermission(oldExec); err != nil { | |
| 184 | - | color.Yellow("Administrator rights are required to perform the update.") | |
| 185 | - | color.Yellow("Please run the following command with sudo:") | |
| 186 | - | color.HiBlack("sudo gordon update") | |
| 187 | - | return nil | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | newExec := filepath.Join(extractDir, p.config.ArchiveExecutable) | |
| 191 | - | if _, err := os.Stat(newExec); err != nil { | |
| 192 | - | // try again with an .exe extension | |
| 193 | - | newExec = newExec + ".exe" | |
| 194 | - | if _, fallbackErr := os.Stat(newExec); fallbackErr != nil { | |
| 195 | - | return fmt.Errorf("the executable in the extracted path is missing or it is inaccessible: %v, %v", err, fallbackErr) | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // rename the current executable | |
| 200 | - | if err := os.Rename(oldExec, renamedOldExec); err != nil { | |
| 201 | - | return fmt.Errorf("failed to rename the current executable: %w", err) | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | tryToRevertExecChanges := func() { | |
| 205 | - | if revertErr := os.Rename(renamedOldExec, oldExec); revertErr != nil { | |
| 206 | - | log.Println(revertErr) | |
| 207 | - | } | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | // replace with the extracted binary | |
| 211 | - | if err := os.Rename(newExec, oldExec); err != nil { | |
| 212 | - | tryToRevertExecChanges() | |
| 213 | - | return fmt.Errorf("failed replacing the executable: %w", err) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | color.HiBlack("---") | |
| 217 | - | color.Green("Update completed successfully! You can start the executable as usual.") | |
| 218 | - | ||
| 219 | - | return nil | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | func (r *release) findAssetBySuffix(suffix string) (*asset, error) { | |
| 223 | - | for _, asset := range r.Assets { | |
| 224 | - | if strings.HasSuffix(asset.Name, suffix) { | |
| 225 | - | return &asset, nil | |
| 226 | - | } | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | return nil, fmt.Errorf("no asset found with suffix %q", suffix) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | type release struct { | |
| 233 | - | Tag string `json:"tag_name"` | |
| 234 | - | Assets []asset `json:"assets"` | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | type asset struct { | |
| 238 | - | Name string `json:"name"` | |
| 239 | - | DownloadUrl string `json:"browser_download_url"` | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | func fetchLatestRelease( | |
| 243 | - | ctx context.Context, | |
| 244 | - | client HttpClient, | |
| 245 | - | owner string, | |
| 246 | - | repo string, | |
| 247 | - | ) (*release, error) { | |
| 248 | - | url := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) | |
| 249 | - | ||
| 250 | - | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 251 | - | if err != nil { | |
| 252 | - | return nil, err | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | res, err := client.Do(req) | |
| 256 | - | if err != nil { | |
| 257 | - | return nil, err | |
| 258 | - | } | |
| 259 | - | defer res.Body.Close() | |
| 260 | - | ||
| 261 | - | rawBody, err := io.ReadAll(res.Body) | |
| 262 | - | if err != nil { | |
| 263 | - | return nil, err | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // http.Client doesn't treat non 2xx responses as error | |
| 267 | - | if res.StatusCode >= 400 { | |
| 268 | - | return nil, fmt.Errorf( | |
| 269 | - | "(%d) failed to fetch latest releases:\n%s", | |
| 270 | - | res.StatusCode, | |
| 271 | - | string(rawBody), | |
| 272 | - | ) | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | result := &release{} | |
| 276 | - | if err := json.Unmarshal(rawBody, result); err != nil { | |
| 277 | - | return nil, err | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | return result, nil | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | func downloadFile( | |
| 284 | - | ctx context.Context, | |
| 285 | - | client HttpClient, | |
| 286 | - | url string, | |
| 287 | - | destPath string, | |
| 288 | - | ) error { | |
| 289 | - | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) | |
| 290 | - | if err != nil { | |
| 291 | - | return err | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | res, err := client.Do(req) | |
| 295 | - | if err != nil { | |
| 296 | - | return err | |
| 297 | - | } | |
| 298 | - | defer res.Body.Close() | |
| 299 | - | ||
| 300 | - | // http.Client doesn't treat non 2xx responses as error | |
| 301 | - | if res.StatusCode >= 400 { | |
| 302 | - | return fmt.Errorf("(%d) failed to send download file request", res.StatusCode) | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | // ensure that the dest parent dir(s) exist | |
| 306 | - | if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil { | |
| 307 | - | return err | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | dest, err := os.Create(destPath) | |
| 311 | - | if err != nil { | |
| 312 | - | return err | |
| 313 | - | } | |
| 314 | - | defer dest.Close() | |
| 315 | - | ||
| 316 | - | if _, err := io.Copy(dest, res.Body); err != nil { | |
| 317 | - | return err | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | return nil | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | // archives: | |
| 324 | - | // - format: zip | |
| 325 | - | // name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" | |
| 326 | - | ||
| 327 | - | func archiveSuffix(goos, goarch string) string { | |
| 328 | - | // Define a map to hold the combinations of goos and goarch. | |
| 329 | - | var archMap = map[string]map[string]string{ | |
| 330 | - | "linux": { | |
| 331 | - | "amd64": "_linux_amd64.zip", | |
| 332 | - | "arm64": "_linux_arm64.zip", | |
| 333 | - | "arm": "_linux_armv7.zip", | |
| 334 | - | }, | |
| 335 | - | "darwin": { | |
| 336 | - | "amd64": "_darwin_amd64.zip", | |
| 337 | - | "arm64": "_darwin_arm64.zip", | |
| 338 | - | }, | |
| 339 | - | "windows": { | |
| 340 | - | "amd64": "_windows_amd64.zip", | |
| 341 | - | "arm64": "_windows_arm64.zip", | |
| 342 | - | }, | |
| 343 | - | } | |
| 344 | - | ||
| 345 | - | // Look up goos then goarch. | |
| 346 | - | if arch, ok := archMap[goos]; ok { | |
| 347 | - | if suffix, ok := arch[goarch]; ok { | |
| 348 | - | return suffix | |
| 349 | - | } | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | // Return a default value if no match is found. | |
| 353 | - | return "" | |
| 354 | - | } | |
| 355 | - | func compareVersions(a, b string) int { | |
| 356 | - | aSplit := strings.Split(a, ".") | |
| 357 | - | aTotal := len(aSplit) | |
| 358 | - | ||
| 359 | - | bSplit := strings.Split(b, ".") | |
| 360 | - | bTotal := len(bSplit) | |
| 361 | - | ||
| 362 | - | limit := aTotal | |
| 363 | - | if bTotal > aTotal { | |
| 364 | - | limit = bTotal | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | for i := 0; i < limit; i++ { | |
| 368 | - | var x, y int | |
| 369 | - | ||
| 370 | - | if i < aTotal { | |
| 371 | - | x, _ = strconv.Atoi(aSplit[i]) | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | if i < bTotal { | |
| 375 | - | y, _ = strconv.Atoi(bSplit[i]) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | if x < y { | |
| 379 | - | return 1 // b is newer | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | if x > y { | |
| 383 | - | return -1 // a is newer | |
| 384 | - | } | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | return 0 // equal | |
| 388 | - | } | |
| 389 | - | ||
| 390 | - | // New function to check write permissions | |
| 391 | - | func checkWritePermission(path string) error { | |
| 392 | - | // Try to create a temporary file in the same directory | |
| 393 | - | dir := filepath.Dir(path) | |
| 394 | - | tmpFile := filepath.Join(dir, ".gordon_update_test") | |
| 395 | - | f, err := os.Create(tmpFile) | |
| 396 | - | if err != nil { | |
| 397 | - | return err | |
| 398 | - | } | |
| 399 | - | f.Close() | |
| 400 | - | os.Remove(tmpFile) | |
| 401 | - | return nil | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | func isRoot() bool { | |
| 405 | - | return os.Geteuid() == 0 | |
| 406 | - | } | |
| 407 | - | ||
| 408 | - | // extractZip extracts a zip file to a destination directory | |
| 409 | - | func extractZip(zipPath, destPath string) error { | |
| 410 | - | // Open the zip file | |
| 411 | - | reader, err := zip.OpenReader(zipPath) | |
| 412 | - | if err != nil { | |
| 413 | - | return fmt.Errorf("failed to open zip file: %w", err) | |
| 414 | - | } | |
| 415 | - | defer reader.Close() | |
| 416 | - | ||
| 417 | - | // Create destination directory if it doesn't exist | |
| 418 | - | if err := os.MkdirAll(destPath, os.ModePerm); err != nil { | |
| 419 | - | return fmt.Errorf("failed to create destination directory: %w", err) | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | // Extract each file | |
| 423 | - | for _, file := range reader.File { | |
| 424 | - | // Construct the full path for the file | |
| 425 | - | path := filepath.Join(destPath, file.Name) | |
| 426 | - | ||
| 427 | - | // Check for path traversal vulnerabilities | |
| 428 | - | if !strings.HasPrefix(path, filepath.Clean(destPath)+string(os.PathSeparator)) { | |
| 429 | - | return fmt.Errorf("illegal file path in zip: %s", file.Name) | |
| 430 | - | } | |
| 431 | - | ||
| 432 | - | // Create directory for file | |
| 433 | - | if file.FileInfo().IsDir() { | |
| 434 | - | if err := os.MkdirAll(path, os.ModePerm); err != nil { | |
| 435 | - | return fmt.Errorf("failed to create directory: %w", err) | |
| 436 | - | } | |
| 437 | - | continue | |
| 438 | - | } | |
| 439 | - | ||
| 440 | - | // Create parent directory if it doesn't exist | |
| 441 | - | if err := os.MkdirAll(filepath.Dir(path), os.ModePerm); err != nil { | |
| 442 | - | return fmt.Errorf("failed to create directory structure: %w", err) | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | // Create file | |
| 446 | - | destFile, err := os.Create(path) | |
| 447 | - | if err != nil { | |
| 448 | - | return fmt.Errorf("failed to create file: %w", err) | |
| 449 | - | } | |
| 450 | - | ||
| 451 | - | // Open the file in the zip | |
| 452 | - | srcFile, err := file.Open() | |
| 453 | - | if err != nil { | |
| 454 | - | destFile.Close() | |
| 455 | - | return fmt.Errorf("failed to open file in zip: %w", err) | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Copy the contents | |
| 459 | - | _, err = io.Copy(destFile, srcFile) | |
| 460 | - | srcFile.Close() | |
| 461 | - | destFile.Close() | |
| 462 | - | if err != nil { | |
| 463 | - | return fmt.Errorf("failed to extract file: %w", err) | |
| 464 | - | } | |
| 465 | - | ||
| 466 | - | // Preserve file permissions | |
| 467 | - | if err := os.Chmod(path, file.Mode()); err != nil { | |
| 468 | - | return fmt.Errorf("failed to set file permissions: %w", err) | |
| 469 | - | } | |
| 470 | - | } | |
| 471 | - | ||
| 472 | - | return nil | |
| 473 | - | } | |
D internal/cli/cmd/version.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | package cmd | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/cli" | |
| 5 | - | "github.com/bnema/gordon/internal/common" | |
| 6 | - | "github.com/bnema/gordon/pkg/logger" | |
| 7 | - | "github.com/spf13/cobra" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func NewVersionCommand(app *cli.App) *cobra.Command { | |
| 11 | - | cmd := &cobra.Command{ | |
| 12 | - | Use: "version", | |
| 13 | - | Short: "Print version information", | |
| 14 | - | Run: func(cmd *cobra.Command, args []string) { | |
| 15 | - | info := common.GetVersionInfo( | |
| 16 | - | app.Config.Build.BuildVersion, | |
| 17 | - | app.Config.Build.BuildCommit, | |
| 18 | - | app.Config.Build.BuildDate, | |
| 19 | - | app.Config.Build.ProxyURL, | |
| 20 | - | ) | |
| 21 | - | logger.Info(info.String()) | |
| 22 | - | ||
| 23 | - | hasUpdate, latestVersion, err := common.CheckForNewVersion( | |
| 24 | - | info.Version, | |
| 25 | - | info.ProxyURL, | |
| 26 | - | ) | |
| 27 | - | if err != nil { | |
| 28 | - | logger.Error("Error checking for updates", "error", err) | |
| 29 | - | return | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | if hasUpdate { | |
| 33 | - | logger.Info("A new version is available", "version", latestVersion) | |
| 34 | - | logger.Info("You can update using: gordon update") | |
| 35 | - | } | |
| 36 | - | }, | |
| 37 | - | } | |
| 38 | - | return cmd | |
| 39 | - | } | |
D internal/cli/handler/auth.go +0 -78
| @@ -1,78 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/cli" | |
| 8 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | "github.com/charmbracelet/log" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func CheckAndRefreshAuth(a *cli.App) error { | |
| 14 | - | if a.Config.General.JwtToken == "" { | |
| 15 | - | log.Info("No authentication token found. Initiating authentication...") | |
| 16 | - | return ReAuthenticate(a) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | resp, err := SendHTTPRequest(a, &common.RequestPayload{ | |
| 20 | - | Type: "ping", | |
| 21 | - | Payload: common.PingPayload{ | |
| 22 | - | Message: "ping", | |
| 23 | - | }, | |
| 24 | - | }, "GET", "/ping") | |
| 25 | - | ||
| 26 | - | if err != nil { | |
| 27 | - | log.Debug("Token validation request failed", "error", err) | |
| 28 | - | return ReAuthenticate(a) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // If we get a 200 status code, and the resp contains message = pong, we're good | |
| 32 | - | if resp.StatusCode != 200 { | |
| 33 | - | log.Debug("Token validation failed", | |
| 34 | - | "status_code", resp.StatusCode, | |
| 35 | - | "response", resp.Body) | |
| 36 | - | return ReAuthenticate(a) | |
| 37 | - | ||
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return nil | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | func ReAuthenticate(a *cli.App) error { | |
| 44 | - | fmt.Println("Re-authenticating...") | |
| 45 | - | ||
| 46 | - | // Small delay for visual clarity | |
| 47 | - | time.Sleep(100 * time.Millisecond) | |
| 48 | - | ||
| 49 | - | token, err := auth.DeviceFlowAuth(a) | |
| 50 | - | ||
| 51 | - | if err != nil { | |
| 52 | - | return fmt.Errorf("device flow authentication failed: %w", err) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // if token is the same as the current one, no need to save | |
| 56 | - | if token == a.Config.General.JwtToken { | |
| 57 | - | fmt.Println("Token is still valid, no changes made.") | |
| 58 | - | return nil | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Update the JWTSecret in the config | |
| 62 | - | a.Config.General.JwtToken = token | |
| 63 | - | ||
| 64 | - | // Save the new token to config file | |
| 65 | - | err = a.Config.SaveConfig() | |
| 66 | - | if err != nil { | |
| 67 | - | return fmt.Errorf("failed to save new token to config: %w", err) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // Clear any remaining auth UI elements | |
| 71 | - | fmt.Print("\033[2K\r") | |
| 72 | - | fmt.Println("Re-authentication successful.") | |
| 73 | - | ||
| 74 | - | // Add delay before resuming upload display | |
| 75 | - | time.Sleep(500 * time.Millisecond) | |
| 76 | - | ||
| 77 | - | return nil | |
| 78 | - | } | |
D internal/cli/handler/chunked_transfer.go +0 -568
| @@ -1,568 +0,0 @@ | |||
| 1 | - | // internal/cli/handler/progress_display.go | |
| 2 | - | ||
| 3 | - | package handler | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "bytes" | |
| 7 | - | "context" | |
| 8 | - | "crypto/sha256" | |
| 9 | - | "encoding/json" | |
| 10 | - | "errors" | |
| 11 | - | "fmt" | |
| 12 | - | "io" | |
| 13 | - | "net" | |
| 14 | - | "net/http" | |
| 15 | - | "os" | |
| 16 | - | "os/signal" | |
| 17 | - | "strings" | |
| 18 | - | "sync" | |
| 19 | - | "syscall" | |
| 20 | - | "time" | |
| 21 | - | ||
| 22 | - | "github.com/bnema/gordon/internal/cli" | |
| 23 | - | "github.com/bnema/gordon/internal/common" | |
| 24 | - | "github.com/charmbracelet/bubbles/spinner" | |
| 25 | - | tea "github.com/charmbracelet/bubbletea" | |
| 26 | - | "github.com/charmbracelet/lipgloss" | |
| 27 | - | "github.com/charmbracelet/log" | |
| 28 | - | "github.com/google/uuid" | |
| 29 | - | "golang.org/x/exp/rand" | |
| 30 | - | ) | |
| 31 | - | ||
| 32 | - | type DeployResponse = common.DeployResponse | |
| 33 | - | ||
| 34 | - | // UploadProgress represents the current state of the upload | |
| 35 | - | type UploadProgress struct { | |
| 36 | - | Completed int | |
| 37 | - | Total int | |
| 38 | - | Percentage float64 | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // ProgressDisplay manages the upload progress display | |
| 42 | - | type ProgressDisplay struct { | |
| 43 | - | mu sync.Mutex | |
| 44 | - | model *uploadModel | |
| 45 | - | program *tea.Program | |
| 46 | - | updateChan chan UploadProgress | |
| 47 | - | done chan struct{} | |
| 48 | - | paused bool | |
| 49 | - | pauseChan chan bool | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | type uploadModel struct { | |
| 53 | - | spinner spinner.Model | |
| 54 | - | completed int | |
| 55 | - | total int | |
| 56 | - | percentage float64 | |
| 57 | - | done bool | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // NewProgressDisplay creates a new progress display | |
| 61 | - | func NewProgressDisplay() *ProgressDisplay { | |
| 62 | - | return &ProgressDisplay{ | |
| 63 | - | updateChan: make(chan UploadProgress), | |
| 64 | - | done: make(chan struct{}), | |
| 65 | - | pauseChan: make(chan bool), | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | func (pd *ProgressDisplay) Start() { | |
| 70 | - | pd.mu.Lock() | |
| 71 | - | defer pd.mu.Unlock() | |
| 72 | - | ||
| 73 | - | pd.model = &uploadModel{ | |
| 74 | - | spinner: spinner.New( | |
| 75 | - | spinner.WithSpinner(spinner.MiniDot), | |
| 76 | - | spinner.WithStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("69"))), | |
| 77 | - | ), | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | pd.program = tea.NewProgram(pd.model) | |
| 81 | - | ||
| 82 | - | // Run the UI in a goroutine | |
| 83 | - | go func() { | |
| 84 | - | if _, err := pd.program.Run(); err != nil { | |
| 85 | - | fmt.Printf("Error running progress display: %v\n", err) | |
| 86 | - | } | |
| 87 | - | }() | |
| 88 | - | ||
| 89 | - | // Handle updates in a separate goroutine | |
| 90 | - | go pd.handleUpdates() | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func (pd *ProgressDisplay) Pause() { | |
| 94 | - | pd.mu.Lock() | |
| 95 | - | defer pd.mu.Unlock() | |
| 96 | - | pd.paused = true | |
| 97 | - | pd.Clear() // Clear the current display | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | func (pd *ProgressDisplay) Resume() { | |
| 101 | - | pd.mu.Lock() | |
| 102 | - | defer pd.mu.Unlock() | |
| 103 | - | pd.paused = false | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | func (pd *ProgressDisplay) handleUpdates() { | |
| 107 | - | for { | |
| 108 | - | select { | |
| 109 | - | case progress := <-pd.updateChan: | |
| 110 | - | pd.mu.Lock() | |
| 111 | - | if !pd.paused { | |
| 112 | - | pd.program.Send(progressMsg{ | |
| 113 | - | completed: progress.Completed, | |
| 114 | - | total: progress.Total, | |
| 115 | - | percentage: progress.Percentage, | |
| 116 | - | }) | |
| 117 | - | } | |
| 118 | - | pd.mu.Unlock() | |
| 119 | - | case <-pd.done: | |
| 120 | - | pd.program.Send(doneMsg{}) | |
| 121 | - | return | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func (pd *ProgressDisplay) Update(completed, total int, percentage float64) { | |
| 127 | - | pd.updateChan <- UploadProgress{ | |
| 128 | - | Completed: completed, | |
| 129 | - | Total: total, | |
| 130 | - | Percentage: percentage, | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | func (pd *ProgressDisplay) Stop() { | |
| 135 | - | close(pd.done) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | func (pd *ProgressDisplay) Clear() { | |
| 139 | - | pd.mu.Lock() | |
| 140 | - | defer pd.mu.Unlock() | |
| 141 | - | ||
| 142 | - | // Send a special clear message | |
| 143 | - | pd.program.Send(clearMsg{}) | |
| 144 | - | // Wait a brief moment for the clear to take effect | |
| 145 | - | time.Sleep(50 * time.Millisecond) | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | type progressMsg struct { | |
| 149 | - | completed int | |
| 150 | - | total int | |
| 151 | - | percentage float64 | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | type doneMsg struct{} | |
| 155 | - | ||
| 156 | - | type clearMsg struct{} | |
| 157 | - | ||
| 158 | - | func (m *uploadModel) Init() tea.Cmd { | |
| 159 | - | return m.spinner.Tick | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | func (m *uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { | |
| 163 | - | switch msg := msg.(type) { | |
| 164 | - | case tea.KeyMsg: | |
| 165 | - | if msg.Type == tea.KeyCtrlC { | |
| 166 | - | return m, tea.Quit | |
| 167 | - | } | |
| 168 | - | case spinner.TickMsg: | |
| 169 | - | var cmd tea.Cmd | |
| 170 | - | m.spinner, cmd = m.spinner.Update(msg) | |
| 171 | - | return m, cmd | |
| 172 | - | case progressMsg: | |
| 173 | - | m.completed = msg.completed | |
| 174 | - | m.total = msg.total | |
| 175 | - | m.percentage = msg.percentage | |
| 176 | - | return m, m.spinner.Tick | |
| 177 | - | case doneMsg: | |
| 178 | - | m.done = true | |
| 179 | - | return m, tea.Quit | |
| 180 | - | case clearMsg: | |
| 181 | - | m.done = true | |
| 182 | - | return m, tea.Quit | |
| 183 | - | } | |
| 184 | - | return m, nil | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | func (m *uploadModel) View() string { | |
| 188 | - | if m.done { | |
| 189 | - | return "" | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | return fmt.Sprintf("\r%s Uploading image... %d/%d chunks (%.2f%%)", | |
| 193 | - | m.spinner.View(), | |
| 194 | - | m.completed, | |
| 195 | - | m.total, | |
| 196 | - | m.percentage, | |
| 197 | - | ) | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | type ChunkMetadata = common.ChunkMetadata | |
| 201 | - | ||
| 202 | - | type ChunkedClient struct { | |
| 203 | - | client *http.Client | |
| 204 | - | baseURL string | |
| 205 | - | token string | |
| 206 | - | chunkSize int64 | |
| 207 | - | app *cli.App | |
| 208 | - | progress *ProgressDisplay | |
| 209 | - | cancelFunc context.CancelFunc | |
| 210 | - | } | |
| 211 | - | ||
| 212 | - | type transferStore struct { | |
| 213 | - | mu sync.Mutex | |
| 214 | - | chunks map[string]map[int][]byte | |
| 215 | - | metadata map[string]*ChunkMetadata | |
| 216 | - | started map[string]time.Time | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | var clientChunkStore = &transferStore{ | |
| 220 | - | chunks: make(map[string]map[int][]byte), | |
| 221 | - | metadata: make(map[string]*ChunkMetadata), | |
| 222 | - | started: make(map[string]time.Time), | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | // NewChunkedClient creates a new chunked client | |
| 226 | - | func NewChunkedClient(app *cli.App) *ChunkedClient { | |
| 227 | - | baseURL := app.Config.Http.BackendURL + "/api" | |
| 228 | - | token := app.Config.General.JwtToken | |
| 229 | - | ||
| 230 | - | return &ChunkedClient{ | |
| 231 | - | client: &http.Client{ | |
| 232 | - | Timeout: 30 * time.Minute, | |
| 233 | - | }, | |
| 234 | - | baseURL: baseURL, | |
| 235 | - | token: token, | |
| 236 | - | chunkSize: 5 * 1024 * 1024, | |
| 237 | - | app: app, | |
| 238 | - | progress: NewProgressDisplay(), | |
| 239 | - | } | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // SendFileAsChunks sends a file as chunks to the specified endpoint | |
| 243 | - | func (c *ChunkedClient) SendFileAsChunks(ctx context.Context, endpoint string, headers http.Header, reader io.Reader, totalSize int64, imageName string) (*Response, error) { | |
| 244 | - | ctx, cancel := context.WithCancel(ctx) | |
| 245 | - | c.cancelFunc = cancel | |
| 246 | - | defer cancel() | |
| 247 | - | ||
| 248 | - | // Set up signal handling | |
| 249 | - | sigChan := make(chan os.Signal, 1) | |
| 250 | - | signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) | |
| 251 | - | defer signal.Stop(sigChan) | |
| 252 | - | ||
| 253 | - | // Start progress display | |
| 254 | - | c.progress.Start() | |
| 255 | - | defer c.progress.Stop() | |
| 256 | - | ||
| 257 | - | // Handle signals in a goroutine | |
| 258 | - | go func() { | |
| 259 | - | select { | |
| 260 | - | case <-sigChan: | |
| 261 | - | log.Info("Received interrupt signal, cancelling upload...") | |
| 262 | - | cancel() | |
| 263 | - | case <-ctx.Done(): | |
| 264 | - | return | |
| 265 | - | } | |
| 266 | - | }() | |
| 267 | - | ||
| 268 | - | transferID := uuid.New().String() | |
| 269 | - | totalChunks := (int(totalSize) + int(c.chunkSize) - 1) / int(c.chunkSize) | |
| 270 | - | buf := make([]byte, c.chunkSize) | |
| 271 | - | ||
| 272 | - | // Initialize transfer tracking | |
| 273 | - | clientChunkStore.mu.Lock() | |
| 274 | - | clientChunkStore.started[transferID] = time.Now() | |
| 275 | - | clientChunkStore.mu.Unlock() | |
| 276 | - | ||
| 277 | - | defer cleanupClientTransfer(transferID) | |
| 278 | - | ||
| 279 | - | var lastResponse *Response | |
| 280 | - | for chunkNum := 0; chunkNum < totalChunks; chunkNum++ { | |
| 281 | - | select { | |
| 282 | - | case <-ctx.Done(): | |
| 283 | - | return nil, fmt.Errorf("upload cancelled") | |
| 284 | - | default: | |
| 285 | - | n, err := io.ReadFull(reader, buf) | |
| 286 | - | if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF { | |
| 287 | - | return nil, fmt.Errorf("failed to read chunk %d: %w", chunkNum, err) | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | if n == 0 { | |
| 291 | - | continue | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | chunk := buf[:n] | |
| 295 | - | metadata := common.ChunkMetadata{ | |
| 296 | - | ChunkNumber: chunkNum, | |
| 297 | - | TotalChunks: totalChunks, | |
| 298 | - | ChunkSize: int64(n), | |
| 299 | - | TotalSize: totalSize, | |
| 300 | - | ImageName: imageName, | |
| 301 | - | TransferID: transferID, | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | resp, err := c.sendChunkWithRetry(ctx, endpoint, headers, chunk, metadata) | |
| 305 | - | if err != nil { | |
| 306 | - | return nil, fmt.Errorf("failed to send chunk %d: %w", chunkNum, err) | |
| 307 | - | } | |
| 308 | - | lastResponse = resp | |
| 309 | - | ||
| 310 | - | // Update progress | |
| 311 | - | c.progress.Update(chunkNum+1, totalChunks, float64(chunkNum+1)/float64(totalChunks)*100) | |
| 312 | - | } | |
| 313 | - | } | |
| 314 | - | ||
| 315 | - | if lastResponse != nil { | |
| 316 | - | c.progress.Clear() | |
| 317 | - | responseType := determineResponseType(endpoint) | |
| 318 | - | if err := handleFinalResponse(lastResponse, responseType); err != nil { | |
| 319 | - | log.Error("Failed to handle final response", "error", err) | |
| 320 | - | } | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | return lastResponse, nil | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | // sendChunkWithRetry sends a single chunk to the server and retries on failure | |
| 327 | - | func (c *ChunkedClient) sendChunkWithRetry(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 328 | - | maxRetries := 3 | |
| 329 | - | backoff := time.Second | |
| 330 | - | ||
| 331 | - | isRetryableError := func(err error) bool { | |
| 332 | - | if err == nil { | |
| 333 | - | return false | |
| 334 | - | } | |
| 335 | - | ||
| 336 | - | if netErr, ok := err.(net.Error); ok { | |
| 337 | - | return netErr.Timeout() | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { | |
| 341 | - | return false | |
| 342 | - | } | |
| 343 | - | ||
| 344 | - | errorStr := err.Error() | |
| 345 | - | retryableErrors := []string{ | |
| 346 | - | "connection reset by peer", | |
| 347 | - | "broken pipe", | |
| 348 | - | "connection refused", | |
| 349 | - | "no such host", | |
| 350 | - | "i/o timeout", | |
| 351 | - | "unexpected EOF", | |
| 352 | - | "connection timed out", | |
| 353 | - | "network is unreachable", | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | for _, retryableErr := range retryableErrors { | |
| 357 | - | if strings.Contains(strings.ToLower(errorStr), retryableErr) { | |
| 358 | - | return true | |
| 359 | - | } | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | return false | |
| 363 | - | } | |
| 364 | - | ||
| 365 | - | var lastErr error | |
| 366 | - | for attempt := 0; attempt < maxRetries; attempt++ { | |
| 367 | - | resp, err := c.sendChunk(ctx, endpoint, headers, chunk, metadata) | |
| 368 | - | if err == nil { | |
| 369 | - | return resp, nil | |
| 370 | - | } | |
| 371 | - | ||
| 372 | - | lastErr = err | |
| 373 | - | ||
| 374 | - | if !isRetryableError(err) { | |
| 375 | - | return nil, err | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | if attempt < maxRetries-1 { | |
| 379 | - | retryDelay := backoff * time.Duration(attempt+1) | |
| 380 | - | jitter := time.Duration(rand.Int63n(int64(retryDelay) / 2)) | |
| 381 | - | ||
| 382 | - | select { | |
| 383 | - | case <-ctx.Done(): | |
| 384 | - | return nil, ctx.Err() | |
| 385 | - | case <-time.After(retryDelay + jitter): | |
| 386 | - | continue | |
| 387 | - | } | |
| 388 | - | } | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | return nil, fmt.Errorf("max retries exceeded after %d attempts: %w", maxRetries, lastErr) | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | func (c *ChunkedClient) Cancel() { | |
| 395 | - | if c.cancelFunc != nil { | |
| 396 | - | c.cancelFunc() | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | // Response handling functions | |
| 401 | - | func determineResponseType(endpoint string) string { | |
| 402 | - | switch { | |
| 403 | - | case strings.Contains(endpoint, "/deploy"): | |
| 404 | - | return "deploy" | |
| 405 | - | case strings.Contains(endpoint, "/push"): | |
| 406 | - | return "push" | |
| 407 | - | default: | |
| 408 | - | return "unknown" | |
| 409 | - | } | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | // handleFinalResponse determines the response type and calls the appropriate handler | |
| 413 | - | func handleFinalResponse(resp *Response, responseType string) error { | |
| 414 | - | if resp == nil || len(resp.Body) == 0 { | |
| 415 | - | return fmt.Errorf("empty response received") | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | switch responseType { | |
| 419 | - | case "deploy": | |
| 420 | - | return handleDeployResponse(resp) | |
| 421 | - | case "push": | |
| 422 | - | return handlePushResponse(resp) | |
| 423 | - | default: | |
| 424 | - | return fmt.Errorf("unknown response type: %s", responseType) | |
| 425 | - | } | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // handleDeployResponse handles the response from a deploy operation | |
| 429 | - | func handleDeployResponse(resp *Response) error { | |
| 430 | - | finalResp := DeployResponse{} | |
| 431 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 432 | - | return fmt.Errorf("failed to unmarshal deploy response: %w", err) | |
| 433 | - | } | |
| 434 | - | ||
| 435 | - | // Always log the success message with available information | |
| 436 | - | logFields := []interface{}{} | |
| 437 | - | ||
| 438 | - | if finalResp.Domain != "" { | |
| 439 | - | logFields = append(logFields, "domain", finalResp.Domain) | |
| 440 | - | } | |
| 441 | - | if finalResp.ContainerID != "" { | |
| 442 | - | shortContainerID := finalResp.ContainerID | |
| 443 | - | if len(shortContainerID) > 8 { | |
| 444 | - | shortContainerID = shortContainerID[:8] | |
| 445 | - | } | |
| 446 | - | logFields = append(logFields, "containerID", shortContainerID) | |
| 447 | - | } | |
| 448 | - | if finalResp.ContainerName != "" { | |
| 449 | - | logFields = append(logFields, "containerName", finalResp.ContainerName) | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | // If there's a message, use it; otherwise, use a default success message | |
| 453 | - | message := finalResp.Message | |
| 454 | - | if message == "" { | |
| 455 | - | message = "Deployment completed successfully" | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Ensure we log the result even if there are no additional fields | |
| 459 | - | if len(logFields) > 0 { | |
| 460 | - | log.Info(message, logFields...) | |
| 461 | - | } else { | |
| 462 | - | log.Info(message) | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | return nil | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | // handlePushResponse handles the response from a push operation | |
| 469 | - | func handlePushResponse(resp *Response) error { | |
| 470 | - | var finalResp common.PushResponse | |
| 471 | - | if err := json.Unmarshal(resp.Body, &finalResp); err != nil { | |
| 472 | - | return fmt.Errorf("failed to unmarshal push response: %w", err) | |
| 473 | - | } | |
| 474 | - | return nil | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // sendChunk sends a single chunk to the server | |
| 478 | - | func (c *ChunkedClient) sendChunk(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 479 | - | if err := verifyChunkIntegrity(chunk, metadata); err != nil { | |
| 480 | - | return nil, fmt.Errorf("chunk integrity check failed: %w", err) | |
| 481 | - | } | |
| 482 | - | for { | |
| 483 | - | select { | |
| 484 | - | case <-ctx.Done(): | |
| 485 | - | return nil, ctx.Err() | |
| 486 | - | default: | |
| 487 | - | resp, err := c.attemptChunkSend(ctx, endpoint, headers, chunk, metadata) | |
| 488 | - | if err != nil { | |
| 489 | - | return nil, err | |
| 490 | - | } | |
| 491 | - | return resp, nil | |
| 492 | - | } | |
| 493 | - | } | |
| 494 | - | } | |
| 495 | - | ||
| 496 | - | func (c *ChunkedClient) attemptChunkSend(ctx context.Context, endpoint string, headers http.Header, chunk []byte, metadata common.ChunkMetadata) (*Response, error) { | |
| 497 | - | metadataJSON, err := json.Marshal(metadata) | |
| 498 | - | if err != nil { | |
| 499 | - | return nil, fmt.Errorf("failed to marshal metadata: %w", err) | |
| 500 | - | } | |
| 501 | - | ||
| 502 | - | url := c.baseURL + endpoint | |
| 503 | - | req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(chunk)) | |
| 504 | - | if err != nil { | |
| 505 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | // Set headers | |
| 509 | - | req.Header.Set("Authorization", "Bearer "+c.token) | |
| 510 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 511 | - | req.Header.Set("X-Chunk-Metadata", string(metadataJSON)) | |
| 512 | - | req.Header.Set("X-Chunk-Hash", fmt.Sprintf("%x", sha256.Sum256(chunk))) | |
| 513 | - | ||
| 514 | - | for k, v := range headers { | |
| 515 | - | req.Header[k] = v | |
| 516 | - | } | |
| 517 | - | ||
| 518 | - | resp, err := c.client.Do(req) | |
| 519 | - | if err != nil { | |
| 520 | - | return nil, err | |
| 521 | - | } | |
| 522 | - | defer resp.Body.Close() | |
| 523 | - | ||
| 524 | - | body, err := io.ReadAll(resp.Body) | |
| 525 | - | if err != nil { | |
| 526 | - | return nil, fmt.Errorf("failed to read response: %w", err) | |
| 527 | - | } | |
| 528 | - | ||
| 529 | - | if resp.StatusCode != http.StatusOK { | |
| 530 | - | return &Response{ | |
| 531 | - | Http: resp, | |
| 532 | - | Body: body, | |
| 533 | - | StatusCode: resp.StatusCode, | |
| 534 | - | Header: resp.Header, | |
| 535 | - | }, fmt.Errorf("unexpected status code: %d", resp.StatusCode) | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | return &Response{ | |
| 539 | - | Http: resp, | |
| 540 | - | Body: body, | |
| 541 | - | StatusCode: resp.StatusCode, | |
| 542 | - | Header: resp.Header, | |
| 543 | - | }, nil | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // verifyChunkIntegrity verifies the integrity of a chunk | |
| 547 | - | func verifyChunkIntegrity(chunk []byte, metadata common.ChunkMetadata) error { | |
| 548 | - | if len(chunk) == 0 { | |
| 549 | - | return fmt.Errorf("empty chunk received") | |
| 550 | - | } | |
| 551 | - | ||
| 552 | - | if int64(len(chunk)) != metadata.ChunkSize { | |
| 553 | - | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |
| 554 | - | metadata.ChunkSize, len(chunk)) | |
| 555 | - | } | |
| 556 | - | ||
| 557 | - | return nil | |
| 558 | - | } | |
| 559 | - | ||
| 560 | - | // cleanupClientTransfer cleans up a client transfer | |
| 561 | - | func cleanupClientTransfer(transferID string) { | |
| 562 | - | clientChunkStore.mu.Lock() | |
| 563 | - | defer clientChunkStore.mu.Unlock() | |
| 564 | - | ||
| 565 | - | delete(clientChunkStore.chunks, transferID) | |
| 566 | - | delete(clientChunkStore.metadata, transferID) | |
| 567 | - | delete(clientChunkStore.started, transferID) | |
| 568 | - | } | |
D internal/cli/handler/deploy_survey.go +0 -116
| @@ -1,116 +0,0 @@ | |||
| 1 | - | // gordon/internal/cli/handler/deploy_survey.go | |
| 2 | - | ||
| 3 | - | package handler | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/AlecAivazis/survey/v2" | |
| 11 | - | "github.com/bnema/gordon/internal/cli" | |
| 12 | - | "github.com/bnema/gordon/internal/common" | |
| 13 | - | "github.com/charmbracelet/log" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | func HandleExistingContainer(app *cli.App, conflictResp *common.DeployResponse) error { | |
| 17 | - | // Add a small delay to ensure terminal is clear | |
| 18 | - | time.Sleep(100 * time.Millisecond) | |
| 19 | - | ||
| 20 | - | // Clear the current line | |
| 21 | - | fmt.Print("\033[2K\r") | |
| 22 | - | ||
| 23 | - | containerName := conflictResp.ContainerName | |
| 24 | - | shortID := conflictResp.ContainerID | |
| 25 | - | if len(shortID) > 12 { | |
| 26 | - | shortID = shortID[:12] | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | var proceed bool | |
| 30 | - | prompt := &survey.Confirm{ | |
| 31 | - | Message: fmt.Sprintf("Container '%s' (ID: %s) is already running for %s. Stop and remove it?", | |
| 32 | - | containerName, | |
| 33 | - | shortID, | |
| 34 | - | conflictResp.RunningTime, | |
| 35 | - | ), | |
| 36 | - | Default: true, | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | if err := survey.AskOne(prompt, &proceed); err != nil { | |
| 40 | - | return fmt.Errorf("survey failed: %w", err) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | if !proceed { | |
| 44 | - | return fmt.Errorf("operation cancelled by user") | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | log.Info("Stopping container...", "name", containerName, "id", shortID) | |
| 48 | - | ||
| 49 | - | // Stop the container | |
| 50 | - | if err := stopContainer(app, conflictResp.ContainerID); err != nil { | |
| 51 | - | return fmt.Errorf("failed to stop container '%s': %w", containerName, err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | log.Info("Removing container...", "name", containerName, "id", shortID) | |
| 55 | - | ||
| 56 | - | // Remove the container | |
| 57 | - | if err := removeContainer(app, conflictResp.ContainerID); err != nil { | |
| 58 | - | return fmt.Errorf("failed to remove container '%s': %w", containerName, err) | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | log.Info("Container stopped and removed successfully", | |
| 62 | - | "name", containerName, | |
| 63 | - | "id", shortID, | |
| 64 | - | ) | |
| 65 | - | return nil | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func removeContainer(a *cli.App, containerID string) error { | |
| 69 | - | reqPayload := common.RequestPayload{ | |
| 70 | - | Type: "remove", | |
| 71 | - | Payload: common.RemovePayload{ | |
| 72 | - | ContainerID: containerID, | |
| 73 | - | }, | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/remove") | |
| 77 | - | if err != nil { | |
| 78 | - | return err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | var removeResponse common.RemoveResponse | |
| 82 | - | if err := json.Unmarshal(resp.Body, &removeResponse); err != nil { | |
| 83 | - | return fmt.Errorf("failed to parse remove response: %w", err) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | if !removeResponse.Success { | |
| 87 | - | return fmt.Errorf("failed to remove container: %s", removeResponse.Message) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | return nil | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func stopContainer(a *cli.App, containerID string) error { | |
| 94 | - | reqPayload := common.RequestPayload{ | |
| 95 | - | Type: "stop", | |
| 96 | - | Payload: common.StopPayload{ | |
| 97 | - | ContainerID: containerID, | |
| 98 | - | }, | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | resp, err := SendHTTPRequest(a, &reqPayload, "POST", "/stop") | |
| 102 | - | if err != nil { | |
| 103 | - | return err | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | var stopResponse common.StopResponse | |
| 107 | - | if err := json.Unmarshal(resp.Body, &stopResponse); err != nil { | |
| 108 | - | return fmt.Errorf("failed to parse stop response: %w", err) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | if !stopResponse.Success { | |
| 112 | - | return fmt.Errorf("failed to stop container: %s", stopResponse.Message) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | return nil | |
| 116 | - | } | |
D internal/cli/handler/docker.go +0 -51
| @@ -1,51 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/pkg/docker" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func ExportDockerImage(imageName string) (io.ReadCloser, int64, error) { | |
| 11 | - | // Initialize Docker client | |
| 12 | - | if err := docker.CheckIfInitialized(); err != nil { | |
| 13 | - | return nil, 0, fmt.Errorf("failed to initialize Docker client: %w", err) | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // Get the image ID first | |
| 17 | - | imageID, err := docker.GetImageIDByName(imageName) | |
| 18 | - | if err != nil { | |
| 19 | - | return nil, 0, fmt.Errorf("failed to get image ID: %w", err) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // Create a pipe for streaming | |
| 23 | - | pr, pw := io.Pipe() | |
| 24 | - | ||
| 25 | - | go func() { | |
| 26 | - | defer pw.Close() | |
| 27 | - | ||
| 28 | - | // Export the image using save | |
| 29 | - | reader, err := docker.ExportImageFromEngine(imageID) | |
| 30 | - | if err != nil { | |
| 31 | - | pw.CloseWithError(fmt.Errorf("failed to export image: %w", err)) | |
| 32 | - | return | |
| 33 | - | } | |
| 34 | - | defer reader.Close() | |
| 35 | - | ||
| 36 | - | // Copy the data to the pipe | |
| 37 | - | _, err = io.Copy(pw, reader) | |
| 38 | - | if err != nil { | |
| 39 | - | pw.CloseWithError(fmt.Errorf("failed to copy image data: %w", err)) | |
| 40 | - | return | |
| 41 | - | } | |
| 42 | - | }() | |
| 43 | - | ||
| 44 | - | // Get the actual size | |
| 45 | - | size, err := docker.GetImageSize(imageID) | |
| 46 | - | if err != nil { | |
| 47 | - | return nil, 0, fmt.Errorf("failed to get image size: %w", err) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | return pr, size, nil | |
| 51 | - | } | |
D internal/cli/handler/field_check.go +0 -53
| @@ -1,53 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/cli" | |
| 7 | - | "github.com/bnema/gordon/internal/cli/auth" | |
| 8 | - | "github.com/bnema/gordon/internal/common" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // config.Http.BackendURL = readUserInput("Enter the backend URL (e.g. https://gordon.mydomain.com):") | |
| 12 | - | // config.General.Token = readUserInput("Enter the token (check your backend config.yml):") | |
| 13 | - | // check in the config.yml if BackendURL and Token are set | |
| 14 | - | // if not, prompt the user to enter them | |
| 15 | - | ||
| 16 | - | // Helper function to check, prompt and set a value | |
| 17 | - | func checkAndSetField(value *string, promptMessage, errorMessage string) (bool, error) { | |
| 18 | - | wasEmpty := false | |
| 19 | - | if *value == "" { | |
| 20 | - | wasEmpty = true | |
| 21 | - | fmt.Println(errorMessage) | |
| 22 | - | *value = common.ReadUserInput(promptMessage) | |
| 23 | - | } | |
| 24 | - | return wasEmpty, nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | func FieldCheck(a *cli.App) error { | |
| 28 | - | wasBackendURLEmpty, err := checkAndSetField(&a.Config.Http.BackendURL, | |
| 29 | - | "Enter the backend URL (e.g. https://gordon.mydomain.com):", | |
| 30 | - | "BackendURL is not set in config.yml") | |
| 31 | - | if err != nil { | |
| 32 | - | return err | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | if a.Config.General.JwtToken == "" { | |
| 36 | - | fmt.Println("Token is not set. Starting OAuth device flow...") | |
| 37 | - | _, err := auth.DeviceFlowAuth(a) | |
| 38 | - | if err != nil { | |
| 39 | - | fmt.Printf("Device flow authentication error: %v\n", err) | |
| 40 | - | return fmt.Errorf("failed to complete device flow authentication: %w", err) | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Save config if BackendURL was empty or if a new token was set | |
| 45 | - | if wasBackendURLEmpty || a.Config.General.JwtToken != "" { | |
| 46 | - | err = a.Config.SaveConfig() | |
| 47 | - | if err != nil { | |
| 48 | - | return err | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | return nil | |
| 53 | - | } | |
D internal/cli/handler/http.go +0 -146
| @@ -1,146 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "encoding/json" | |
| 6 | - | "fmt" | |
| 7 | - | "io" | |
| 8 | - | "net/http" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/cli" | |
| 11 | - | "github.com/bnema/gordon/internal/common" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // PrepareJSONPayload marshals the payload into JSON | |
| 15 | - | func PrepareJSONPayload(payload common.Payload) ([]byte, error) { | |
| 16 | - | return json.Marshal(payload) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // CreateNewRequest creates a new HTTP request | |
| 20 | - | func CreateNewRequest(method, url string, body []byte) (*http.Request, error) { | |
| 21 | - | ||
| 22 | - | return http.NewRequest(method, url, bytes.NewBuffer(body)) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // SetRequestHeaders sets the headers for the request | |
| 26 | - | func setAuthRequestHeaders(req *http.Request, token string) { | |
| 27 | - | req.Header.Set("Authorization", "Bearer "+token) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // SendRequest sends the HTTP request and returns the response | |
| 31 | - | func SendRequest(req *http.Request) (*http.Response, error) { | |
| 32 | - | client := &http.Client{} | |
| 33 | - | return client.Do(req) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | type Response struct { | |
| 37 | - | Http *http.Response | |
| 38 | - | Header http.Header | |
| 39 | - | Body []byte | |
| 40 | - | StatusCode int | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // SendHTTPRequest sends the HTTP request | |
| 44 | - | func SendHTTPRequest(a *cli.App, rp *common.RequestPayload, method string, endpoint string) (*Response, error) { | |
| 45 | - | apiUrl := a.Config.Http.BackendURL + "/api" | |
| 46 | - | client := &http.Client{} | |
| 47 | - | ||
| 48 | - | reauthenticated := false | |
| 49 | - | ||
| 50 | - | for { | |
| 51 | - | // Use JWTSecret (which now holds the CLI JWT) for authentication | |
| 52 | - | req, err := createRequest(apiUrl, endpoint, method, rp, a.Config.General.JwtToken) | |
| 53 | - | if err != nil { | |
| 54 | - | return nil, fmt.Errorf("failed to create request: %w", err) | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | resp, err := client.Do(req) | |
| 58 | - | if err != nil { | |
| 59 | - | return nil, fmt.Errorf("failed to send request: %w", err) | |
| 60 | - | } | |
| 61 | - | defer resp.Body.Close() | |
| 62 | - | ||
| 63 | - | body, err := io.ReadAll(resp.Body) | |
| 64 | - | if err != nil { | |
| 65 | - | return nil, fmt.Errorf("failed to read response body: %w", err) | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | if resp.StatusCode != http.StatusOK { | |
| 69 | - | var errorResp struct { | |
| 70 | - | Success bool `json:"success"` | |
| 71 | - | Message string `json:"message"` | |
| 72 | - | } | |
| 73 | - | if err := json.Unmarshal(body, &errorResp); err == nil { | |
| 74 | - | if resp.StatusCode == http.StatusUnauthorized && !reauthenticated { | |
| 75 | - | fmt.Println("Token is invalid or expired. Initiating re-authentication...") | |
| 76 | - | err := ReAuthenticate(a) | |
| 77 | - | if err != nil { | |
| 78 | - | return nil, fmt.Errorf("re-authentication failed: %w", err) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | reauthenticated = true | |
| 82 | - | continue | |
| 83 | - | } | |
| 84 | - | return nil, fmt.Errorf("failed to send request: %s", errorResp.Message) | |
| 85 | - | } | |
| 86 | - | return nil, fmt.Errorf("failed to send request: %s", string(body)) | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | return &Response{Http: resp, Body: body, StatusCode: resp.StatusCode}, nil | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func createRequest(apiUrl, endpoint, method string, rp *common.RequestPayload, token string) (*http.Request, error) { | |
| 94 | - | var req *http.Request | |
| 95 | - | var err error | |
| 96 | - | ||
| 97 | - | switch rp.Type { | |
| 98 | - | case "deploy": | |
| 99 | - | deployPayload, ok := rp.Payload.(common.DeployPayload) | |
| 100 | - | if !ok { | |
| 101 | - | return nil, fmt.Errorf("invalid payload type for deploy") | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | req, err = http.NewRequest(method, apiUrl+endpoint, deployPayload.Data) | |
| 105 | - | if err != nil { | |
| 106 | - | return nil, fmt.Errorf("failed to create new streaming request: %w", err) | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 110 | - | req.Header.Set("X-Ports", deployPayload.Port) | |
| 111 | - | req.Header.Set("X-Target-Domain", deployPayload.TargetDomain) | |
| 112 | - | req.Header.Set("X-Image-Name", deployPayload.ImageName) | |
| 113 | - | ||
| 114 | - | case "push": | |
| 115 | - | pushPayload, ok := rp.Payload.(common.PushPayload) | |
| 116 | - | if !ok { | |
| 117 | - | return nil, fmt.Errorf("invalid payload type for push") | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | req, err = http.NewRequest(method, apiUrl+endpoint, pushPayload.Data) | |
| 121 | - | if err != nil { | |
| 122 | - | return nil, fmt.Errorf("failed to create new streaming request: %w", err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | req.Header.Set("Content-Type", "application/octet-stream") | |
| 126 | - | req.Header.Set("X-Image-Name", pushPayload.ImageName) | |
| 127 | - | ||
| 128 | - | default: | |
| 129 | - | jsonPayload, err := json.Marshal(rp) | |
| 130 | - | if err != nil { | |
| 131 | - | return nil, fmt.Errorf("failed to marshal payload: %w", err) | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | req, err = http.NewRequest(method, apiUrl+endpoint, bytes.NewBuffer(jsonPayload)) | |
| 135 | - | if err != nil { | |
| 136 | - | return nil, fmt.Errorf("failed to create new JSON request: %w", err) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | req.Header.Set("Content-Type", "application/json") | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // Set the authorization header for all request types | |
| 143 | - | setAuthRequestHeaders(req, token) | |
| 144 | - | ||
| 145 | - | return req, nil | |
| 146 | - | } | |
D internal/cli/handler/ping_request.go +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/cli" | |
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // Define a struct to match the JSON response structure | |
| 13 | - | type PingResponse struct { | |
| 14 | - | Uptime string `json:"uptime"` | |
| 15 | - | Version string `json:"version"` | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func PerformPingRequest(a *cli.App) (PingResponse, error) { | |
| 19 | - | var pingResp PingResponse | |
| 20 | - | ||
| 21 | - | pingPayload := common.PingPayload{Message: "ping"} | |
| 22 | - | reqPayload := common.RequestPayload{ | |
| 23 | - | Type: "ping", | |
| 24 | - | Payload: pingPayload, | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | resp, err := SendHTTPRequest(a, &reqPayload, "GET", "/ping") | |
| 28 | - | if err != nil { | |
| 29 | - | return pingResp, fmt.Errorf("failed to reach server: %w", err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | defer resp.Http.Body.Close() | |
| 33 | - | ||
| 34 | - | if resp.StatusCode != http.StatusOK { | |
| 35 | - | // Return the error message from the server | |
| 36 | - | return PingResponse{}, fmt.Errorf("server returned status %d: %s", resp.StatusCode, string(resp.Body)) | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Unmarshal the JSON response into the PingResponse struct | |
| 40 | - | err = json.Unmarshal(resp.Body, &pingResp) | |
| 41 | - | if err != nil { | |
| 42 | - | return pingResp, err | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return pingResp, nil | |
| 46 | - | } | |
D internal/cli/handler/regex_check.go +0 -40
| @@ -1,40 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "regexp" | |
| 6 | - | "strings" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | func ValidateImageName(imageName string) error { | |
| 10 | - | match, _ := regexp.MatchString("^([a-zA-Z0-9_\\-\\.]+\\/)*[a-zA-Z0-9_\\-\\.]+(:[a-zA-Z0-9_\\-\\.]+)?$", imageName) | |
| 11 | - | if !match { | |
| 12 | - | return fmt.Errorf("You must specify a valid image name in the form (registry/)image:tag, check your container engine image list") | |
| 13 | - | } | |
| 14 | - | return nil | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func EnsureImageTag(imageName *string) error { | |
| 18 | - | // if not tags returns an error | |
| 19 | - | if !strings.Contains(*imageName, ":") { | |
| 20 | - | return fmt.Errorf("You must specify a tag for the image") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | return nil | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func ValidatePortMapping(port string) error { | |
| 27 | - | match, _ := regexp.MatchString("^[0-9]+:[0-9]+(\\/(tcp|udp))?$", port) | |
| 28 | - | if !match { | |
| 29 | - | return fmt.Errorf("You must specify a port mapping in the form port:port/proto, if no protocol is specified, TCP is used") | |
| 30 | - | } | |
| 31 | - | return nil | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func ValidateTargetDomain(targetDomain string) error { | |
| 35 | - | match, _ := regexp.MatchString("^(https?:\\/\\/)?([a-zA-Z0-9\\-_\\.]+\\.)+[a-zA-Z0-9\\-_\\.]+(:[0-9]+)?$", targetDomain) | |
| 36 | - | if !match { | |
| 37 | - | return fmt.Errorf("invalid target domain format") | |
| 38 | - | } | |
| 39 | - | return nil | |
| 40 | - | } | |
D internal/cli/handler/start_server.go +0 -118
| @@ -1,118 +0,0 @@ | |||
| 1 | - | package handler | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "os/signal" | |
| 8 | - | "sync" | |
| 9 | - | "syscall" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/internal/httpserve" | |
| 13 | - | "github.com/bnema/gordon/internal/server" | |
| 14 | - | "github.com/bnema/gordon/pkg/logger" | |
| 15 | - | "github.com/labstack/echo/v4" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // Global server instance for shutdown handling | |
| 19 | - | var ( | |
| 20 | - | globalServer *server.App | |
| 21 | - | globalServerLock sync.Mutex | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | // SetGlobalServer sets the global server instance for shutdown handling | |
| 25 | - | func SetGlobalServer(s *server.App) { | |
| 26 | - | globalServerLock.Lock() | |
| 27 | - | defer globalServerLock.Unlock() | |
| 28 | - | globalServer = s | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // GetGlobalServer gets the global server instance | |
| 32 | - | func GetGlobalServer() *server.App { | |
| 33 | - | globalServerLock.Lock() | |
| 34 | - | defer globalServerLock.Unlock() | |
| 35 | - | return globalServer | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // execute cmd/srv/main.go main function | |
| 39 | - | ||
| 40 | - | func StartServer(a *server.App, port string) error { | |
| 41 | - | // Set the global server instance for shutdown handling | |
| 42 | - | SetGlobalServer(a) | |
| 43 | - | ||
| 44 | - | _, err := server.InitializeDB(a) | |
| 45 | - | if err != nil { | |
| 46 | - | logger.Fatal("Failed to initialize database:", err) | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Start the session cleaner cron job | |
| 50 | - | a.StartSessionCleaner() | |
| 51 | - | ||
| 52 | - | // Initialize and start the reverse proxy using the already loaded configuration | |
| 53 | - | p, err := httpserve.InitializeProxy(a) | |
| 54 | - | if err != nil { | |
| 55 | - | logger.Error("Failed to initialize reverse proxy", "error", err) | |
| 56 | - | // Continue even if proxy fails, as the main server should still work | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | e := echo.New() | |
| 60 | - | e.Server.ReadTimeout = 10 * time.Minute | |
| 61 | - | e.Server.WriteTimeout = 10 * time.Minute | |
| 62 | - | e.HideBanner = true | |
| 63 | - | e.HidePort = true | |
| 64 | - | e = httpserve.RegisterRoutes(e, a) | |
| 65 | - | ||
| 66 | - | // Setup a channel to capture termination signals | |
| 67 | - | sigs := make(chan os.Signal, 1) | |
| 68 | - | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | |
| 69 | - | ||
| 70 | - | // Start server in a goroutine | |
| 71 | - | go func() { | |
| 72 | - | logger.Info("Starting server", "port", port) | |
| 73 | - | if err := e.Start(fmt.Sprintf(":%s", a.Config.Http.Port)); err != nil { | |
| 74 | - | if err.Error() != "http: Server closed" { | |
| 75 | - | logger.Error("Server error", "error", err) | |
| 76 | - | } | |
| 77 | - | } | |
| 78 | - | }() | |
| 79 | - | ||
| 80 | - | // Test admin connections after the server has started (in a separate goroutine) | |
| 81 | - | if p != nil { | |
| 82 | - | go func() { | |
| 83 | - | // Add a delay to ensure the server has fully started | |
| 84 | - | time.Sleep(2 * time.Second) | |
| 85 | - | p.TestAdminConnectionLater() | |
| 86 | - | }() | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | // Wait for interrupt signal | |
| 90 | - | <-sigs | |
| 91 | - | logger.Info("Received shutdown signal") | |
| 92 | - | ||
| 93 | - | // Create a deadline for graceful shutdown | |
| 94 | - | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| 95 | - | defer cancel() | |
| 96 | - | ||
| 97 | - | // Gracefully shutdown the server | |
| 98 | - | logger.Info("Shutting down server...") | |
| 99 | - | if err := e.Shutdown(ctx); err != nil { | |
| 100 | - | logger.Error("Server shutdown error", "error", err) | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Perform application shutdown (including database closure) | |
| 104 | - | if err := a.Shutdown(); err != nil { | |
| 105 | - | logger.Error("Application shutdown error", "error", err) | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | // Stop the proxy if it was started | |
| 109 | - | if p != nil { | |
| 110 | - | logger.Info("Stopping reverse proxy...") | |
| 111 | - | if err := p.Stop(); err != nil { | |
| 112 | - | logger.Error("Failed to stop reverse proxy", "error", err) | |
| 113 | - | } | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | logger.Info("Shutdown complete") | |
| 117 | - | return nil | |
| 118 | - | } | |
D internal/cli/new_client.go +0 -27
| @@ -1,27 +0,0 @@ | |||
| 1 | - | package cli | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | type App struct { | |
| 10 | - | Config common.Config | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | // NewClientApp initializes a new App with configuration. | |
| 14 | - | func NewClientApp(buildConfig *common.BuildConfig) (*App, error) { | |
| 15 | - | // Get global config singleton instead of loading the config again | |
| 16 | - | config, err := common.GetGlobalConfig(buildConfig) | |
| 17 | - | if err != nil { | |
| 18 | - | return nil, fmt.Errorf("failed to load configuration: %w", err) | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // Initialize App | |
| 22 | - | a := &App{ | |
| 23 | - | Config: *config, | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | return a, nil | |
| 27 | - | } | |
D internal/common/init_config.go +0 -860
| @@ -1,860 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "errors" | |
| 5 | - | "fmt" | |
| 6 | - | "io/fs" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "runtime" | |
| 10 | - | "strings" | |
| 11 | - | "sync" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/gordon/pkg/docker" | |
| 14 | - | "github.com/bnema/gordon/pkg/logger" | |
| 15 | - | "github.com/bnema/gordon/pkg/parser" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | var ( | |
| 19 | - | globalConfig *Config | |
| 20 | - | globalConfigOnce sync.Once | |
| 21 | - | globalConfigMu sync.RWMutex | |
| 22 | - | configLogsPrinted bool | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | // Initialize package-level logging configuration | |
| 26 | - | func init() { | |
| 27 | - | // Configure the logger from environment variables | |
| 28 | - | logger.GetLogger().ConfigureFromEnv() | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | type Config struct { | |
| 32 | - | General GeneralConfig `yaml:"General"` | |
| 33 | - | Http HttpConfig `yaml:"Http"` | |
| 34 | - | Admin AdminConfig `yaml:"Admin"` | |
| 35 | - | ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"` | |
| 36 | - | ReverseProxy ReverseProxyConfig `yaml:"ReverseProxy"` | |
| 37 | - | Build BuildConfig `yaml:"-"` | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | type BuildConfig struct { | |
| 41 | - | RunEnv string `yaml:"-"` // come from env | |
| 42 | - | BuildVersion string `yaml:"-"` // come from build ldflags | |
| 43 | - | BuildCommit string `yaml:"-"` // come from build ldflags | |
| 44 | - | BuildDate string `yaml:"-"` // come from build ldflags | |
| 45 | - | ProxyURL string `yaml:"-"` | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | type AdminConfig struct { | |
| 49 | - | Path string `yaml:"path"` | |
| 50 | - | AdminWebUI bool `yaml:"-"` | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | type GeneralConfig struct { | |
| 54 | - | StorageDir string `yaml:"storageDir"` | |
| 55 | - | LogLevel string `yaml:"logLevel"` | |
| 56 | - | GordonContainerID string `yaml:"-"` | |
| 57 | - | JwtToken string `yaml:"jwtToken"` | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | type HttpConfig struct { | |
| 61 | - | Port string `yaml:"port"` | |
| 62 | - | Domain string `yaml:"domain"` | |
| 63 | - | SubDomain string `yaml:"subDomain"` | |
| 64 | - | BackendURL string `yaml:"backendURL"` | |
| 65 | - | Https bool `yaml:"https"` | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | type ContainerEngineConfig struct { | |
| 69 | - | Sock string `yaml:"dockersock"` | |
| 70 | - | PodmanSock string `yaml:"podmansock"` | |
| 71 | - | Podman bool `yaml:"podman"` | |
| 72 | - | Network string `yaml:"network"` | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | type ReverseProxyConfig struct { | |
| 76 | - | Enabled bool `yaml:"enabled"` // Whether the reverse proxy is enabled | |
| 77 | - | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 78 | - | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 79 | - | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 80 | - | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 81 | - | LetsEncryptMode string `yaml:"letsEncryptMode"` // staging or production | |
| 82 | - | SkipCertificates bool `yaml:"skipCertificates"` // Skip Let's Encrypt certificate acquisition when behind a TLS terminating proxy | |
| 83 | - | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 84 | - | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 85 | - | EnableHttpLogs bool `yaml:"enableHttpLogs"` // Whether to enable HTTP request logging | |
| 86 | - | EnableRateLimit bool `yaml:"enableRateLimit"` // Whether to enable rate limiting middleware | |
| 87 | - | DetectUpstreamProxy bool `yaml:"detectUpstreamProxy"` // Whether to detect and handle upstream TLS termination proxies | |
| 88 | - | BlockDirectIP bool `yaml:"blockDirectIP"` // Whether to block requests made directly to the server's IP (allows ACME) | |
| 89 | - | DefaultChallengeType string `yaml:"defaultChallengeType"` // http-01 or dns-01 | |
| 90 | - | DefaultHttpChallengePort string `yaml:"defaultHttpChallengePort"` | |
| 91 | - | DefaultDnsChallengePropagationTimeout int `yaml:"defaultDnsChallengePropagationTimeout"` | |
| 92 | - | DefaultDnsChallengePollingInterval int `yaml:"defaultDnsChallengePollingInterval"` | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Default values | |
| 96 | - | var ( | |
| 97 | - | sock = "/var/run/docker.sock" | |
| 98 | - | podmansock = "/run/podman/podman.sock" | |
| 99 | - | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | |
| 100 | - | httpPort = "80" // Standard HTTP port | |
| 101 | - | certDir = "/certs" | |
| 102 | - | letsEncryptMode = "staging" | |
| 103 | - | proxyEnabled = true // Default to enabled for backward compatibility | |
| 104 | - | renewBefore = 30 // days | |
| 105 | - | gracePeriod = 30 // seconds | |
| 106 | - | defaultLogLevel = "info" // Default log level | |
| 107 | - | defaultHttpHttps = true // Default to true | |
| 108 | - | disableRateLimit = false | |
| 109 | - | detectUpstreamProxy = false // Default to disabled | |
| 110 | - | skipCertificates = false // Default to disabled | |
| 111 | - | enableRateLimit = false // Default to disabled | |
| 112 | - | enableHttpLogs = true // Default to enabled | |
| 113 | - | defaultChallengeType = "http-01" | |
| 114 | - | defaultHttpChallengePort = "80" | |
| 115 | - | defaultDnsChallengePropagationTimeout = 60 // seconds | |
| 116 | - | defaultDnsChallengePollingInterval = 5 // seconds | |
| 117 | - | defaultAdminPath = "/admin" | |
| 118 | - | defaultNetwork = "gordon" | |
| 119 | - | blockDirectIP = true // Default to enabled | |
| 120 | - | ) | |
| 121 | - | ||
| 122 | - | // applyDefaultsToConfig applies default values to any fields that have zero values | |
| 123 | - | // Returns true if any defaults were applied | |
| 124 | - | func applyDefaultsToConfig(config *Config) bool { | |
| 125 | - | defaultsApplied := false | |
| 126 | - | ||
| 127 | - | // Apply defaults to GeneralConfig | |
| 128 | - | if config.General.LogLevel == "" { | |
| 129 | - | config.General.LogLevel = defaultLogLevel | |
| 130 | - | logger.Debug("Applied default value for General.LogLevel", "value", defaultLogLevel) | |
| 131 | - | defaultsApplied = true | |
| 132 | - | } | |
| 133 | - | if config.General.StorageDir == "" { | |
| 134 | - | // When running in container, use /data as the default storage location | |
| 135 | - | if docker.IsRunningInContainer() { | |
| 136 | - | config.General.StorageDir = "/data" | |
| 137 | - | } else { | |
| 138 | - | // Otherwise, use ~/.gordon | |
| 139 | - | homeDir, err := os.UserHomeDir() | |
| 140 | - | if err != nil { | |
| 141 | - | homeDir = "/tmp" | |
| 142 | - | } | |
| 143 | - | config.General.StorageDir = filepath.Join(homeDir, ".gordon") | |
| 144 | - | } | |
| 145 | - | logger.Debug("Applied default value for General.StorageDir", "value", config.General.StorageDir) | |
| 146 | - | defaultsApplied = true | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Apply defaults to HttpConfig | |
| 150 | - | // Check if Https is NOT explicitly set to true (i.e., it's the zero value 'false') | |
| 151 | - | if !config.Http.Https { | |
| 152 | - | config.Http.Https = defaultHttpHttps // Use the defined default variable | |
| 153 | - | logger.Debug("Applied default value for Http.Https", "value", defaultHttpHttps) | |
| 154 | - | defaultsApplied = true | |
| 155 | - | } | |
| 156 | - | // No defaults for Http.Port, Http.Domain, Http.SubDomain as they require explicit config | |
| 157 | - | ||
| 158 | - | // Apply defaults to Admin config | |
| 159 | - | if config.Admin.Path == "" { | |
| 160 | - | config.Admin.Path = defaultAdminPath | |
| 161 | - | logger.Debug("Applied default value for Admin.Path", "value", defaultAdminPath) | |
| 162 | - | defaultsApplied = true | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // Apply defaults to ContainerEngine config | |
| 166 | - | if config.ContainerEngine.Sock == "" { | |
| 167 | - | config.ContainerEngine.Sock = sock | |
| 168 | - | logger.Debug("Applied default value for ContainerEngine.Sock", "value", sock) | |
| 169 | - | defaultsApplied = true | |
| 170 | - | } | |
| 171 | - | if config.ContainerEngine.PodmanSock == "" { | |
| 172 | - | config.ContainerEngine.PodmanSock = podmansock | |
| 173 | - | logger.Debug("Applied default value for ContainerEngine.PodmanSock", "value", podmansock) | |
| 174 | - | defaultsApplied = true | |
| 175 | - | } | |
| 176 | - | if config.ContainerEngine.Network == "" { | |
| 177 | - | config.ContainerEngine.Network = defaultNetwork | |
| 178 | - | logger.Debug("Applied default value for ContainerEngine.Network", "value", defaultNetwork) | |
| 179 | - | defaultsApplied = true | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | // Apply defaults to ReverseProxy config | |
| 183 | - | // Set Enabled to true by default if not specified | |
| 184 | - | if !config.ReverseProxy.Enabled { | |
| 185 | - | config.ReverseProxy.Enabled = proxyEnabled | |
| 186 | - | logger.Debug("Applied default value for ReverseProxy.Enabled", "value", proxyEnabled) | |
| 187 | - | defaultsApplied = true | |
| 188 | - | } | |
| 189 | - | if config.ReverseProxy.Port == "" { | |
| 190 | - | config.ReverseProxy.Port = reverseProxyPort | |
| 191 | - | logger.Debug("Applied default value for ReverseProxy.Port", "value", reverseProxyPort) | |
| 192 | - | defaultsApplied = true | |
| 193 | - | } | |
| 194 | - | if config.ReverseProxy.HttpPort == "" { | |
| 195 | - | config.ReverseProxy.HttpPort = httpPort | |
| 196 | - | logger.Debug("Applied default value for ReverseProxy.HttpPort", "value", httpPort) | |
| 197 | - | defaultsApplied = true | |
| 198 | - | } | |
| 199 | - | if config.ReverseProxy.CertDir == "" { | |
| 200 | - | config.ReverseProxy.CertDir = certDir | |
| 201 | - | logger.Debug("Applied default value for ReverseProxy.CertDir", "value", certDir) | |
| 202 | - | defaultsApplied = true | |
| 203 | - | } | |
| 204 | - | if config.ReverseProxy.RenewBefore == 0 { | |
| 205 | - | config.ReverseProxy.RenewBefore = renewBefore | |
| 206 | - | logger.Debug("Applied default value for ReverseProxy.RenewBefore", "value", renewBefore) | |
| 207 | - | defaultsApplied = true | |
| 208 | - | } | |
| 209 | - | if config.ReverseProxy.LetsEncryptMode == "" { | |
| 210 | - | config.ReverseProxy.LetsEncryptMode = letsEncryptMode | |
| 211 | - | logger.Debug("Applied default value for ReverseProxy.LetsEncryptMode", "value", letsEncryptMode) | |
| 212 | - | defaultsApplied = true | |
| 213 | - | } | |
| 214 | - | if config.ReverseProxy.GracePeriod == 0 { | |
| 215 | - | config.ReverseProxy.GracePeriod = gracePeriod | |
| 216 | - | logger.Debug("Applied default value for ReverseProxy.GracePeriod", "value", gracePeriod) | |
| 217 | - | defaultsApplied = true | |
| 218 | - | } | |
| 219 | - | // Check if "enableHttpLogs" is explicitly mentioned in the config file | |
| 220 | - | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 221 | - | if fileExists(configFilePath) { | |
| 222 | - | yamlContent, err := os.ReadFile(configFilePath) | |
| 223 | - | if err == nil { | |
| 224 | - | // Only set default if enableHttpLogs not explicitly specified | |
| 225 | - | if !strings.Contains(string(yamlContent), "enableHttpLogs:") { | |
| 226 | - | config.ReverseProxy.EnableHttpLogs = enableHttpLogs | |
| 227 | - | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs) | |
| 228 | - | defaultsApplied = true | |
| 229 | - | } else { | |
| 230 | - | logger.Debug("Keeping explicit value for ReverseProxy.EnableHttpLogs", "value", config.ReverseProxy.EnableHttpLogs) | |
| 231 | - | } | |
| 232 | - | } else { | |
| 233 | - | // If we can't read the file, apply the default only if not set | |
| 234 | - | if !config.ReverseProxy.EnableHttpLogs { | |
| 235 | - | config.ReverseProxy.EnableHttpLogs = enableHttpLogs | |
| 236 | - | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs) | |
| 237 | - | defaultsApplied = true | |
| 238 | - | } | |
| 239 | - | } | |
| 240 | - | } else { | |
| 241 | - | // If config file doesn't exist yet, set the default | |
| 242 | - | config.ReverseProxy.EnableHttpLogs = enableHttpLogs | |
| 243 | - | logger.Debug("Applied default value for ReverseProxy.EnableHttpLogs", "value", enableHttpLogs) | |
| 244 | - | defaultsApplied = true | |
| 245 | - | } | |
| 246 | - | // Set EnableRateLimit to false by default if not specified | |
| 247 | - | if !config.ReverseProxy.EnableRateLimit { | |
| 248 | - | config.ReverseProxy.EnableRateLimit = enableRateLimit | |
| 249 | - | logger.Debug("Applied default value for ReverseProxy.EnableRateLimit", "value", enableRateLimit) | |
| 250 | - | defaultsApplied = true | |
| 251 | - | } | |
| 252 | - | ||
| 253 | - | // Set DetectUpstreamProxy to false by default if not specified | |
| 254 | - | if !config.ReverseProxy.DetectUpstreamProxy { | |
| 255 | - | config.ReverseProxy.DetectUpstreamProxy = detectUpstreamProxy | |
| 256 | - | logger.Debug("Applied default value for ReverseProxy.DetectUpstreamProxy", "value", detectUpstreamProxy) | |
| 257 | - | defaultsApplied = true | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | // Set SkipCertificates to false by default if not specified | |
| 261 | - | if !config.ReverseProxy.SkipCertificates { | |
| 262 | - | config.ReverseProxy.SkipCertificates = skipCertificates | |
| 263 | - | logger.Debug("Applied default value for ReverseProxy.SkipCertificates", "value", skipCertificates) | |
| 264 | - | defaultsApplied = true | |
| 265 | - | } | |
| 266 | - | ||
| 267 | - | // Set BlockDirectIP to true by default if not explicitly set to false | |
| 268 | - | if !config.ReverseProxy.BlockDirectIP { | |
| 269 | - | // Check if the field is explicitly set to false in the config file | |
| 270 | - | configFilePath := filepath.Join(getConfigDirMustExist(), "config.yml") | |
| 271 | - | isExplicitlyFalse := false | |
| 272 | - | if fileExists(configFilePath) { | |
| 273 | - | yamlContent, err := os.ReadFile(configFilePath) | |
| 274 | - | if err == nil { | |
| 275 | - | // Simple check for 'blockDirectIP: false' | |
| 276 | - | // Note: This doesn't handle comments or complex YAML structures perfectly | |
| 277 | - | if strings.Contains(string(yamlContent), "blockDirectIP: false") { | |
| 278 | - | isExplicitlyFalse = true | |
| 279 | - | } | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | // Apply default 'true' only if it wasn't explicitly set to 'false' | |
| 284 | - | if !isExplicitlyFalse { | |
| 285 | - | config.ReverseProxy.BlockDirectIP = blockDirectIP | |
| 286 | - | logger.Debug("Applied default value for ReverseProxy.BlockDirectIP", "value", blockDirectIP) | |
| 287 | - | defaultsApplied = true | |
| 288 | - | } else { | |
| 289 | - | // It was explicitly set to false in the file, keep it as false (env var can still override) | |
| 290 | - | logger.Debug("Keeping explicitly set 'false' for ReverseProxy.BlockDirectIP", "value", config.ReverseProxy.BlockDirectIP) | |
| 291 | - | } | |
| 292 | - | } | |
| 293 | - | ||
| 294 | - | // Apply defaults for Let's Encrypt challenge settings | |
| 295 | - | if config.ReverseProxy.DefaultChallengeType == "" { | |
| 296 | - | config.ReverseProxy.DefaultChallengeType = defaultChallengeType | |
| 297 | - | logger.Debug("Applied default value for ReverseProxy.DefaultChallengeType", "value", defaultChallengeType) | |
| 298 | - | defaultsApplied = true | |
| 299 | - | } | |
| 300 | - | if config.ReverseProxy.DefaultHttpChallengePort == "" { | |
| 301 | - | config.ReverseProxy.DefaultHttpChallengePort = defaultHttpChallengePort | |
| 302 | - | logger.Debug("Applied default value for ReverseProxy.DefaultHttpChallengePort", "value", defaultHttpChallengePort) | |
| 303 | - | defaultsApplied = true | |
| 304 | - | } | |
| 305 | - | if config.ReverseProxy.DefaultDnsChallengePropagationTimeout == 0 { | |
| 306 | - | config.ReverseProxy.DefaultDnsChallengePropagationTimeout = defaultDnsChallengePropagationTimeout | |
| 307 | - | logger.Debug("Applied default value for ReverseProxy.DefaultDnsChallengePropagationTimeout", "value", defaultDnsChallengePropagationTimeout) | |
| 308 | - | defaultsApplied = true | |
| 309 | - | } | |
| 310 | - | if config.ReverseProxy.DefaultDnsChallengePollingInterval == 0 { | |
| 311 | - | config.ReverseProxy.DefaultDnsChallengePollingInterval = defaultDnsChallengePollingInterval | |
| 312 | - | logger.Debug("Applied default value for ReverseProxy.DefaultDnsChallengePollingInterval", "value", defaultDnsChallengePollingInterval) | |
| 313 | - | defaultsApplied = true | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | return defaultsApplied | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // getConfigDirMustExist returns the config directory and creates it if it doesn't exist | |
| 320 | - | // This is a helper function to avoid error handling in applyDefaultsToConfig | |
| 321 | - | func getConfigDirMustExist() string { | |
| 322 | - | configDir, err := getConfigDir() | |
| 323 | - | if err != nil { | |
| 324 | - | // Fallback to a reasonable default if there's an error | |
| 325 | - | homeDir, err := os.UserHomeDir() | |
| 326 | - | if err != nil { | |
| 327 | - | return "/tmp/.gordon" | |
| 328 | - | } | |
| 329 | - | return filepath.Join(homeDir, ".gordon") | |
| 330 | - | } | |
| 331 | - | return configDir | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | // GetGlobalConfig returns the singleton config instance, loading it if necessary | |
| 335 | - | func GetGlobalConfig(buildConfig *BuildConfig) (*Config, error) { | |
| 336 | - | logger.Debug("GetGlobalConfig: Starting with buildConfig", "buildConfig", buildConfig != nil) | |
| 337 | - | ||
| 338 | - | // Try read lock first | |
| 339 | - | logger.Debug("GetGlobalConfig: Acquiring read lock") | |
| 340 | - | globalConfigMu.RLock() | |
| 341 | - | if globalConfig != nil { | |
| 342 | - | // If buildConfig is provided, update the Build field | |
| 343 | - | if buildConfig != nil { | |
| 344 | - | globalConfig.Build = *buildConfig | |
| 345 | - | } | |
| 346 | - | config := globalConfig | |
| 347 | - | logger.Debug("GetGlobalConfig: Config exists, releasing read lock") | |
| 348 | - | globalConfigMu.RUnlock() | |
| 349 | - | logger.Debug("GetGlobalConfig: Using existing global configuration (already loaded)") | |
| 350 | - | return config, nil | |
| 351 | - | } | |
| 352 | - | logger.Debug("GetGlobalConfig: Config not found, releasing read lock") | |
| 353 | - | globalConfigMu.RUnlock() | |
| 354 | - | ||
| 355 | - | // Need to load the config - acquire write lock | |
| 356 | - | logger.Debug("GetGlobalConfig: No existing config, acquiring write lock") | |
| 357 | - | globalConfigMu.Lock() | |
| 358 | - | defer func() { | |
| 359 | - | logger.Debug("GetGlobalConfig: Releasing write lock via defer") | |
| 360 | - | globalConfigMu.Unlock() | |
| 361 | - | }() | |
| 362 | - | ||
| 363 | - | // Double-check in case another goroutine loaded the config while we were waiting | |
| 364 | - | if globalConfig != nil { | |
| 365 | - | // If buildConfig is provided, update the Build field | |
| 366 | - | if buildConfig != nil { | |
| 367 | - | globalConfig.Build = *buildConfig | |
| 368 | - | } | |
| 369 | - | logger.Debug("GetGlobalConfig: Using existing global configuration (loaded by another goroutine)") | |
| 370 | - | return globalConfig, nil | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | // Initialize config with build info | |
| 374 | - | config := &Config{} | |
| 375 | - | if buildConfig != nil { | |
| 376 | - | config.Build = *buildConfig | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | // Load the config once | |
| 380 | - | var err error | |
| 381 | - | logger.Debug("GetGlobalConfig: About to execute globalConfigOnce.Do") | |
| 382 | - | globalConfigOnce.Do(func() { | |
| 383 | - | logger.Debug("Inside globalConfigOnce.Do, loading configuration...") | |
| 384 | - | var loadedConfig *Config | |
| 385 | - | loadedConfig, err = config.LoadConfig() | |
| 386 | - | if err == nil { | |
| 387 | - | logger.Debug("GetGlobalConfig: LoadConfig successful, setting globalConfig") | |
| 388 | - | globalConfig = loadedConfig | |
| 389 | - | logger.Info("Global configuration loaded successfully") | |
| 390 | - | } else { | |
| 391 | - | logger.Error("Error loading global configuration", "error", err) | |
| 392 | - | } | |
| 393 | - | }) | |
| 394 | - | logger.Debug("GetGlobalConfig: Completed globalConfigOnce.Do") | |
| 395 | - | ||
| 396 | - | // Return the global config if it was set, otherwise return the local config | |
| 397 | - | if globalConfig != nil { | |
| 398 | - | return globalConfig, err | |
| 399 | - | } | |
| 400 | - | ||
| 401 | - | return config, err | |
| 402 | - | } | |
| 403 | - | ||
| 404 | - | func getConfigDir() (string, error) { | |
| 405 | - | logger.Debug("getConfigDir: Starting getConfigDir function") | |
| 406 | - | var configDir string | |
| 407 | - | ||
| 408 | - | logger.Debug("getConfigDir: Checking if running in WSL") | |
| 409 | - | ||
| 410 | - | logger.Debug("getConfigDir: Checking if running in container") | |
| 411 | - | if docker.IsRunningInContainer() { | |
| 412 | - | logger.Debug("getConfigDir: Running in container, using current directory") | |
| 413 | - | return ".", nil | |
| 414 | - | } | |
| 415 | - | ||
| 416 | - | // Check for XDG_CONFIG_HOME first | |
| 417 | - | logger.Debug("getConfigDir: Checking XDG_CONFIG_HOME environment variable") | |
| 418 | - | if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { | |
| 419 | - | logger.Debug("getConfigDir: Using XDG_CONFIG_HOME environment variable", "value", xdgConfigHome) | |
| 420 | - | configDir = filepath.Join(xdgConfigHome, "Gordon") | |
| 421 | - | } else { | |
| 422 | - | // If XDG_CONFIG_HOME is not set, fall back to default locations | |
| 423 | - | logger.Debug("getConfigDir: XDG_CONFIG_HOME not set, getting user home directory") | |
| 424 | - | homeDir, err := os.UserHomeDir() | |
| 425 | - | if err != nil { | |
| 426 | - | logger.Debug("getConfigDir: Failed to get user home directory", "error", err) | |
| 427 | - | return "", fmt.Errorf("could not determine user home directory: %w", err) | |
| 428 | - | } | |
| 429 | - | ||
| 430 | - | // For Windows, use the standard application data directory | |
| 431 | - | if runtime.GOOS == "windows" { | |
| 432 | - | logger.Debug("getConfigDir: On Windows, using AppData") | |
| 433 | - | configDir = filepath.Join(homeDir, "AppData", "Local", "Gordon") | |
| 434 | - | } else { | |
| 435 | - | // For Unix systems, use the XDG Base Directory Specification | |
| 436 | - | logger.Debug("getConfigDir: On Unix system, using XDG pattern") | |
| 437 | - | configDir = filepath.Join(homeDir, ".config", "Gordon") | |
| 438 | - | } | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | logger.Debug("getConfigDir: Returning config directory", "dir", configDir) | |
| 442 | - | return configDir, nil | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | // readAndUnmarshalConfig reads and unmarshals a configuration file using the provided filesystem and path. | |
| 446 | - | func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | |
| 447 | - | logger.Debug("readAndUnmarshalConfig: Starting to read and unmarshal config file", "path", filePath) | |
| 448 | - | err := parser.ParseYAMLFile(fs, filePath, config) | |
| 449 | - | if err != nil { | |
| 450 | - | logger.Debug("readAndUnmarshalConfig: Failed to parse YAML file", "error", err) | |
| 451 | - | return fmt.Errorf("error reading and unmarshaling configuration file: %w", err) | |
| 452 | - | } | |
| 453 | - | logger.Debug("readAndUnmarshalConfig: Successfully parsed YAML file") | |
| 454 | - | return nil | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | func (config *Config) LoadConfig() (*Config, error) { | |
| 458 | - | // If we're in a non-interactive environment, use simplified config | |
| 459 | - | nonInteractiveEnv := os.Getenv("GORDON_NONINTERACTIVE") | |
| 460 | - | isNonInteractive := nonInteractiveEnv == "true" || nonInteractiveEnv == "1" || nonInteractiveEnv == "yes" | |
| 461 | - | isInContainer := docker.IsRunningInContainer() // Check once | |
| 462 | - | ||
| 463 | - | // Flag to track if saving is needed at the end | |
| 464 | - | shouldSaveConfig := false | |
| 465 | - | defaultsApplied := false | |
| 466 | - | ||
| 467 | - | if isNonInteractive { | |
| 468 | - | logger.Info("Non-interactive mode detected, using simplified config without prompts") | |
| 469 | - | // Start with an empty config | |
| 470 | - | *config = Config{} | |
| 471 | - | ||
| 472 | - | // Determine StorageDir | |
| 473 | - | if isInContainer { | |
| 474 | - | config.General.StorageDir = "/data" | |
| 475 | - | logger.Debug("Non-interactive: Using default container storage dir", "dir", config.General.StorageDir) | |
| 476 | - | } else { | |
| 477 | - | homeDir, err := os.UserHomeDir() | |
| 478 | - | if err != nil { | |
| 479 | - | homeDir = "/tmp" | |
| 480 | - | logger.Warn("Could not get user home directory, using /tmp instead") | |
| 481 | - | } | |
| 482 | - | config.General.StorageDir = filepath.Join(homeDir, ".gordon") | |
| 483 | - | logger.Debug("Non-interactive: Using default host storage dir", "dir", config.General.StorageDir) | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // Auto-detect Podman and set relevant fields | |
| 487 | - | isPodman, podmanSocket := docker.DetectPodman() | |
| 488 | - | config.ContainerEngine.Podman = isPodman | |
| 489 | - | if isPodman { | |
| 490 | - | config.ContainerEngine.PodmanSock = podmanSocket | |
| 491 | - | logger.Info("Non-interactive: Podman detected", "socket", podmanSocket) | |
| 492 | - | } else { | |
| 493 | - | // Explicitly set Docker sock if Podman not detected, applyDefaults might override if empty later | |
| 494 | - | config.ContainerEngine.Sock = sock | |
| 495 | - | logger.Info("Non-interactive: Assuming Docker") | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | // Apply remaining defaults | |
| 499 | - | defaultsApplied = applyDefaultsToConfig(config) | |
| 500 | - | if defaultsApplied { | |
| 501 | - | logger.Info("Non-interactive: Applied default configuration values") | |
| 502 | - | shouldSaveConfig = true // Mark for saving if defaults were needed | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | // If in container, apply env vars (potentially overriding defaults) | |
| 506 | - | if isInContainer { | |
| 507 | - | logger.Info("Non-interactive: Running in container, overriding configuration with environment variables...") | |
| 508 | - | // Assuming loadConfigFromEnv modifies config in place | |
| 509 | - | loadConfigFromEnv(config, !configLogsPrinted) // Pass flag to control initial logging | |
| 510 | - | configLogsPrinted = true // Mark logs as printed | |
| 511 | - | shouldSaveConfig = true // Mark for saving if env vars were applied | |
| 512 | - | logger.Debug("Non-interactive: Finished loading config from environment") | |
| 513 | - | } | |
| 514 | - | ||
| 515 | - | // Load run env (common step) | |
| 516 | - | config.Build.RunEnv = os.Getenv("RUN_ENV") | |
| 517 | - | if config.Build.RunEnv == "" { | |
| 518 | - | config.Build.RunEnv = "prod" | |
| 519 | - | logger.Info("Non-interactive: No RUN_ENV specified, defaulting to 'prod'") | |
| 520 | - | } else { | |
| 521 | - | logger.Info("Non-interactive: Run environment set", "env", config.Build.RunEnv) | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | // Check AdminWebUI (common step) | |
| 525 | - | if config.Admin.Path == "" { | |
| 526 | - | logger.Warn("Non-interactive: Admin path is empty or not set, admin dashboard webui will be disabled") | |
| 527 | - | config.Admin.AdminWebUI = false | |
| 528 | - | } else { | |
| 529 | - | config.Admin.AdminWebUI = true | |
| 530 | - | } | |
| 531 | - | ||
| 532 | - | // Apply log level (common step) | |
| 533 | - | if config.General.LogLevel != "" { | |
| 534 | - | logger.GetLogger().SetLogLevel(config.General.LogLevel) | |
| 535 | - | logger.Debug("Non-interactive: Log level set from configuration", "level", config.General.LogLevel) | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | // Save config if needed (only in non-interactive mode if defaults/env applied) | |
| 539 | - | if shouldSaveConfig { | |
| 540 | - | logger.Info("Non-interactive: Saving configuration...") | |
| 541 | - | err := config.SaveConfig() | |
| 542 | - | if err != nil { | |
| 543 | - | // Log error but continue, as config is loaded, just not saved | |
| 544 | - | logger.Error("Non-interactive: Failed to save configuration", "error", err) | |
| 545 | - | } else { | |
| 546 | - | logger.Info("Non-interactive: Successfully saved configuration") | |
| 547 | - | } | |
| 548 | - | } | |
| 549 | - | ||
| 550 | - | logger.Debug("LoadConfig: Completed non-interactive setup") | |
| 551 | - | return config, nil // Return early for non-interactive mode | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | // ---- Interactive Mode Logic ---- | |
| 555 | - | logger.Debug("LoadConfig: Starting interactive LoadConfig function") | |
| 556 | - | ||
| 557 | - | // Log current execution environment for debugging | |
| 558 | - | cwd, _ := os.Getwd() | |
| 559 | - | logger.Debug("LoadConfig: Environment information", | |
| 560 | - | "isContainer", isInContainer, // Use cached value | |
| 561 | - | "currentDir", cwd, | |
| 562 | - | "ENV", os.Getenv("ENV"), | |
| 563 | - | "GORDON_NONINTERACTIVE", os.Getenv("GORDON_NONINTERACTIVE")) | |
| 564 | - | ||
| 565 | - | logger.Info("Loading config...") | |
| 566 | - | ||
| 567 | - | // Enable extra debugging for this function if requested | |
| 568 | - | debugLevel := os.Getenv("GORDON_CONFIG_LOG_LEVEL") | |
| 569 | - | if debugLevel == "trace" { | |
| 570 | - | logger.Debug("LoadConfig: TRACE mode enabled, will log detailed steps") | |
| 571 | - | } | |
| 572 | - | ||
| 573 | - | // Get the config directory | |
| 574 | - | logger.Debug("LoadConfig: About to call getConfigDir()") | |
| 575 | - | configDir, err := getConfigDir() | |
| 576 | - | if err != nil { | |
| 577 | - | logger.Error("LoadConfig: Failed to get config directory", "error", err) | |
| 578 | - | return nil, fmt.Errorf("error getting configuration directory: %w", err) | |
| 579 | - | } | |
| 580 | - | ||
| 581 | - | logger.Debug("LoadConfig: Got config directory", "dir", configDir) | |
| 582 | - | logger.Info("Using configuration directory", "dir", configDir) | |
| 583 | - | ||
| 584 | - | // Determine config file path | |
| 585 | - | var configFilePath string | |
| 586 | - | logger.Debug("LoadConfig: Checking config file location") | |
| 587 | - | // Check for direct mount first only if in container | |
| 588 | - | directMountPath := "./config.yml" | |
| 589 | - | useDirectMount := isInContainer && fileExists(directMountPath) | |
| 590 | - | ||
| 591 | - | if useDirectMount { | |
| 592 | - | configFilePath = directMountPath | |
| 593 | - | logger.Info("Found directly mounted config.yml in container current directory") | |
| 594 | - | } else { | |
| 595 | - | configFilePath = filepath.Join(configDir, "config.yml") | |
| 596 | - | logger.Info("Using config file path", "path", configFilePath) | |
| 597 | - | } | |
| 598 | - | ||
| 599 | - | // Add an indicator if this is not the first time we're loading config | |
| 600 | - | if configLogsPrinted { | |
| 601 | - | logger.Info("Reloading configuration (already loaded previously)...") | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | configExists := true // Assume exists unless stat fails | |
| 605 | - | ||
| 606 | - | logger.Debug("LoadConfig: Checking if config file exists", "path", configFilePath) | |
| 607 | - | _, err = os.Stat(configFilePath) | |
| 608 | - | if errors.Is(err, fs.ErrNotExist) { | |
| 609 | - | configExists = false | |
| 610 | - | shouldSaveConfig = true // Need to save if we create it | |
| 611 | - | logger.Info("Config file not found, creating it", "path", configFilePath) | |
| 612 | - | ||
| 613 | - | // Create config dir if it doesn't exist (only needed if using configDir path) | |
| 614 | - | if !useDirectMount { | |
| 615 | - | logger.Debug("LoadConfig: Creating config directory", "dir", configDir) | |
| 616 | - | err = os.MkdirAll(configDir, 0755) | |
| 617 | - | if err != nil { | |
| 618 | - | logger.Error("LoadConfig: Failed to create config directory", "error", err) | |
| 619 | - | return nil, fmt.Errorf("error creating configuration directory: %w", err) | |
| 620 | - | } | |
| 621 | - | } else { | |
| 622 | - | logger.Debug("LoadConfig: Using direct mount, skipping config directory creation") | |
| 623 | - | } | |
| 624 | - | ||
| 625 | - | // Start with an empty config | |
| 626 | - | *config = Config{} | |
| 627 | - | ||
| 628 | - | // Set specific initial values before applying defaults | |
| 629 | - | ||
| 630 | - | // Storage Directory | |
| 631 | - | if isInContainer { | |
| 632 | - | config.General.StorageDir = "/data" | |
| 633 | - | logger.Debug("LoadConfig: Setting initial StorageDir for container", "value", config.General.StorageDir) | |
| 634 | - | } else { | |
| 635 | - | homeDir, err := os.UserHomeDir() | |
| 636 | - | if err != nil { | |
| 637 | - | logger.Error("LoadConfig: Failed to get user home directory", "error", err) | |
| 638 | - | // Setting a temporary default, applyDefaultsToConfig will handle it properly later if needed | |
| 639 | - | homeDir = "/tmp" | |
| 640 | - | } | |
| 641 | - | config.General.StorageDir = filepath.Join(homeDir, ".gordon") | |
| 642 | - | logger.Debug("LoadConfig: Setting initial StorageDir for host", "value", config.General.StorageDir) | |
| 643 | - | } | |
| 644 | - | ||
| 645 | - | // Podman Setting | |
| 646 | - | var defaultPodman bool | |
| 647 | - | if !isInContainer { // Only prompt if interactive and not in a container | |
| 648 | - | logger.Info("Not in container, prompting for podman preference...") | |
| 649 | - | logger.Debug("LoadConfig: About to prompt for podman preference") | |
| 650 | - | defaultPodman = ReadUserInputNonBlocking("Are you using podman ? (y/n)", "n") == "y" | |
| 651 | - | logger.Debug("LoadConfig: User responded to podman prompt", "usingPodman", defaultPodman) | |
| 652 | - | } else if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |
| 653 | - | defaultPodman = true | |
| 654 | - | logger.Info("Podman container engine detected via environment variable") | |
| 655 | - | } else { | |
| 656 | - | // Default to false (Docker) in container if env var not set | |
| 657 | - | defaultPodman = false | |
| 658 | - | logger.Info("Using Docker as the default container engine in container environment") | |
| 659 | - | } | |
| 660 | - | config.ContainerEngine.Podman = defaultPodman | |
| 661 | - | logger.Debug("LoadConfig: Set initial podman value", "value", defaultPodman) | |
| 662 | - | ||
| 663 | - | // Now apply all other defaults | |
| 664 | - | logger.Info("Applying default values for the new configuration...") | |
| 665 | - | defaultsApplied = applyDefaultsToConfig(config) | |
| 666 | - | // defaultsApplied is already true implicitly because we created the file | |
| 667 | - | ||
| 668 | - | } else if err != nil { | |
| 669 | - | logger.Error("LoadConfig: Error checking if config file exists", "error", err) | |
| 670 | - | return nil, fmt.Errorf("error checking configuration file: %w", err) | |
| 671 | - | } else { | |
| 672 | - | // If config file exists, read it | |
| 673 | - | logger.Info("Reading existing configuration file...") | |
| 674 | - | logger.Debug("LoadConfig: About to read and unmarshal existing config file") | |
| 675 | - | // Use DirFS based on whether it's a direct mount or in configDir | |
| 676 | - | var baseDir string | |
| 677 | - | var fileName string | |
| 678 | - | if useDirectMount { | |
| 679 | - | baseDir = "." | |
| 680 | - | fileName = "config.yml" | |
| 681 | - | } else { | |
| 682 | - | baseDir = configDir | |
| 683 | - | fileName = "config.yml" | |
| 684 | - | } | |
| 685 | - | err = readAndUnmarshalConfig(os.DirFS(baseDir), fileName, config) | |
| 686 | - | if err != nil { | |
| 687 | - | logger.Error("LoadConfig: Failed to read/unmarshal config file", "error", err) | |
| 688 | - | return nil, err | |
| 689 | - | } | |
| 690 | - | logger.Debug("LoadConfig: Successfully read and unmarshaled config file") | |
| 691 | - | ||
| 692 | - | // Apply default values to fields that were not specified in the config file | |
| 693 | - | logger.Info("Applying default values to missing configuration fields...") | |
| 694 | - | defaultsApplied = applyDefaultsToConfig(config) | |
| 695 | - | if defaultsApplied { | |
| 696 | - | shouldSaveConfig = true // Mark for saving if defaults were applied to existing file | |
| 697 | - | } | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | // --- Common Steps for Interactive Mode (after file read/creation) --- | |
| 701 | - | ||
| 702 | - | // Load environment variables if in a container (might override file/defaults) | |
| 703 | - | envVarsLoaded := false | |
| 704 | - | if isInContainer { | |
| 705 | - | shouldPrintLogs := !configLogsPrinted | |
| 706 | - | logger.Info("Running in container, overriding configuration with environment variables...") | |
| 707 | - | logger.Debug("LoadConfig: About to load config from environment") | |
| 708 | - | loadConfigFromEnv(config, shouldPrintLogs) // Assuming modification in place | |
| 709 | - | envVarsLoaded = true | |
| 710 | - | configLogsPrinted = true | |
| 711 | - | shouldSaveConfig = true // Mark for saving if env vars were applied | |
| 712 | - | logger.Debug("LoadConfig: Finished loading config from environment") | |
| 713 | - | } | |
| 714 | - | ||
| 715 | - | // Load run env | |
| 716 | - | logger.Debug("LoadConfig: Setting run environment") | |
| 717 | - | config.Build.RunEnv = os.Getenv("RUN_ENV") | |
| 718 | - | if config.Build.RunEnv == "" { | |
| 719 | - | config.Build.RunEnv = "prod" | |
| 720 | - | logger.Info("No RUN_ENV specified, defaulting to 'prod'") | |
| 721 | - | } else { | |
| 722 | - | logger.Info("Run environment set", "env", config.Build.RunEnv) | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | // Check AdminWebUI status | |
| 726 | - | if config.Admin.Path == "" { | |
| 727 | - | logger.Warn("Admin path is empty or not set, admin dashboard webui will be disabled") | |
| 728 | - | config.Admin.AdminWebUI = false | |
| 729 | - | } else { | |
| 730 | - | config.Admin.AdminWebUI = true | |
| 731 | - | } | |
| 732 | - | ||
| 733 | - | // Save config file if it was created, defaults were applied, or env vars loaded in container | |
| 734 | - | if shouldSaveConfig { | |
| 735 | - | logger.Info("Saving final configuration state...", "path", configFilePath) | |
| 736 | - | logger.Debug("LoadConfig: About to save configuration", "reason", fmt.Sprintf("configExists=%v, defaultsApplied=%v, envVarsLoadedInContainer=%v", !configExists, defaultsApplied, envVarsLoaded && isInContainer)) | |
| 737 | - | err = config.SaveConfig() // SaveConfig determines the correct path internally now | |
| 738 | - | if err != nil { | |
| 739 | - | // Log error but proceed, config is loaded, just potentially not saved | |
| 740 | - | logger.Error("LoadConfig: Failed to save final configuration", "error", err) | |
| 741 | - | } else { | |
| 742 | - | logger.Info("LoadConfig: Successfully saved final configuration") | |
| 743 | - | } | |
| 744 | - | } | |
| 745 | - | ||
| 746 | - | // Apply the log level from the final configuration | |
| 747 | - | if config.General.LogLevel != "" { | |
| 748 | - | logger.GetLogger().SetLogLevel(config.General.LogLevel) | |
| 749 | - | logger.Debug("Log level set from final configuration", "level", config.General.LogLevel) | |
| 750 | - | } else { | |
| 751 | - | logger.Warn("Final configuration has empty LogLevel") | |
| 752 | - | } | |
| 753 | - | ||
| 754 | - | // Final debug log before returning | |
| 755 | - | logger.Debug("LoadConfig: Completed interactive LoadConfig function") | |
| 756 | - | return config, nil | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | func (config *Config) SaveConfig() error { | |
| 760 | - | configDir, err := getConfigDir() | |
| 761 | - | if err != nil { | |
| 762 | - | return fmt.Errorf("error getting configuration directory: %w", err) | |
| 763 | - | } | |
| 764 | - | isInContainer := docker.IsRunningInContainer() | |
| 765 | - | ||
| 766 | - | // Determine the correct save path | |
| 767 | - | var configFilePath string | |
| 768 | - | directMountPath := "/config.yml" // Use root path for direct container mount check | |
| 769 | - | if isInContainer && fileExists(directMountPath) { | |
| 770 | - | configFilePath = directMountPath | |
| 771 | - | logger.Debug("SaveConfig: Using direct mount path", "path", configFilePath) | |
| 772 | - | } else if isInContainer && fileExists("./config.yml") { // Check relative path if direct root mount check fails | |
| 773 | - | configFilePath = "./config.yml" | |
| 774 | - | logger.Debug("SaveConfig: Using relative path in container", "path", configFilePath) | |
| 775 | - | } else { | |
| 776 | - | // Default to XDG path if not a direct mount or relative path found in container | |
| 777 | - | configFilePath = filepath.Join(configDir, "config.yml") | |
| 778 | - | logger.Debug("SaveConfig: Using standard config directory path", "path", configFilePath) | |
| 779 | - | ||
| 780 | - | // Ensure the directory exists before trying to write the file | |
| 781 | - | err = os.MkdirAll(filepath.Dir(configFilePath), 0755) | |
| 782 | - | if err != nil { | |
| 783 | - | return fmt.Errorf("error ensuring configuration directory exists for saving: %w", err) | |
| 784 | - | } | |
| 785 | - | } | |
| 786 | - | ||
| 787 | - | logger.Info("Saving configuration to", "path", configFilePath) | |
| 788 | - | ||
| 789 | - | // Create a temporary config copy excluding Build field for saving | |
| 790 | - | saveConfig := *config | |
| 791 | - | saveConfig.Build = BuildConfig{} // Exclude build info from saved file | |
| 792 | - | ||
| 793 | - | err = parser.WriteYAMLFile(configFilePath, &saveConfig) // Pass pointer to the copy | |
| 794 | - | if err != nil { | |
| 795 | - | return fmt.Errorf("error writing configuration file: %w", err) | |
| 796 | - | } | |
| 797 | - | ||
| 798 | - | logger.Debug("SaveConfig: Configuration saved successfully", "path", configFilePath) | |
| 799 | - | return nil | |
| 800 | - | } | |
| 801 | - | ||
| 802 | - | // Helper function to check if a file exists | |
| 803 | - | func fileExists(filepath string) bool { | |
| 804 | - | _, err := os.Stat(filepath) | |
| 805 | - | return err == nil | |
| 806 | - | } | |
| 807 | - | ||
| 808 | - | // GetRunEnv returns the run environment | |
| 809 | - | func (config *BuildConfig) GetRunEnv() string { | |
| 810 | - | return config.RunEnv | |
| 811 | - | } | |
| 812 | - | ||
| 813 | - | // get storage dir | |
| 814 | - | func (config *Config) GetStorageDir() string { | |
| 815 | - | return config.General.StorageDir | |
| 816 | - | } | |
| 817 | - | ||
| 818 | - | func (c *Config) GetBackendURL() string { | |
| 819 | - | return c.Http.BackendURL | |
| 820 | - | } | |
| 821 | - | ||
| 822 | - | // GetToken returns the token | |
| 823 | - | func (c *Config) GetToken() string { | |
| 824 | - | return c.General.JwtToken | |
| 825 | - | } | |
| 826 | - | ||
| 827 | - | func (c *HttpConfig) Protocol() string { | |
| 828 | - | if c.Https { | |
| 829 | - | return "https" | |
| 830 | - | } | |
| 831 | - | return "http" | |
| 832 | - | } | |
| 833 | - | ||
| 834 | - | func (c *HttpConfig) FullDomain() string { | |
| 835 | - | domain := c.Domain | |
| 836 | - | ||
| 837 | - | // Clean the domain to ensure it doesn't contain path components | |
| 838 | - | domain = strings.TrimSuffix(domain, "/") | |
| 839 | - | if idx := strings.Index(domain, "/"); idx >= 0 { | |
| 840 | - | // If there's a path component, remove it | |
| 841 | - | domain = domain[:idx] | |
| 842 | - | } | |
| 843 | - | ||
| 844 | - | if c.SubDomain != "" { | |
| 845 | - | // Clean the subdomain too | |
| 846 | - | subdomain := strings.TrimSuffix(c.SubDomain, "/") | |
| 847 | - | return fmt.Sprintf("%s.%s", subdomain, domain) | |
| 848 | - | } | |
| 849 | - | return domain | |
| 850 | - | } | |
| 851 | - | ||
| 852 | - | // GetVersion | |
| 853 | - | func (c *Config) GetVersion() string { | |
| 854 | - | return c.Build.BuildVersion | |
| 855 | - | } | |
| 856 | - | ||
| 857 | - | // IsAdminWebUIEnabled returns whether the admin dashboard is enabled | |
| 858 | - | func (c *AdminConfig) IsAdminWebUIEnabled() bool { | |
| 859 | - | return c.AdminWebUI && c.Path != "" | |
| 860 | - | } | |
D internal/common/init_dockerclient.go +0 -221
| @@ -1,221 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "log" | |
| 6 | - | "os" | |
| 7 | - | "strings" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/pkg/docker" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | func DockerInit(cc *ContainerEngineConfig) error { | |
| 13 | - | // Auto-detect Podman based on socket path | |
| 14 | - | isPodmanSocket := detectPodmanSocket(cc.Sock) | |
| 15 | - | if isPodmanSocket && !cc.Podman { | |
| 16 | - | fmt.Println("Podman socket detected at", cc.Sock) | |
| 17 | - | fmt.Println("Automatically enabling Podman support") | |
| 18 | - | cc.Podman = true | |
| 19 | - | cc.PodmanSock = cc.Sock | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // If socket is empty, try to detect it automatically | |
| 23 | - | if cc.Sock == "" { | |
| 24 | - | // Try standard paths first | |
| 25 | - | standardPaths := []string{ | |
| 26 | - | "/var/run/docker.sock", // Standard Docker socket in containers | |
| 27 | - | "/run/podman/podman.sock", // Standard Podman path | |
| 28 | - | "/var/run/podman/podman.sock", // Alternative standard path | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // Add home directory based path | |
| 32 | - | homePath := os.Getenv("HOME") | |
| 33 | - | if homePath != "" { | |
| 34 | - | standardPaths = append(standardPaths, homePath+"/.local/podman/podman.sock") | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // Check standard paths first | |
| 38 | - | for _, path := range standardPaths { | |
| 39 | - | if _, err := os.Stat(path); err == nil { | |
| 40 | - | log.Printf("Auto-detected container engine socket at %s", path) | |
| 41 | - | ||
| 42 | - | // Detect if this is a Podman socket | |
| 43 | - | isPodmanSocket = detectPodmanSocket(path) | |
| 44 | - | if isPodmanSocket { | |
| 45 | - | cc.Podman = true | |
| 46 | - | cc.PodmanSock = path | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | cc.Sock = path | |
| 50 | - | break | |
| 51 | - | } else { | |
| 52 | - | log.Printf("Socket not found at: %s", path) | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // If not found in standard paths, try common user IDs | |
| 57 | - | if cc.Sock == "" { | |
| 58 | - | log.Printf("Scanning for user-specific Podman sockets in ID range 1000-1100...") | |
| 59 | - | ||
| 60 | - | // Try common user IDs from 1000 to 1100 (typical range for first users on Linux) | |
| 61 | - | for uid := 1000; uid <= 1100; uid++ { | |
| 62 | - | uidStr := fmt.Sprintf("%d", uid) | |
| 63 | - | podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr) | |
| 64 | - | dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr) | |
| 65 | - | ||
| 66 | - | // Check the Podman socket exists | |
| 67 | - | if _, err := os.Stat(podmanSocketPath); err == nil { | |
| 68 | - | // Check if the socket is accessible for reading/writing | |
| 69 | - | if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil { | |
| 70 | - | file.Close() | |
| 71 | - | log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath) | |
| 72 | - | cc.Podman = true | |
| 73 | - | cc.PodmanSock = podmanSocketPath | |
| 74 | - | cc.Sock = podmanSocketPath | |
| 75 | - | break | |
| 76 | - | } else { | |
| 77 | - | log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err) | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Check Docker socket for this UID | |
| 82 | - | if _, err := os.Stat(dockerSocketPath); err == nil { | |
| 83 | - | if file, err := os.OpenFile(dockerSocketPath, os.O_RDWR, 0); err == nil { | |
| 84 | - | file.Close() | |
| 85 | - | log.Printf("Found accessible Docker socket for user ID %s at: %s", uidStr, dockerSocketPath) | |
| 86 | - | cc.Sock = dockerSocketPath | |
| 87 | - | break | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | } | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | // If we found a socket, try to initialize Docker client | |
| 95 | - | if cc.Sock != "" { | |
| 96 | - | config := NewDockerConfig(cc) | |
| 97 | - | err := docker.InitializeClient(config) | |
| 98 | - | if err == nil { | |
| 99 | - | log.Printf("Successfully initialized Docker client with socket: %s", cc.Sock) | |
| 100 | - | return nil | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Log the error but continue with fallback strategy | |
| 104 | - | log.Printf("Error initializing Docker client: %s", err) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // If we failed to find or initialize a socket, try a comprehensive fallback scan | |
| 108 | - | // across all common socket locations and user IDs | |
| 109 | - | socketPaths := []string{ | |
| 110 | - | "/var/run/docker.sock", | |
| 111 | - | "/run/podman/podman.sock", | |
| 112 | - | "/var/run/podman/podman.sock", | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // Add home directory based path | |
| 116 | - | homePath := os.Getenv("HOME") | |
| 117 | - | if homePath != "" { | |
| 118 | - | socketPaths = append(socketPaths, homePath+"/.local/podman/podman.sock") | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // Skip if we already tried this path | |
| 122 | - | if cc.Sock != "" { | |
| 123 | - | skipPath := cc.Sock | |
| 124 | - | for i, path := range socketPaths { | |
| 125 | - | if path == skipPath { | |
| 126 | - | socketPaths = append(socketPaths[:i], socketPaths[i+1:]...) | |
| 127 | - | break | |
| 128 | - | } | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // Try common user IDs from 1000 to 1100 | |
| 133 | - | for uid := 1000; uid <= 1100; uid++ { | |
| 134 | - | uidStr := fmt.Sprintf("%d", uid) | |
| 135 | - | podmanSocketPath := fmt.Sprintf("/run/user/%s/podman/podman.sock", uidStr) | |
| 136 | - | dockerSocketPath := fmt.Sprintf("/run/user/%s/docker/docker.sock", uidStr) | |
| 137 | - | ||
| 138 | - | // Check the Podman socket exists | |
| 139 | - | if _, err := os.Stat(podmanSocketPath); err == nil { | |
| 140 | - | // Check if the socket is accessible for reading/writing | |
| 141 | - | if file, err := os.OpenFile(podmanSocketPath, os.O_RDWR, 0); err == nil { | |
| 142 | - | file.Close() | |
| 143 | - | log.Printf("Found accessible Podman socket for user ID %s at: %s", uidStr, podmanSocketPath) | |
| 144 | - | socketPaths = append([]string{podmanSocketPath}, socketPaths...) // Prioritize this path | |
| 145 | - | } else { | |
| 146 | - | log.Printf("Found Podman socket for user ID %s at %s but it's not accessible: %v", uidStr, podmanSocketPath, err) | |
| 147 | - | } | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // Also check Docker socket for this UID | |
| 151 | - | if _, err := os.Stat(dockerSocketPath); err == nil { | |
| 152 | - | socketPaths = append(socketPaths, dockerSocketPath) | |
| 153 | - | } | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | log.Printf("Comprehensive socket scan across %d potential paths...", len(socketPaths)) | |
| 157 | - | ||
| 158 | - | for _, path := range socketPaths { | |
| 159 | - | // Skip if it's the path we already tried | |
| 160 | - | if path == cc.Sock { | |
| 161 | - | continue | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // Check if this socket exists | |
| 165 | - | if _, err := os.Stat(path); err == nil { | |
| 166 | - | log.Printf("Found alternative socket at %s", path) | |
| 167 | - | ||
| 168 | - | // Detect if this is a Podman socket | |
| 169 | - | isPodmanSocket = detectPodmanSocket(path) | |
| 170 | - | if isPodmanSocket { | |
| 171 | - | cc.Podman = true | |
| 172 | - | cc.PodmanSock = path | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | cc.Sock = path | |
| 176 | - | ||
| 177 | - | // Try again with the new path | |
| 178 | - | newConfig := NewDockerConfig(cc) | |
| 179 | - | if err := docker.InitializeClient(newConfig); err == nil { | |
| 180 | - | log.Printf("Successfully initialized Docker client with socket: %s", path) | |
| 181 | - | return nil | |
| 182 | - | } else { | |
| 183 | - | log.Printf("Socket found but client initialization failed: %s", err) | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // If we still haven't found a valid socket, give up but don't fail the application | |
| 189 | - | log.Printf("No working container engine socket found after comprehensive scan") | |
| 190 | - | log.Printf("Docker/Podman functionality will be limited or unavailable") | |
| 191 | - | return fmt.Errorf("could not find or connect to any container engine socket") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | // detectPodmanSocket checks if a socket path is likely a Podman socket | |
| 195 | - | func detectPodmanSocket(socketPath string) bool { | |
| 196 | - | if socketPath == "" { | |
| 197 | - | return false | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | // Check if it's a user-specific Podman socket path | |
| 201 | - | if strings.Contains(socketPath, "user") && strings.Contains(socketPath, "podman") { | |
| 202 | - | log.Printf("User-specific rootless Podman socket detected: %s", socketPath) | |
| 203 | - | return true | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | return strings.Contains(socketPath, "podman") | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // NewDockerConfig creates and returns a new Docker client configuration | |
| 210 | - | func NewDockerConfig(cc *ContainerEngineConfig) *docker.Config { | |
| 211 | - | if cc.Podman { | |
| 212 | - | return &docker.Config{ | |
| 213 | - | Sock: cc.PodmanSock, | |
| 214 | - | PodmanEnable: true, | |
| 215 | - | } | |
| 216 | - | } | |
| 217 | - | return &docker.Config{ | |
| 218 | - | Sock: cc.Sock, | |
| 219 | - | PodmanEnable: false, | |
| 220 | - | } | |
| 221 | - | } | |
D internal/common/init_env.go +0 -283
| @@ -1,283 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "strconv" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/bnema/gordon/pkg/logger" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // loadConfigFromEnv loads configuration from environment variables | |
| 13 | - | func loadConfigFromEnv(config *Config, printLogs bool) { | |
| 14 | - | // General Configuration | |
| 15 | - | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | |
| 16 | - | config.General.StorageDir = val | |
| 17 | - | if printLogs { | |
| 18 | - | logger.Info("Using environment variable GORDON_STORAGE_DIR", "value", val) | |
| 19 | - | } | |
| 20 | - | } | |
| 21 | - | // Removed GORDON_TOKEN handling | |
| 22 | - | if val := os.Getenv("GORDON_LOG_LEVEL"); val != "" { | |
| 23 | - | config.General.LogLevel = val | |
| 24 | - | if printLogs { | |
| 25 | - | logger.Info("Using environment variable GORDON_LOG_LEVEL", "value", val) | |
| 26 | - | } | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // HTTP Configuration | |
| 30 | - | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | |
| 31 | - | config.Http.Port = val | |
| 32 | - | if printLogs { | |
| 33 | - | logger.Info("Using environment variable GORDON_HTTP_PORT", "value", val) | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | |
| 37 | - | config.Http.Domain = val | |
| 38 | - | if printLogs { | |
| 39 | - | logger.Info("Using environment variable GORDON_HTTP_DOMAIN", "value", val) | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | |
| 43 | - | config.Http.SubDomain = val | |
| 44 | - | if printLogs { | |
| 45 | - | logger.Info("Using environment variable GORDON_HTTP_SUBDOMAIN", "value", val) | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | |
| 49 | - | config.Http.BackendURL = val | |
| 50 | - | if printLogs { | |
| 51 | - | logger.Info("Using environment variable GORDON_HTTP_BACKEND_URL", "value", val) | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | |
| 55 | - | config.Http.Https = strings.ToLower(val) == "true" | |
| 56 | - | if printLogs { | |
| 57 | - | logger.Info("Using environment variable GORDON_HTTP_HTTPS", "value", config.Http.Https) | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // Admin Configuration | |
| 62 | - | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | |
| 63 | - | config.Admin.Path = val | |
| 64 | - | if printLogs { | |
| 65 | - | logger.Info("Using environment variable GORDON_ADMIN_PATH", "value", val) | |
| 66 | - | } | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Container Engine Configuration | |
| 70 | - | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | |
| 71 | - | config.ContainerEngine.Sock = val | |
| 72 | - | if printLogs { | |
| 73 | - | logger.Info("Using environment variable GORDON_CONTAINER_SOCK", "value", val) | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | if val := os.Getenv("GORDON_CONTAINER_PODMANSOCK"); val != "" { | |
| 77 | - | config.ContainerEngine.PodmanSock = val | |
| 78 | - | if printLogs { | |
| 79 | - | logger.Info("Using environment variable GORDON_CONTAINER_PODMANSOCK", "value", val) | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | |
| 83 | - | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | |
| 84 | - | if printLogs { | |
| 85 | - | logger.Info("Using environment variable GORDON_CONTAINER_PODMAN", "value", config.ContainerEngine.Podman) | |
| 86 | - | } | |
| 87 | - | } else { | |
| 88 | - | // Auto-detect Podman if not specified in environment | |
| 89 | - | isPodman, podmanSocket := docker.DetectPodman() | |
| 90 | - | if isPodman { | |
| 91 | - | config.ContainerEngine.Podman = true | |
| 92 | - | config.ContainerEngine.PodmanSock = podmanSocket | |
| 93 | - | if printLogs { | |
| 94 | - | logger.Info("Automatically detected Podman installation", | |
| 95 | - | "using_podman", true, | |
| 96 | - | "socket", podmanSocket) | |
| 97 | - | } | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // If we're in a container, check for special socket naming patterns | |
| 101 | - | if docker.IsRunningInContainer() && fileExists(config.ContainerEngine.Sock) { | |
| 102 | - | // Check if socket path contains "podman" which indicates it might be a podman socket | |
| 103 | - | if strings.Contains(config.ContainerEngine.Sock, "podman") { | |
| 104 | - | config.ContainerEngine.Podman = true | |
| 105 | - | if printLogs { | |
| 106 | - | logger.Info("Socket path contains 'podman', enabling Podman mode", "path", config.ContainerEngine.Sock) | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | |
| 112 | - | config.ContainerEngine.Network = val | |
| 113 | - | if printLogs { | |
| 114 | - | logger.Info("Using environment variable GORDON_CONTAINER_NETWORK", "value", val) | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Use Podman socket if Podman is enabled and no Docker socket is specified | |
| 119 | - | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | |
| 120 | - | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | |
| 121 | - | if printLogs { | |
| 122 | - | logger.Info("Setting ContainerEngine.Sock to PodmanSock value", "value", config.ContainerEngine.Sock) | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // Additional debug info for container sockets | |
| 127 | - | if docker.IsRunningInContainer() { | |
| 128 | - | logger.Debug("Container socket configuration", | |
| 129 | - | "sock", config.ContainerEngine.Sock, | |
| 130 | - | "podmansock", config.ContainerEngine.PodmanSock, | |
| 131 | - | "podman", config.ContainerEngine.Podman, | |
| 132 | - | "sock_exists", fileExists(config.ContainerEngine.Sock), | |
| 133 | - | "podmansock_exists", fileExists(config.ContainerEngine.PodmanSock)) | |
| 134 | - | ||
| 135 | - | // Special handling for container environment to ensure sockets are properly set | |
| 136 | - | // If no socket is configured but default socket path exists, use it | |
| 137 | - | if config.ContainerEngine.Sock == "" && fileExists(sock) { | |
| 138 | - | config.ContainerEngine.Sock = sock | |
| 139 | - | logger.Info("Found default Docker socket in container, using it", "path", sock) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // If podman socket exists but podman mode is not enabled, check if we should enable it | |
| 143 | - | if fileExists(podmansock) && !config.ContainerEngine.Podman { | |
| 144 | - | // If the main socket path contains "podman", enable podman mode | |
| 145 | - | if strings.Contains(config.ContainerEngine.Sock, "podman") { | |
| 146 | - | config.ContainerEngine.Podman = true | |
| 147 | - | logger.Info("Socket path suggests Podman usage, enabling Podman mode", "path", config.ContainerEngine.Sock) | |
| 148 | - | } | |
| 149 | - | } | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // Reverse Proxy Configuration | |
| 153 | - | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | |
| 154 | - | config.ReverseProxy.Port = val | |
| 155 | - | if printLogs { | |
| 156 | - | logger.Info("Using environment variable GORDON_PROXY_PORT", "value", val) | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | |
| 160 | - | config.ReverseProxy.HttpPort = val | |
| 161 | - | if printLogs { | |
| 162 | - | logger.Info("Using environment variable GORDON_PROXY_HTTP_PORT", "value", val) | |
| 163 | - | } | |
| 164 | - | } | |
| 165 | - | ||
| 166 | - | // New environment variables for reverse proxy port control | |
| 167 | - | if val := os.Getenv("GORDON_USE_FALLBACK_BINDING"); val != "" { | |
| 168 | - | // This is handled at runtime in the proxy server but we log it here | |
| 169 | - | if printLogs { | |
| 170 | - | logger.Info("Found environment variable GORDON_USE_FALLBACK_BINDING", "value", val) | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // Handle GORDON_PROXY_ENABLED environment variable | |
| 175 | - | if val := os.Getenv("GORDON_PROXY_ENABLED"); val != "" { | |
| 176 | - | enabled, err := strconv.ParseBool(val) | |
| 177 | - | if err == nil { | |
| 178 | - | config.ReverseProxy.Enabled = enabled | |
| 179 | - | } | |
| 180 | - | if printLogs { | |
| 181 | - | logger.Info("Using environment variable GORDON_PROXY_ENABLED", "value", config.ReverseProxy.Enabled) | |
| 182 | - | } | |
| 183 | - | } | |
| 184 | - | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | |
| 185 | - | config.ReverseProxy.CertDir = val | |
| 186 | - | if printLogs { | |
| 187 | - | logger.Info("Using environment variable GORDON_PROXY_CERT_DIR", "value", val) | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | |
| 191 | - | if i, err := strconv.Atoi(val); err == nil { | |
| 192 | - | config.ReverseProxy.RenewBefore = i | |
| 193 | - | if printLogs { | |
| 194 | - | logger.Info("Using environment variable GORDON_PROXY_RENEW_BEFORE", "value", i) | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | |
| 199 | - | config.ReverseProxy.LetsEncryptMode = val | |
| 200 | - | if printLogs { | |
| 201 | - | logger.Info("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE", "value", val) | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | |
| 205 | - | config.ReverseProxy.Email = val | |
| 206 | - | if printLogs { | |
| 207 | - | logger.Info("Using environment variable GORDON_PROXY_EMAIL", "value", val) | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" { | |
| 211 | - | i, err := strconv.Atoi(val) | |
| 212 | - | if err == nil { | |
| 213 | - | config.ReverseProxy.GracePeriod = i | |
| 214 | - | } | |
| 215 | - | logger.Info("Using environment variable GORDON_PROXY_GRACE_PERIOD", "value", i) | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | // Handle GORDON_PROXY_ENABLE_HTTP_LOGS environment variable | |
| 219 | - | if val := os.Getenv("GORDON_PROXY_ENABLE_HTTP_LOGS"); val != "" { | |
| 220 | - | enableLogs, err := strconv.ParseBool(val) | |
| 221 | - | if err == nil { | |
| 222 | - | config.ReverseProxy.EnableHttpLogs = enableLogs | |
| 223 | - | } | |
| 224 | - | logger.Info("Using environment variable GORDON_PROXY_ENABLE_HTTP_LOGS", "value", config.ReverseProxy.EnableHttpLogs) | |
| 225 | - | } else if val := os.Getenv("GORDON_PROXY_ENABLE_LOGS"); val != "" { | |
| 226 | - | // For backward compatibility | |
| 227 | - | enableLogs, err := strconv.ParseBool(val) | |
| 228 | - | if err == nil { | |
| 229 | - | config.ReverseProxy.EnableHttpLogs = enableLogs | |
| 230 | - | } | |
| 231 | - | logger.Info("Using environment variable GORDON_PROXY_ENABLE_LOGS (deprecated, use GORDON_PROXY_ENABLE_HTTP_LOGS)", "value", config.ReverseProxy.EnableHttpLogs) | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | // Handle GORDON_PROXY_ENABLE_RATE_LIMIT environment variable | |
| 235 | - | if val := os.Getenv("GORDON_PROXY_ENABLE_RATE_LIMIT"); val != "" { | |
| 236 | - | enableRateLimit, err := strconv.ParseBool(val) | |
| 237 | - | if err == nil { | |
| 238 | - | config.ReverseProxy.EnableRateLimit = enableRateLimit | |
| 239 | - | } | |
| 240 | - | logger.Info("Using environment variable GORDON_PROXY_ENABLE_RATE_LIMIT", "value", config.ReverseProxy.EnableRateLimit) | |
| 241 | - | } else if val := os.Getenv("GORDON_PROXY_DISABLE_RATE_LIMIT"); val != "" { | |
| 242 | - | // For backward compatibility | |
| 243 | - | disableRateLimit, err := strconv.ParseBool(val) | |
| 244 | - | if err == nil { | |
| 245 | - | // Invert the logic | |
| 246 | - | config.ReverseProxy.EnableRateLimit = !disableRateLimit | |
| 247 | - | } | |
| 248 | - | logger.Info("Using environment variable GORDON_PROXY_DISABLE_RATE_LIMIT (deprecated, use GORDON_PROXY_ENABLE_RATE_LIMIT)", "value", !config.ReverseProxy.EnableRateLimit) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // Handle GORDON_PROXY_DETECT_UPSTREAM environment variable | |
| 252 | - | if val := os.Getenv("GORDON_PROXY_DETECT_UPSTREAM"); val != "" { | |
| 253 | - | detectUpstream, err := strconv.ParseBool(val) | |
| 254 | - | if err == nil { | |
| 255 | - | config.ReverseProxy.DetectUpstreamProxy = detectUpstream | |
| 256 | - | } | |
| 257 | - | if printLogs { | |
| 258 | - | logger.Info("Using environment variable GORDON_PROXY_DETECT_UPSTREAM", "value", config.ReverseProxy.DetectUpstreamProxy) | |
| 259 | - | } | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | // Handle GORDON_PROXY_SKIP_CERTIFICATES environment variable | |
| 263 | - | if val := os.Getenv("GORDON_PROXY_SKIP_CERTIFICATES"); val != "" { | |
| 264 | - | skipCerts, err := strconv.ParseBool(val) | |
| 265 | - | if err == nil { | |
| 266 | - | config.ReverseProxy.SkipCertificates = skipCerts | |
| 267 | - | } | |
| 268 | - | if printLogs { | |
| 269 | - | logger.Info("Using environment variable GORDON_PROXY_SKIP_CERTIFICATES", "value", config.ReverseProxy.SkipCertificates) | |
| 270 | - | } | |
| 271 | - | } | |
| 272 | - | ||
| 273 | - | // Handle GORDON_PROXY_BLOCK_DIRECT_IP environment variable | |
| 274 | - | if val := os.Getenv("GORDON_PROXY_BLOCK_DIRECT_IP"); val != "" { | |
| 275 | - | blockDirectIP, err := strconv.ParseBool(val) | |
| 276 | - | if err == nil { | |
| 277 | - | config.ReverseProxy.BlockDirectIP = blockDirectIP | |
| 278 | - | } | |
| 279 | - | if printLogs { | |
| 280 | - | logger.Info("Using environment variable GORDON_PROXY_BLOCK_DIRECT_IP", "value", config.ReverseProxy.BlockDirectIP) | |
| 281 | - | } | |
| 282 | - | } | |
| 283 | - | } | |
D internal/common/inputs.go +0 -110
| @@ -1,110 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bufio" | |
| 5 | - | "fmt" | |
| 6 | - | "os" | |
| 7 | - | "strings" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/pkg/logger" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // ReadUserInput reads a string input from the user with a prompt. | |
| 13 | - | // It handles multi-word inputs and properly checks for errors. | |
| 14 | - | func ReadUserInput(prompt string) string { | |
| 15 | - | logger.Debug("ReadUserInput: Prompting user for input", "prompt", prompt) | |
| 16 | - | // Make the prompt more visible with newlines and formatting | |
| 17 | - | fmt.Printf("\n>>> %s: ", prompt) | |
| 18 | - | // Flush to ensure the prompt is displayed | |
| 19 | - | os.Stdout.Sync() | |
| 20 | - | ||
| 21 | - | // Use bufio.NewReader for better input handling | |
| 22 | - | reader := bufio.NewReader(os.Stdin) | |
| 23 | - | logger.Debug("ReadUserInput: Created reader, waiting for input...") | |
| 24 | - | ||
| 25 | - | // ReadString reads until newline, handling spaces in input | |
| 26 | - | input, err := reader.ReadString('\n') | |
| 27 | - | if err != nil { | |
| 28 | - | logger.Error("ReadUserInput: Error reading input", "error", err) | |
| 29 | - | return "" | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Trim spaces and newline characters | |
| 33 | - | result := strings.TrimSpace(input) | |
| 34 | - | logger.Debug("ReadUserInput: Received input", "value", result) | |
| 35 | - | return result | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // ReadUserInputWithValidation reads user input and validates it using a provided function. | |
| 39 | - | // It keeps prompting until valid input is received. | |
| 40 | - | func ReadUserInputWithValidation(prompt string, validate func(string) bool) string { | |
| 41 | - | for { | |
| 42 | - | input := ReadUserInput(prompt) | |
| 43 | - | if validate(input) { | |
| 44 | - | return input | |
| 45 | - | } | |
| 46 | - | fmt.Println("Invalid input. Please try again.") | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // ReadUserInputWithDefault reads user input with a default value if input is empty. | |
| 51 | - | func ReadUserInputWithDefault(prompt string, defaultValue string) string { | |
| 52 | - | // Flush to ensure the prompt is displayed | |
| 53 | - | os.Stdout.Sync() | |
| 54 | - | ||
| 55 | - | reader := bufio.NewReader(os.Stdin) | |
| 56 | - | input, err := reader.ReadString('\n') | |
| 57 | - | if err != nil { | |
| 58 | - | logger.Error("Error reading input", "error", err) | |
| 59 | - | return defaultValue | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | input = strings.TrimSpace(input) | |
| 63 | - | if input == "" { | |
| 64 | - | return defaultValue | |
| 65 | - | } | |
| 66 | - | return input | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // ReadConfirmation reads a yes/no confirmation from the user. | |
| 70 | - | func ReadConfirmation(prompt string) bool { | |
| 71 | - | input := ReadUserInputWithValidation( | |
| 72 | - | prompt+" (y/n)", | |
| 73 | - | func(s string) bool { | |
| 74 | - | s = strings.ToLower(s) | |
| 75 | - | return s == "y" || s == "n" || s == "yes" || s == "no" | |
| 76 | - | }, | |
| 77 | - | ) | |
| 78 | - | ||
| 79 | - | input = strings.ToLower(input) | |
| 80 | - | return input == "y" || input == "yes" | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | // ReadUserInputNonBlocking attempts to read user input but doesn't block in non-interactive environments. | |
| 84 | - | // Instead, it returns the provided default value when not in an interactive terminal. | |
| 85 | - | func ReadUserInputNonBlocking(prompt string, defaultValue string) string { | |
| 86 | - | // Check if we're in an interactive terminal | |
| 87 | - | stat, err := os.Stdin.Stat() | |
| 88 | - | if err != nil { | |
| 89 | - | logger.Error("ReadUserInputNonBlocking: Error checking terminal status", "error", err) | |
| 90 | - | logger.Info("Defaulting to non-interactive mode due to error", "default", defaultValue) | |
| 91 | - | return defaultValue | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | isTerminal := (stat.Mode() & os.ModeCharDevice) != 0 | |
| 95 | - | logger.Debug("ReadUserInputNonBlocking: Terminal status check", | |
| 96 | - | "isInteractive", isTerminal, | |
| 97 | - | "fileMode", stat.Mode().String()) | |
| 98 | - | ||
| 99 | - | if isTerminal { | |
| 100 | - | // In interactive terminal, use normal prompt | |
| 101 | - | logger.Debug("ReadUserInputNonBlocking: Using interactive prompt") | |
| 102 | - | return ReadUserInput(prompt) | |
| 103 | - | } else { | |
| 104 | - | // Not in interactive terminal, log and return default | |
| 105 | - | logger.Info("Non-interactive environment, using default for prompt", | |
| 106 | - | "prompt", prompt, | |
| 107 | - | "default", defaultValue) | |
| 108 | - | return defaultValue | |
| 109 | - | } | |
| 110 | - | } | |
D internal/common/payload.go +0 -208
| @@ -1,208 +0,0 @@ | |||
| 1 | - | package common | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "io" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // RequestPayload represents the structure of incoming requests | |
| 10 | - | type RequestPayload struct { | |
| 11 | - | Type string `json:"type"` | |
| 12 | - | Payload Payload `json:"payload"` | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // Payload is an interface for different types of payloads | |
| 16 | - | type Payload interface { | |
| 17 | - | GetType() string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // DeployResponse represents the response structure for deployment requests | |
| 21 | - | type DeployResponse struct { | |
| 22 | - | Success bool `json:"success"` | |
| 23 | - | Message string `json:"message"` | |
| 24 | - | StatusCode int `json:"status_code,omitempty"` | |
| 25 | - | Domain string `json:"domain,omitempty"` | |
| 26 | - | ContainerID string `json:"container_id,omitempty"` | |
| 27 | - | ContainerName string `json:"container_name,omitempty"` | |
| 28 | - | State string `json:"state,omitempty"` | |
| 29 | - | RunningTime string `json:"running_time,omitempty"` | |
| 30 | - | Ports string `json:"ports,omitempty"` | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // PushResponse represents the response structure for push requests | |
| 34 | - | type PushResponse struct { | |
| 35 | - | Success bool `json:"success"` | |
| 36 | - | Message string `json:"message"` | |
| 37 | - | CreateContainerURL string `json:"create_container_url,omitempty"` | |
| 38 | - | ImageID string `json:"image_id,omitempty"` | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | type StopResponse struct { | |
| 42 | - | Success bool `json:"success"` | |
| 43 | - | Message string `json:"message"` | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | type RemoveResponse struct { | |
| 47 | - | Success bool `json:"success"` | |
| 48 | - | Message string `json:"message"` | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | type StartResponse struct { | |
| 52 | - | Success bool `json:"success"` | |
| 53 | - | Message string `json:"message"` | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // DeployPayload represents the payload for deployment requests | |
| 57 | - | type DeployPayload struct { | |
| 58 | - | Port string `json:"port"` | |
| 59 | - | TargetDomain string `json:"targetdomain"` | |
| 60 | - | ImageName string `json:"imagename"` | |
| 61 | - | ImageID string `json:"imageid"` | |
| 62 | - | SkipProxySetup bool `json:"skip_proxy_setup"` | |
| 63 | - | Data io.ReadCloser | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // PushPayload represents the payload for push requests | |
| 67 | - | type PushPayload struct { | |
| 68 | - | ImageName string `json:"imagename"` | |
| 69 | - | ImageID string `json:"imageid"` | |
| 70 | - | Data io.Reader | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | type ChunkMetadata struct { | |
| 74 | - | ChunkNumber int `json:"chunk_number"` | |
| 75 | - | TotalChunks int `json:"total_chunks"` | |
| 76 | - | ChunkSize int64 `json:"chunk_size"` | |
| 77 | - | TotalSize int64 `json:"total_size"` | |
| 78 | - | ImageName string `json:"image_name"` | |
| 79 | - | TransferID string `json:"transfer_id"` | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | type ConflictAction struct { | |
| 83 | - | Action string `json:"action"` // "stop", "remove", or "cancel" | |
| 84 | - | Force bool `json:"force"` | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | type DeviceFlowResponse struct { | |
| 88 | - | Code string `json:"code"` | |
| 89 | - | Message string `json:"message"` | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // UnmarshalJSON custom unmarshaler for RequestPayload | |
| 93 | - | func (p *RequestPayload) UnmarshalJSON(data []byte) error { | |
| 94 | - | var raw map[string]json.RawMessage | |
| 95 | - | err := json.Unmarshal(data, &raw) | |
| 96 | - | if err != nil { | |
| 97 | - | return err | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | if err := json.Unmarshal(raw["type"], &p.Type); err != nil { | |
| 101 | - | return err | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | // Unmarshal the payload based on the type | |
| 105 | - | switch p.Type { | |
| 106 | - | case "ping": | |
| 107 | - | var payload PingPayload | |
| 108 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 109 | - | return err | |
| 110 | - | } | |
| 111 | - | p.Payload = payload | |
| 112 | - | case "deploy": | |
| 113 | - | var payload DeployPayload | |
| 114 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 115 | - | return err | |
| 116 | - | } | |
| 117 | - | p.Payload = payload | |
| 118 | - | case "push": | |
| 119 | - | var payload PushPayload | |
| 120 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 121 | - | return err | |
| 122 | - | } | |
| 123 | - | p.Payload = payload | |
| 124 | - | case "stop": | |
| 125 | - | var payload StopPayload | |
| 126 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 127 | - | return err | |
| 128 | - | } | |
| 129 | - | p.Payload = payload | |
| 130 | - | case "start": | |
| 131 | - | var payload StartPayload | |
| 132 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 133 | - | return err | |
| 134 | - | } | |
| 135 | - | p.Payload = payload | |
| 136 | - | case "remove": | |
| 137 | - | var payload RemovePayload | |
| 138 | - | if err := json.Unmarshal(raw["payload"], &payload); err != nil { | |
| 139 | - | return err | |
| 140 | - | } | |
| 141 | - | p.Payload = payload | |
| 142 | - | default: | |
| 143 | - | return fmt.Errorf("invalid type: %s", p.Type) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | return nil | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // PingPayload represents the payload for ping requests | |
| 150 | - | type PingPayload struct { | |
| 151 | - | Message string `json:"message"` | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // GetType returns the type of the PingPayload | |
| 155 | - | func (p PingPayload) GetType() string { | |
| 156 | - | return "ping" | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | // GetType returns the type of the DeployPayload | |
| 160 | - | func (p DeployPayload) GetType() string { | |
| 161 | - | return "deploy" | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // GetType returns the type of the PushPayload | |
| 165 | - | func (p PushPayload) GetType() string { | |
| 166 | - | return "push" | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | // NewPingPayload creates a new PingPayload from a map of data | |
| 170 | - | func NewPingPayload(data map[string]interface{}) (PingPayload, error) { | |
| 171 | - | message, ok := data["message"].(string) | |
| 172 | - | if !ok { | |
| 173 | - | return PingPayload{}, fmt.Errorf("invalid data for message") | |
| 174 | - | } | |
| 175 | - | return PingPayload{Message: message}, nil | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // StopPayload represents the payload for stop requests | |
| 179 | - | type StopPayload struct { | |
| 180 | - | ContainerID string `json:"container_id"` | |
| 181 | - | ContainerName string `json:"container_name"` | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | // GetType returns the type of the StopPayload | |
| 185 | - | func (p StopPayload) GetType() string { | |
| 186 | - | return "stop" | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | // StartPayload represents the payload for start requests | |
| 190 | - | type StartPayload struct { | |
| 191 | - | ContainerID string `json:"container_id"` | |
| 192 | - | ContainerName string `json:"container_name"` | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // GetType returns the type of the StartPayload | |
| 196 | - | func (p StartPayload) GetType() string { | |
| 197 | - | return "start" | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | // RemovePayload represents the payload for remove requests | |
| 201 | - | type RemovePayload struct { | |
| 202 | - | ContainerID string `json:"container_id"` | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // GetType returns the type of the RemovePayload | |
| 206 | - | func (p RemovePayload) GetType() string { | |
| 207 | - | return "remove" | |
| 208 | - | } | |
D internal/common/version.go +0 -189
| @@ -1,189 +0,0 @@ | |||
| 1 | - | // internal/common/version.go | |
| 2 | - | ||
| 3 | - | package common | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "runtime/debug" | |
| 11 | - | "strings" | |
| 12 | - | "time" | |
| 13 | - | ||
| 14 | - | "github.com/Masterminds/semver/v3" | |
| 15 | - | "github.com/bnema/gordon/pkg/logger" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // Info represents version information | |
| 19 | - | type Info struct { | |
| 20 | - | Version string | |
| 21 | - | Commit string | |
| 22 | - | Date string | |
| 23 | - | ProxyURL string | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | type ProxyVersion struct { | |
| 27 | - | Version string `json:"version"` | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // GetVersionInfo returns the current version information based on BuildConfig | |
| 31 | - | func GetVersionInfo(buildVersion, buildCommit, buildDate, proxyURL string) Info { | |
| 32 | - | // If version was set in BuildConfig, use that | |
| 33 | - | if buildVersion != "" && buildVersion != "dev" { | |
| 34 | - | return Info{ | |
| 35 | - | Version: buildVersion, | |
| 36 | - | Commit: buildCommit, | |
| 37 | - | Date: buildDate, | |
| 38 | - | ProxyURL: proxyURL, | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Try to get version from runtime build info (for go install) | |
| 43 | - | if buildInfo, ok := debug.ReadBuildInfo(); ok { | |
| 44 | - | return Info{ | |
| 45 | - | Version: buildInfo.Main.Version, | |
| 46 | - | Commit: "unknown", | |
| 47 | - | Date: "unknown", | |
| 48 | - | ProxyURL: proxyURL, | |
| 49 | - | } | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // Fallback for development builds | |
| 53 | - | return Info{ | |
| 54 | - | Version: "dev", | |
| 55 | - | Commit: "unknown", | |
| 56 | - | Date: "unknown", | |
| 57 | - | ProxyURL: proxyURL, | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // CheckForNewVersion checks if a newer version is available via proxy | |
| 62 | - | func CheckForNewVersion(currentVersion, proxyURL string) (bool, string, error) { | |
| 63 | - | // Don't check for new versions in development builds or dirty builds | |
| 64 | - | if currentVersion == "dev" || strings.Contains(currentVersion, "-dirty") { | |
| 65 | - | return false, "", nil | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // If currentVersion is empty, treat it as v0.0.0 | |
| 69 | - | if currentVersion == "" { | |
| 70 | - | currentVersion = "v0.0.0" | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | client := &http.Client{ | |
| 74 | - | Timeout: 10 * time.Second, | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | resp, err := client.Get(proxyURL + "/version") | |
| 78 | - | if err != nil { | |
| 79 | - | return false, "", fmt.Errorf("error making request: %w", err) | |
| 80 | - | } | |
| 81 | - | defer resp.Body.Close() | |
| 82 | - | ||
| 83 | - | if resp.StatusCode != http.StatusOK { | |
| 84 | - | return false, "", fmt.Errorf("unexpected status code: %d", resp.StatusCode) | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | body, err := io.ReadAll(resp.Body) | |
| 88 | - | if err != nil { | |
| 89 | - | return false, "", fmt.Errorf("error reading response: %w", err) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | var proxyVer ProxyVersion | |
| 93 | - | if err := json.Unmarshal(body, &proxyVer); err != nil { | |
| 94 | - | return false, "", fmt.Errorf("error parsing JSON: %w", err) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Ensure versions start with 'v' | |
| 98 | - | if !strings.HasPrefix(currentVersion, "v") { | |
| 99 | - | currentVersion = "v" + currentVersion | |
| 100 | - | } | |
| 101 | - | if !strings.HasPrefix(proxyVer.Version, "v") { | |
| 102 | - | proxyVer.Version = "v" + proxyVer.Version | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Clean up version strings | |
| 106 | - | latestVersion := strings.TrimPrefix(proxyVer.Version, "v") | |
| 107 | - | currentBase := extractBaseVersion(currentVersion) | |
| 108 | - | ||
| 109 | - | // Handle development versions | |
| 110 | - | if currentBase == "" || currentBase == "dev" { | |
| 111 | - | currentBase = "0.0.0" | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // Parse versions using semver | |
| 115 | - | current, err := semver.NewVersion(currentBase) | |
| 116 | - | if err != nil { | |
| 117 | - | return false, "", fmt.Errorf("error parsing current version (%s): %w", currentBase, err) | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | latest, err := semver.NewVersion(latestVersion) | |
| 121 | - | if err != nil { | |
| 122 | - | return false, "", fmt.Errorf("error parsing latest version (%s): %w", latestVersion, err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | if latest.GreaterThan(current) { | |
| 126 | - | return true, proxyVer.Version, nil | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | return false, "", nil | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | // CheckVersionPeriodically checks for new versions periodically | |
| 133 | - | func CheckVersionPeriodically(info Info, checkInterval time.Duration) { | |
| 134 | - | ticker := time.NewTicker(checkInterval) | |
| 135 | - | defer ticker.Stop() | |
| 136 | - | ||
| 137 | - | // Initial check | |
| 138 | - | checkVersion(info) | |
| 139 | - | ||
| 140 | - | // Periodic checks | |
| 141 | - | for range ticker.C { | |
| 142 | - | checkVersion(info) | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | func checkVersion(info Info) { | |
| 147 | - | hasUpdate, latestVersion, err := CheckForNewVersion(info.Version, info.ProxyURL) | |
| 148 | - | if err != nil { | |
| 149 | - | logger.Error("Failed to check for updates", "error", err) | |
| 150 | - | return | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | if hasUpdate { | |
| 154 | - | logger.Info("New version available!", | |
| 155 | - | "current", info.Version, | |
| 156 | - | "latest", latestVersion, | |
| 157 | - | "update_url", "https://github.com/bnema/gordon/releases/latest") | |
| 158 | - | } | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func extractBaseVersion(version string) string { | |
| 162 | - | // Remove 'v' prefix if present | |
| 163 | - | version = strings.TrimPrefix(version, "v") | |
| 164 | - | ||
| 165 | - | // Handle empty or invalid versions | |
| 166 | - | if version == "" || version == "dev" { | |
| 167 | - | return "0.0.0" | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Split on first hyphen to remove dirty/commit info | |
| 171 | - | parts := strings.SplitN(version, "-", 2) | |
| 172 | - | return parts[0] | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // String returns a formatted version string | |
| 176 | - | func (i Info) String() string { | |
| 177 | - | if i.Version == "dev" { | |
| 178 | - | return "Gordon version dev (development build)" | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | // Check if it's a dirty version | |
| 182 | - | if strings.Contains(i.Version, "-dirty") { | |
| 183 | - | return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)", | |
| 184 | - | i.Version, i.Commit, i.Date) | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | return fmt.Sprintf("Gordon version %s (commit: %s, built at: %s)", | |
| 188 | - | i.Version, i.Commit, i.Date) | |
| 189 | - | } | |
A internal/config/config.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package config | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/viper" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type Config struct { | |
| 11 | + | Server ServerConfig `mapstructure:"server"` | |
| 12 | + | Auth AuthConfig `mapstructure:"auth"` | |
| 13 | + | Routes map[string]string `mapstructure:"routes"` | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | type ServerConfig struct { | |
| 17 | + | Port int `mapstructure:"port"` | |
| 18 | + | RegistryPort int `mapstructure:"registry_port"` | |
| 19 | + | Runtime string `mapstructure:"runtime"` | |
| 20 | + | SSLEmail string `mapstructure:"ssl_email"` | |
| 21 | + | DataDir string `mapstructure:"data_dir"` | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | type AuthConfig struct { | |
| 25 | + | Enabled bool `mapstructure:"enabled"` | |
| 26 | + | Method string `mapstructure:"method"` // "jwt", "api_key", or "basic" | |
| 27 | + | JWTSecret string `mapstructure:"jwt_secret"` | |
| 28 | + | APIKey string `mapstructure:"api_key"` | |
| 29 | + | Username string `mapstructure:"username"` | |
| 30 | + | Password string `mapstructure:"password"` | |
| 31 | + | RegistryAuth bool `mapstructure:"registry_auth"` | |
| 32 | + | AllowedIPs []string `mapstructure:"allowed_ips"` | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | type Route struct { | |
| 36 | + | Domain string | |
| 37 | + | Image string | |
| 38 | + | HTTPS bool | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func Load() (*Config, error) { | |
| 42 | + | var cfg Config | |
| 43 | + | ||
| 44 | + | // Set defaults | |
| 45 | + | viper.SetDefault("server.port", 8080) | |
| 46 | + | viper.SetDefault("server.registry_port", 5000) | |
| 47 | + | viper.SetDefault("server.runtime", "docker") | |
| 48 | + | viper.SetDefault("server.data_dir", "./data") | |
| 49 | + | viper.SetDefault("auth.enabled", false) | |
| 50 | + | viper.SetDefault("auth.method", "basic") | |
| 51 | + | viper.SetDefault("auth.registry_auth", false) | |
| 52 | + | viper.SetDefault("auth.allowed_ips", []string{"127.0.0.1", "::1"}) | |
| 53 | + | ||
| 54 | + | // Handle the routes manually since Viper struggles with domain names | |
| 55 | + | cfg.Routes = make(map[string]string) | |
| 56 | + | ||
| 57 | + | // Get server config first | |
| 58 | + | if err := viper.UnmarshalKey("server", &cfg.Server); err != nil { | |
| 59 | + | return nil, fmt.Errorf("unable to decode server config: %v", err) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Get auth config | |
| 63 | + | if err := viper.UnmarshalKey("auth", &cfg.Auth); err != nil { | |
| 64 | + | return nil, fmt.Errorf("unable to decode auth config: %v", err) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Get routes manually from the raw config | |
| 68 | + | routesRaw := viper.Get("routes") | |
| 69 | + | if routesRaw != nil { | |
| 70 | + | if routes, ok := routesRaw.(map[string]interface{}); ok { | |
| 71 | + | for domain, image := range routes { | |
| 72 | + | if imageStr, ok := image.(string); ok { | |
| 73 | + | cfg.Routes[domain] = imageStr | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | // Validate required fields | |
| 80 | + | if cfg.Server.SSLEmail == "" { | |
| 81 | + | return nil, fmt.Errorf("server.ssl_email is required") | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | if cfg.Server.Runtime != "docker" && cfg.Server.Runtime != "podman" { | |
| 85 | + | return nil, fmt.Errorf("server.runtime must be 'docker' or 'podman'") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // Validate auth config | |
| 89 | + | if cfg.Auth.Enabled { | |
| 90 | + | switch cfg.Auth.Method { | |
| 91 | + | case "jwt": | |
| 92 | + | if cfg.Auth.JWTSecret == "" { | |
| 93 | + | return nil, fmt.Errorf("JWT method selected but no jwt_secret provided") | |
| 94 | + | } | |
| 95 | + | case "api_key": | |
| 96 | + | if cfg.Auth.APIKey == "" { | |
| 97 | + | return nil, fmt.Errorf("API key method selected but no api_key provided") | |
| 98 | + | } | |
| 99 | + | case "basic": | |
| 100 | + | if cfg.Auth.Username == "" || cfg.Auth.Password == "" { | |
| 101 | + | return nil, fmt.Errorf("Basic auth method selected but username/password not provided") | |
| 102 | + | } | |
| 103 | + | default: | |
| 104 | + | return nil, fmt.Errorf("invalid auth method: %s (must be 'jwt', 'api_key', or 'basic')", cfg.Auth.Method) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | return &cfg, nil | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func (c *Config) GetRoutes() []Route { | |
| 112 | + | var routes []Route | |
| 113 | + | ||
| 114 | + | for domain, image := range c.Routes { | |
| 115 | + | route := Route{ | |
| 116 | + | Image: image, | |
| 117 | + | HTTPS: true, // Default to HTTPS | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Check if domain has http:// prefix | |
| 121 | + | if strings.HasPrefix(domain, "http://") { | |
| 122 | + | route.Domain = strings.TrimPrefix(domain, "http://") | |
| 123 | + | route.HTTPS = false | |
| 124 | + | } else { | |
| 125 | + | route.Domain = domain | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | routes = append(routes, route) | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | return routes | |
| 132 | + | }⏎̸ | |
A internal/container/docker.go +313 -0
| @@ -0,0 +1,313 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "strconv" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/docker/docker/api/types/container" | |
| 11 | + | "github.com/docker/docker/api/types/image" | |
| 12 | + | "github.com/docker/docker/client" | |
| 13 | + | "github.com/docker/go-connections/nat" | |
| 14 | + | "github.com/rs/zerolog/log" | |
| 15 | + | ||
| 16 | + | "gordon/pkg/runtime" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // DockerRuntime implements the Runtime interface using Docker API | |
| 20 | + | type DockerRuntime struct { | |
| 21 | + | client *client.Client | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewDockerRuntime creates a new Docker runtime instance | |
| 25 | + | func NewDockerRuntime() (*DockerRuntime, error) { | |
| 26 | + | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| 27 | + | if err != nil { | |
| 28 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | return &DockerRuntime{ | |
| 32 | + | client: cli, | |
| 33 | + | }, nil | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // CreateContainer creates a new container | |
| 37 | + | func (d *DockerRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) { | |
| 38 | + | // Convert ports to Docker format | |
| 39 | + | exposedPorts := make(nat.PortSet) | |
| 40 | + | portBindings := make(nat.PortMap) | |
| 41 | + | ||
| 42 | + | for _, port := range config.Ports { | |
| 43 | + | containerPort := nat.Port(fmt.Sprintf("%d/tcp", port)) | |
| 44 | + | exposedPorts[containerPort] = struct{}{} | |
| 45 | + | ||
| 46 | + | // Bind to random available port on host | |
| 47 | + | portBindings[containerPort] = []nat.PortBinding{ | |
| 48 | + | { | |
| 49 | + | HostIP: "0.0.0.0", | |
| 50 | + | HostPort: "0", // Docker will assign a random available port | |
| 51 | + | }, | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Create container configuration | |
| 56 | + | containerConfig := &container.Config{ | |
| 57 | + | Image: config.Image, | |
| 58 | + | Env: config.Env, | |
| 59 | + | ExposedPorts: exposedPorts, | |
| 60 | + | WorkingDir: config.WorkingDir, | |
| 61 | + | Cmd: config.Cmd, | |
| 62 | + | Labels: config.Labels, | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | hostConfig := &container.HostConfig{ | |
| 66 | + | PortBindings: portBindings, | |
| 67 | + | AutoRemove: config.AutoRemove, | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Create the container | |
| 71 | + | resp, err := d.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name) | |
| 72 | + | if err != nil { | |
| 73 | + | return nil, fmt.Errorf("failed to create container: %w", err) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Container created") | |
| 77 | + | ||
| 78 | + | // Inspect the created container to get full details | |
| 79 | + | return d.InspectContainer(ctx, resp.ID) | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // StartContainer starts a container | |
| 83 | + | func (d *DockerRuntime) StartContainer(ctx context.Context, containerID string) error { | |
| 84 | + | err := d.client.ContainerStart(ctx, containerID, container.StartOptions{}) | |
| 85 | + | if err != nil { | |
| 86 | + | return fmt.Errorf("failed to start container %s: %w", containerID, err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | log.Info().Str("id", containerID).Msg("Container started") | |
| 90 | + | return nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | // StopContainer stops a container | |
| 94 | + | func (d *DockerRuntime) StopContainer(ctx context.Context, containerID string) error { | |
| 95 | + | timeout := 30 // 30 seconds timeout | |
| 96 | + | err := d.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 97 | + | if err != nil { | |
| 98 | + | return fmt.Errorf("failed to stop container %s: %w", containerID, err) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | log.Info().Str("id", containerID).Msg("Container stopped") | |
| 102 | + | return nil | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // RestartContainer restarts a container | |
| 106 | + | func (d *DockerRuntime) RestartContainer(ctx context.Context, containerID string) error { | |
| 107 | + | timeout := 30 // 30 seconds timeout | |
| 108 | + | err := d.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 109 | + | if err != nil { | |
| 110 | + | return fmt.Errorf("failed to restart container %s: %w", containerID, err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | log.Info().Str("id", containerID).Msg("Container restarted") | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // RemoveContainer removes a container | |
| 118 | + | func (d *DockerRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error { | |
| 119 | + | err := d.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force}) | |
| 120 | + | if err != nil { | |
| 121 | + | return fmt.Errorf("failed to remove container %s: %w", containerID, err) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | log.Info().Str("id", containerID).Bool("force", force).Msg("Container removed") | |
| 125 | + | return nil | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ListContainers lists containers | |
| 129 | + | func (d *DockerRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) { | |
| 130 | + | containers, err := d.client.ContainerList(ctx, container.ListOptions{All: all}) | |
| 131 | + | if err != nil { | |
| 132 | + | return nil, fmt.Errorf("failed to list containers: %w", err) | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | var result []*runtime.Container | |
| 136 | + | for _, c := range containers { | |
| 137 | + | // Extract ports | |
| 138 | + | var ports []int | |
| 139 | + | for _, port := range c.Ports { | |
| 140 | + | if port.PublicPort > 0 { | |
| 141 | + | ports = append(ports, int(port.PublicPort)) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | // Get the primary name (remove leading slash) | |
| 146 | + | name := "" | |
| 147 | + | if len(c.Names) > 0 { | |
| 148 | + | name = strings.TrimPrefix(c.Names[0], "/") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | result = append(result, &runtime.Container{ | |
| 152 | + | ID: c.ID, | |
| 153 | + | Image: c.Image, | |
| 154 | + | Name: name, | |
| 155 | + | Status: c.Status, | |
| 156 | + | Ports: ports, | |
| 157 | + | Labels: c.Labels, | |
| 158 | + | }) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | return result, nil | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // InspectContainer inspects a container | |
| 165 | + | func (d *DockerRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) { | |
| 166 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 167 | + | if err != nil { | |
| 168 | + | return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | // Extract published ports | |
| 172 | + | var ports []int | |
| 173 | + | if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { | |
| 174 | + | for _, bindings := range resp.NetworkSettings.Ports { | |
| 175 | + | for _, binding := range bindings { | |
| 176 | + | if binding.HostPort != "" { | |
| 177 | + | if port, err := strconv.Atoi(binding.HostPort); err == nil { | |
| 178 | + | ports = append(ports, port) | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // Get container name (remove leading slash) | |
| 186 | + | name := strings.TrimPrefix(resp.Name, "/") | |
| 187 | + | ||
| 188 | + | return &runtime.Container{ | |
| 189 | + | ID: resp.ID, | |
| 190 | + | Image: resp.Config.Image, | |
| 191 | + | Name: name, | |
| 192 | + | Status: resp.State.Status, | |
| 193 | + | Ports: ports, | |
| 194 | + | Labels: resp.Config.Labels, | |
| 195 | + | }, nil | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // GetContainerLogs gets container logs | |
| 199 | + | func (d *DockerRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) { | |
| 200 | + | logs, err := d.client.ContainerLogs(ctx, containerID, container.LogsOptions{ | |
| 201 | + | ShowStdout: true, | |
| 202 | + | ShowStderr: true, | |
| 203 | + | Follow: follow, | |
| 204 | + | Timestamps: true, | |
| 205 | + | }) | |
| 206 | + | if err != nil { | |
| 207 | + | return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | return logs, nil | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // PullImage pulls an image | |
| 214 | + | func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |
| 215 | + | log.Info().Str("image", imageRef).Msg("Pulling image") | |
| 216 | + | ||
| 217 | + | reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{}) | |
| 218 | + | if err != nil { | |
| 219 | + | return fmt.Errorf("failed to pull image %s: %w", imageRef, err) | |
| 220 | + | } | |
| 221 | + | defer reader.Close() | |
| 222 | + | ||
| 223 | + | // Read the response to completion (this is required for the pull to complete) | |
| 224 | + | _, err = io.Copy(io.Discard, reader) | |
| 225 | + | if err != nil { | |
| 226 | + | return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err) | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | log.Info().Str("image", imageRef).Msg("Image pulled successfully") | |
| 230 | + | return nil | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // RemoveImage removes an image | |
| 234 | + | func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error { | |
| 235 | + | _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force}) | |
| 236 | + | if err != nil { | |
| 237 | + | return fmt.Errorf("failed to remove image %s: %w", imageRef, err) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | log.Info().Str("image", imageRef).Bool("force", force).Msg("Image removed") | |
| 241 | + | return nil | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // ListImages lists images | |
| 245 | + | func (d *DockerRuntime) ListImages(ctx context.Context) ([]string, error) { | |
| 246 | + | images, err := d.client.ImageList(ctx, image.ListOptions{}) | |
| 247 | + | if err != nil { | |
| 248 | + | return nil, fmt.Errorf("failed to list images: %w", err) | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | var result []string | |
| 252 | + | for _, img := range images { | |
| 253 | + | for _, tag := range img.RepoTags { | |
| 254 | + | if tag != "<none>:<none>" { | |
| 255 | + | result = append(result, tag) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | return result, nil | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // Ping checks if Docker is responsive | |
| 264 | + | func (d *DockerRuntime) Ping(ctx context.Context) error { | |
| 265 | + | _, err := d.client.Ping(ctx) | |
| 266 | + | if err != nil { | |
| 267 | + | return fmt.Errorf("Docker ping failed: %w", err) | |
| 268 | + | } | |
| 269 | + | return nil | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Version returns Docker version | |
| 273 | + | func (d *DockerRuntime) Version(ctx context.Context) (string, error) { | |
| 274 | + | version, err := d.client.ServerVersion(ctx) | |
| 275 | + | if err != nil { | |
| 276 | + | return "", fmt.Errorf("failed to get Docker version: %w", err) | |
| 277 | + | } | |
| 278 | + | return version.Version, nil | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | // IsContainerRunning checks if a container is running | |
| 282 | + | func (d *DockerRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) { | |
| 283 | + | container, err := d.InspectContainer(ctx, containerID) | |
| 284 | + | if err != nil { | |
| 285 | + | return false, err | |
| 286 | + | } | |
| 287 | + | return container.Status == "running", nil | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // GetContainerPort gets the host port for a container's internal port | |
| 291 | + | func (d *DockerRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) { | |
| 292 | + | resp, err := d.client.ContainerInspect(ctx, containerID) | |
| 293 | + | if err != nil { | |
| 294 | + | return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil { | |
| 298 | + | return 0, fmt.Errorf("no port mappings found for container %s", containerID) | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort)) | |
| 302 | + | bindings, exists := resp.NetworkSettings.Ports[containerPort] | |
| 303 | + | if !exists || len(bindings) == 0 { | |
| 304 | + | return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | hostPort, err := strconv.Atoi(bindings[0].HostPort) | |
| 308 | + | if err != nil { | |
| 309 | + | return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err) | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | return hostPort, nil | |
| 313 | + | }⏎̸ | |
A internal/container/manager.go +254 -0
| @@ -0,0 +1,254 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "sync" | |
| 7 | + | ||
| 8 | + | "github.com/rs/zerolog/log" | |
| 9 | + | ||
| 10 | + | "gordon/internal/config" | |
| 11 | + | "gordon/pkg/runtime" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Manager handles container lifecycle and management | |
| 15 | + | type Manager struct { | |
| 16 | + | runtime runtime.Runtime | |
| 17 | + | config *config.Config | |
| 18 | + | containers map[string]*runtime.Container // map[domain] -> container | |
| 19 | + | mu sync.RWMutex | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // NewManager creates a new container manager | |
| 23 | + | func NewManager(cfg *config.Config) (*Manager, error) { | |
| 24 | + | var rt runtime.Runtime | |
| 25 | + | var err error | |
| 26 | + | ||
| 27 | + | switch cfg.Server.Runtime { | |
| 28 | + | case "docker": | |
| 29 | + | rt, err = NewDockerRuntime() | |
| 30 | + | if err != nil { | |
| 31 | + | return nil, fmt.Errorf("failed to create Docker runtime: %w", err) | |
| 32 | + | } | |
| 33 | + | case "podman": | |
| 34 | + | // TODO: Implement Podman runtime | |
| 35 | + | return nil, fmt.Errorf("Podman runtime not yet implemented") | |
| 36 | + | default: | |
| 37 | + | return nil, fmt.Errorf("unsupported runtime: %s", cfg.Server.Runtime) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Test runtime connectivity | |
| 41 | + | ctx := context.Background() | |
| 42 | + | if err := rt.Ping(ctx); err != nil { | |
| 43 | + | return nil, fmt.Errorf("runtime not available: %w", err) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | version, err := rt.Version(ctx) | |
| 47 | + | if err != nil { | |
| 48 | + | log.Warn().Err(err).Msg("Could not get runtime version") | |
| 49 | + | } else { | |
| 50 | + | log.Info().Str("runtime", cfg.Server.Runtime).Str("version", version).Msg("Container runtime connected") | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | return &Manager{ | |
| 54 | + | runtime: rt, | |
| 55 | + | config: cfg, | |
| 56 | + | containers: make(map[string]*runtime.Container), | |
| 57 | + | }, nil | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // DeployContainer deploys a container for a specific route | |
| 61 | + | func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*runtime.Container, error) { | |
| 62 | + | m.mu.Lock() | |
| 63 | + | defer m.mu.Unlock() | |
| 64 | + | ||
| 65 | + | // Check if container already exists for this domain | |
| 66 | + | if existing, exists := m.containers[route.Domain]; exists { | |
| 67 | + | log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting") | |
| 68 | + | ||
| 69 | + | // Stop and remove existing container | |
| 70 | + | if err := m.runtime.StopContainer(ctx, existing.ID); err != nil { | |
| 71 | + | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container") | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 75 | + | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | delete(m.containers, route.Domain) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | // Pull the image first | |
| 82 | + | log.Info().Str("image", route.Image).Msg("Pulling image") | |
| 83 | + | if err := m.runtime.PullImage(ctx, route.Image); err != nil { | |
| 84 | + | return nil, fmt.Errorf("failed to pull image %s: %w", route.Image, err) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // Create container configuration | |
| 88 | + | containerConfig := &runtime.ContainerConfig{ | |
| 89 | + | Image: route.Image, | |
| 90 | + | Name: fmt.Sprintf("gordon-%s", route.Domain), | |
| 91 | + | Ports: []int{80, 8080, 3000}, // Common web server ports | |
| 92 | + | Labels: map[string]string{ | |
| 93 | + | "gordon.domain": route.Domain, | |
| 94 | + | "gordon.image": route.Image, | |
| 95 | + | "gordon.managed": "true", | |
| 96 | + | }, | |
| 97 | + | AutoRemove: false, // Keep containers for inspection | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Create the container | |
| 101 | + | container, err := m.runtime.CreateContainer(ctx, containerConfig) | |
| 102 | + | if err != nil { | |
| 103 | + | return nil, fmt.Errorf("failed to create container for %s: %w", route.Domain, err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | // Start the container | |
| 107 | + | if err := m.runtime.StartContainer(ctx, container.ID); err != nil { | |
| 108 | + | // Clean up the created container | |
| 109 | + | m.runtime.RemoveContainer(ctx, container.ID, true) | |
| 110 | + | return nil, fmt.Errorf("failed to start container for %s: %w", route.Domain, err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | // Re-inspect to get updated port information | |
| 114 | + | container, err = m.runtime.InspectContainer(ctx, container.ID) | |
| 115 | + | if err != nil { | |
| 116 | + | return nil, fmt.Errorf("failed to inspect started container: %w", err) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Store the container mapping | |
| 120 | + | m.containers[route.Domain] = container | |
| 121 | + | ||
| 122 | + | log.Info(). | |
| 123 | + | Str("domain", route.Domain). | |
| 124 | + | Str("image", route.Image). | |
| 125 | + | Str("container", container.ID). | |
| 126 | + | Ints("ports", container.Ports). | |
| 127 | + | Msg("Container deployed successfully") | |
| 128 | + | ||
| 129 | + | return container, nil | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | // GetContainer returns the container for a domain | |
| 133 | + | func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) { | |
| 134 | + | m.mu.RLock() | |
| 135 | + | defer m.mu.RUnlock() | |
| 136 | + | ||
| 137 | + | container, exists := m.containers[domain] | |
| 138 | + | return container, exists | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // GetContainerPort returns the host port for a domain's container | |
| 142 | + | func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalPort int) (int, error) { | |
| 143 | + | m.mu.RLock() | |
| 144 | + | container, exists := m.containers[domain] | |
| 145 | + | m.mu.RUnlock() | |
| 146 | + | ||
| 147 | + | if !exists { | |
| 148 | + | return 0, fmt.Errorf("no container found for domain %s", domain) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | return m.runtime.GetContainerPort(ctx, container.ID, internalPort) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | // ListContainers returns all managed containers | |
| 155 | + | func (m *Manager) ListContainers() map[string]*runtime.Container { | |
| 156 | + | m.mu.RLock() | |
| 157 | + | defer m.mu.RUnlock() | |
| 158 | + | ||
| 159 | + | // Return a copy of the map to avoid race conditions | |
| 160 | + | result := make(map[string]*runtime.Container) | |
| 161 | + | for domain, container := range m.containers { | |
| 162 | + | result[domain] = container | |
| 163 | + | } | |
| 164 | + | return result | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // StopContainer stops a container for a domain | |
| 168 | + | func (m *Manager) StopContainer(ctx context.Context, domain string) error { | |
| 169 | + | m.mu.Lock() | |
| 170 | + | defer m.mu.Unlock() | |
| 171 | + | ||
| 172 | + | container, exists := m.containers[domain] | |
| 173 | + | if !exists { | |
| 174 | + | return fmt.Errorf("no container found for domain %s", domain) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | if err := m.runtime.StopContainer(ctx, container.ID); err != nil { | |
| 178 | + | return fmt.Errorf("failed to stop container for %s: %w", domain, err) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Container stopped") | |
| 182 | + | return nil | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // RemoveContainer removes a container for a domain | |
| 186 | + | func (m *Manager) RemoveContainer(ctx context.Context, domain string, force bool) error { | |
| 187 | + | m.mu.Lock() | |
| 188 | + | defer m.mu.Unlock() | |
| 189 | + | ||
| 190 | + | container, exists := m.containers[domain] | |
| 191 | + | if !exists { | |
| 192 | + | return fmt.Errorf("no container found for domain %s", domain) | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | if err := m.runtime.RemoveContainer(ctx, container.ID, force); err != nil { | |
| 196 | + | return fmt.Errorf("failed to remove container for %s: %w", domain, err) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | delete(m.containers, domain) | |
| 200 | + | log.Info().Str("domain", domain).Str("container", container.ID).Msg("Container removed") | |
| 201 | + | return nil | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // SyncContainers synchronizes the internal state with the actual running containers | |
| 205 | + | func (m *Manager) SyncContainers(ctx context.Context) error { | |
| 206 | + | m.mu.Lock() | |
| 207 | + | defer m.mu.Unlock() | |
| 208 | + | ||
| 209 | + | // Get all containers from the runtime | |
| 210 | + | allContainers, err := m.runtime.ListContainers(ctx, false) // only running containers | |
| 211 | + | if err != nil { | |
| 212 | + | return fmt.Errorf("failed to list containers: %w", err) | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // Find containers managed by Gordon | |
| 216 | + | managedContainers := make(map[string]*runtime.Container) | |
| 217 | + | for _, container := range allContainers { | |
| 218 | + | if container.Labels != nil { | |
| 219 | + | if domain, exists := container.Labels["gordon.domain"]; exists && container.Labels["gordon.managed"] == "true" { | |
| 220 | + | managedContainers[domain] = container | |
| 221 | + | } | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // Update our internal state | |
| 226 | + | m.containers = managedContainers | |
| 227 | + | ||
| 228 | + | log.Info().Int("count", len(managedContainers)).Msg("Container state synchronized") | |
| 229 | + | return nil | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | // HealthCheck checks the health of all managed containers | |
| 233 | + | func (m *Manager) HealthCheck(ctx context.Context) map[string]bool { | |
| 234 | + | m.mu.RLock() | |
| 235 | + | defer m.mu.RUnlock() | |
| 236 | + | ||
| 237 | + | health := make(map[string]bool) | |
| 238 | + | for domain, container := range m.containers { | |
| 239 | + | running, err := m.runtime.IsContainerRunning(ctx, container.ID) | |
| 240 | + | if err != nil { | |
| 241 | + | log.Warn().Err(err).Str("domain", domain).Str("container", container.ID).Msg("Failed to check container health") | |
| 242 | + | health[domain] = false | |
| 243 | + | } else { | |
| 244 | + | health[domain] = running | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | return health | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | // Runtime returns the underlying runtime interface | |
| 252 | + | func (m *Manager) Runtime() runtime.Runtime { | |
| 253 | + | return m.runtime | |
| 254 | + | }⏎̸ | |
D internal/db/db_struct.go +0 -105
| @@ -1,105 +0,0 @@ | |||
| 1 | - | package db | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | ) | |
| 6 | - | ||
| 7 | - | // User struct model | |
| 8 | - | type User struct { | |
| 9 | - | ID string `sql:"id, primary_key"` | |
| 10 | - | Name string `sql:"name"` | |
| 11 | - | Email string `sql:"email"` | |
| 12 | - | Account Account | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | type Account struct { | |
| 16 | - | ID string `sql:"id, primary_key"` | |
| 17 | - | UserID string `sql:"user_id, foreign_key=user.id"` | |
| 18 | - | Sessions []Sessions | |
| 19 | - | Providers []Provider | |
| 20 | - | Clients []Clients | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | type Sessions struct { | |
| 24 | - | ID string `sql:"id, primary_key"` | |
| 25 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 26 | - | BrowserInfo string `sql:"browser_info"` | |
| 27 | - | AccessToken string `sql:"access_token"` | |
| 28 | - | Expires string `sql:"expires"` | |
| 29 | - | IsActive bool `sql:"is_active"` | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | type Provider struct { | |
| 33 | - | ID string `sql:"id, primary_key"` | |
| 34 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 35 | - | Name string `sql:"name"` | |
| 36 | - | Login string `sql:"login"` | |
| 37 | - | AvatarURL string `sql:"avatar_url"` | |
| 38 | - | ProfileURL string `sql:"profile_url"` | |
| 39 | - | Email string `sql:"email"` | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | type Clients struct { | |
| 43 | - | ID string `sql:"id, primary_key"` | |
| 44 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 45 | - | OS string `sql:"os"` | |
| 46 | - | IP string `sql:"ip"` | |
| 47 | - | Hostname string `sql:"hostname"` | |
| 48 | - | Expires string `sql:"expires"` | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // Domain represents a domain configuration for the proxy | |
| 52 | - | type Domain struct { | |
| 53 | - | ID string `sql:"id, primary_key"` | |
| 54 | - | Name string `sql:"name"` // e.g. example.com | |
| 55 | - | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 56 | - | CreatedAt string `sql:"created_at"` | |
| 57 | - | UpdatedAt string `sql:"updated_at"` | |
| 58 | - | AcmeEnabled bool `sql:"acme_enabled"` // Whether ACME is enabled for this domain | |
| 59 | - | AcmeChallengeType sql.NullString `sql:"acme_challenge_type"` // e.g. "http-01", "dns-01" | |
| 60 | - | AcmeDnsProvider sql.NullString `sql:"acme_dns_provider"` // DNS provider name if using DNS-01 | |
| 61 | - | AcmeDnsCredentialsRef sql.NullString `sql:"acme_dns_credentials_ref"` // Reference to credentials (not raw credentials) | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Certificate represents a TLS certificate for a domain | |
| 65 | - | type Certificate struct { | |
| 66 | - | ID string `sql:"id, primary_key"` | |
| 67 | - | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |
| 68 | - | CertFile string `sql:"cert_file"` // Path to the certificate file | |
| 69 | - | KeyFile string `sql:"key_file"` // Path to the private key file | |
| 70 | - | IssuedAt string `sql:"issued_at"` | |
| 71 | - | ExpiresAt string `sql:"expires_at"` | |
| 72 | - | Issuer string `sql:"issuer"` // e.g. "Let's Encrypt" | |
| 73 | - | Status string `sql:"status"` // e.g. valid, expired, revoked | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // ProxyRoute represents a mapping from a domain to a container | |
| 77 | - | type ProxyRoute struct { | |
| 78 | - | ID string `sql:"id, primary_key"` | |
| 79 | - | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |
| 80 | - | ContainerID string `sql:"container_id"` | |
| 81 | - | ContainerIP string `sql:"container_ip"` | |
| 82 | - | ContainerPort string `sql:"container_port"` | |
| 83 | - | Protocol string `sql:"protocol"` // http or https | |
| 84 | - | Path string `sql:"path"` // path prefix to route (e.g., /api) | |
| 85 | - | Active bool `sql:"active"` | |
| 86 | - | CreatedAt string `sql:"created_at"` | |
| 87 | - | UpdatedAt string `sql:"updated_at"` | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // AcmeAccount represents the stored ACME account details | |
| 91 | - | type AcmeAccount struct { | |
| 92 | - | Email string `sql:"email, primary_key"` | |
| 93 | - | PrivateKey string `sql:"private_key"` // PEM encoded private key | |
| 94 | - | RegistrationInfo sql.NullString `sql:"registration_info"` // JSON marshaled registration resource | |
| 95 | - | CreatedAt string `sql:"created_at"` | |
| 96 | - | UpdatedAt string `sql:"updated_at"` | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // GithubUserInfo holds the essential information for a Github user | |
| 100 | - | type GithubUserInfo struct { | |
| 101 | - | Login string `json:"login"` | |
| 102 | - | AvatarURL string `json:"avatar_url"` | |
| 103 | - | ProfileURL string `json:"html_url"` | |
| 104 | - | Emails []string | |
| 105 | - | } | |
D internal/db/helpers.go +0 -136
| @@ -1,136 +0,0 @@ | |||
| 1 | - | package db | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/pkg/logger" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // ExecWithRetry performs a database execution with retry logic for handling locked database | |
| 12 | - | func ExecWithRetry(db *sql.DB, query string, args ...interface{}) (sql.Result, error) { | |
| 13 | - | var result sql.Result | |
| 14 | - | var err error | |
| 15 | - | maxRetries := 10 | |
| 16 | - | retryDelay := 200 * time.Millisecond | |
| 17 | - | ||
| 18 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 19 | - | result, err = db.Exec(query, args...) | |
| 20 | - | if err == nil { | |
| 21 | - | return result, nil | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // Check if this is a database locked error | |
| 25 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 26 | - | logger.Debug("Database locked, retrying operation", | |
| 27 | - | "attempt", attempt, | |
| 28 | - | "max_retries", maxRetries, | |
| 29 | - | "query", query) | |
| 30 | - | time.Sleep(retryDelay) | |
| 31 | - | // Increase delay for next retry (exponential backoff) | |
| 32 | - | retryDelay *= 2 | |
| 33 | - | continue | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // If it's not a locking error, return immediately | |
| 37 | - | return nil, err | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // QueryWithRetry performs a database query with retry logic for handling locked database | |
| 44 | - | func QueryWithRetry(db *sql.DB, query string, args ...interface{}) (*sql.Rows, error) { | |
| 45 | - | var rows *sql.Rows | |
| 46 | - | var err error | |
| 47 | - | maxRetries := 10 | |
| 48 | - | retryDelay := 200 * time.Millisecond | |
| 49 | - | ||
| 50 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 51 | - | rows, err = db.Query(query, args...) | |
| 52 | - | if err == nil { | |
| 53 | - | return rows, nil | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Check if this is a database locked error | |
| 57 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 58 | - | logger.Debug("Database locked, retrying operation", | |
| 59 | - | "attempt", attempt, | |
| 60 | - | "max_retries", maxRetries, | |
| 61 | - | "query", query) | |
| 62 | - | time.Sleep(retryDelay) | |
| 63 | - | // Increase delay for next retry (exponential backoff) | |
| 64 | - | retryDelay *= 2 | |
| 65 | - | continue | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // If it's not a locking error, return immediately | |
| 69 | - | return nil, err | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // BeginWithRetry starts a transaction with retry logic for handling locked database | |
| 76 | - | func BeginWithRetry(db *sql.DB) (*sql.Tx, error) { | |
| 77 | - | var tx *sql.Tx | |
| 78 | - | var err error | |
| 79 | - | maxRetries := 10 | |
| 80 | - | retryDelay := 200 * time.Millisecond | |
| 81 | - | ||
| 82 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 83 | - | tx, err = db.Begin() | |
| 84 | - | if err == nil { | |
| 85 | - | return tx, nil | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // Check if this is a database locked error | |
| 89 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 90 | - | logger.Debug("Database locked, retrying transaction start", | |
| 91 | - | "attempt", attempt, | |
| 92 | - | "max_retries", maxRetries) | |
| 93 | - | time.Sleep(retryDelay) | |
| 94 | - | // Increase delay for next retry (exponential backoff) | |
| 95 | - | retryDelay *= 2 | |
| 96 | - | continue | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // If it's not a locking error, return immediately | |
| 100 | - | return nil, err | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // TxExecWithRetry executes a query within a transaction with retry logic | |
| 107 | - | func TxExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | |
| 108 | - | var result sql.Result | |
| 109 | - | var err error | |
| 110 | - | maxRetries := 10 | |
| 111 | - | retryDelay := 200 * time.Millisecond | |
| 112 | - | ||
| 113 | - | for attempt := 1; attempt <= maxRetries; attempt++ { | |
| 114 | - | result, err = tx.Exec(query, args...) | |
| 115 | - | if err == nil { | |
| 116 | - | return result, nil | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | // Check if this is a database locked error | |
| 120 | - | if err.Error() == "database is locked (5) (SQLITE_BUSY)" { | |
| 121 | - | logger.Debug("Database locked, retrying transaction operation", | |
| 122 | - | "attempt", attempt, | |
| 123 | - | "max_retries", maxRetries, | |
| 124 | - | "query", query) | |
| 125 | - | time.Sleep(retryDelay) | |
| 126 | - | // Increase delay for next retry (exponential backoff) | |
| 127 | - | retryDelay *= 2 | |
| 128 | - | continue | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // If it's not a locking error, return immediately | |
| 132 | - | return nil, err | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | return nil, fmt.Errorf("max retries exceeded: %w", err) | |
| 136 | - | } | |
D internal/db/queries/account.go +0 -73
| @@ -1,73 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/db" | |
| 7 | - | "github.com/google/uuid" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // AccountQueries contains all SQL queries for account operations | |
| 11 | - | type AccountQueries struct { | |
| 12 | - | InsertAccount string | |
| 13 | - | CheckAccountExists string | |
| 14 | - | GetAccountByID string | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | // NewAccountQueries returns a new instance of AccountQueries | |
| 18 | - | func NewAccountQueries() *AccountQueries { | |
| 19 | - | return &AccountQueries{ | |
| 20 | - | InsertAccount: "INSERT INTO account (id, user_id) VALUES (?, ?)", | |
| 21 | - | CheckAccountExists: "SELECT EXISTS(SELECT 1 FROM account WHERE id = ?)", | |
| 22 | - | GetAccountByID: "SELECT id, user_id FROM account WHERE id = ?", | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // CreateDBAccount creates a new account in the database | |
| 27 | - | func CreateDBAccount(database *sql.DB, userID string) (*db.Account, error) { | |
| 28 | - | account := &db.Account{ | |
| 29 | - | ID: uuid.NewString(), | |
| 30 | - | UserID: userID, | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | _, err := database.Exec( | |
| 34 | - | NewAccountQueries().InsertAccount, | |
| 35 | - | account.ID, userID, | |
| 36 | - | ) | |
| 37 | - | ||
| 38 | - | return account, err | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // CheckDBAccountExists checks if an account exists in the database | |
| 42 | - | func CheckDBAccountExists(database *sql.DB, accountID string) (bool, error) { | |
| 43 | - | var exists bool | |
| 44 | - | ||
| 45 | - | err := database.QueryRow( | |
| 46 | - | NewAccountQueries().CheckAccountExists, | |
| 47 | - | accountID, | |
| 48 | - | ).Scan(&exists) | |
| 49 | - | ||
| 50 | - | if err != nil { | |
| 51 | - | return false, err | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | return exists, nil | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // GetDBAccountByID gets an account from the database by ID | |
| 58 | - | func GetDBAccountByID(database *sql.DB, accountID string) (*db.Account, error) { | |
| 59 | - | account := &db.Account{ | |
| 60 | - | ID: accountID, | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | err := database.QueryRow( | |
| 64 | - | NewAccountQueries().GetAccountByID, | |
| 65 | - | accountID, | |
| 66 | - | ).Scan(&account.ID, &account.UserID) | |
| 67 | - | ||
| 68 | - | if err != nil { | |
| 69 | - | return nil, err | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | return account, nil | |
| 73 | - | } | |
D internal/db/queries/clients.go +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | package queries | |
D internal/db/queries/inspectdb.go +0 -74
| @@ -1,74 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // InspectInMemoryDB inspects the in-memory database. (for debug purpose) | |
| 9 | - | func InspectInMemoryDB(database *sql.DB) error { | |
| 10 | - | // Query the sqlite_master table to get a list of all tables | |
| 11 | - | rows, err := database.Query("SELECT name FROM sqlite_master WHERE type='table'") | |
| 12 | - | fmt.Println("rows", rows) | |
| 13 | - | if err != nil { | |
| 14 | - | return err | |
| 15 | - | } | |
| 16 | - | defer rows.Close() | |
| 17 | - | ||
| 18 | - | var tables []string | |
| 19 | - | for rows.Next() { | |
| 20 | - | var tableName string | |
| 21 | - | if err := rows.Scan(&tableName); err != nil { | |
| 22 | - | return err | |
| 23 | - | } | |
| 24 | - | tables = append(tables, tableName) | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | if err := rows.Err(); err != nil { | |
| 28 | - | return err | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // Print the tables | |
| 32 | - | fmt.Println("Tables in the in-memory database:") | |
| 33 | - | for _, table := range tables { | |
| 34 | - | fmt.Println(table) | |
| 35 | - | } | |
| 36 | - | // Print the content of the users table | |
| 37 | - | userRows, err := database.Query("SELECT * FROM user") | |
| 38 | - | if err != nil { | |
| 39 | - | return err | |
| 40 | - | } | |
| 41 | - | defer userRows.Close() | |
| 42 | - | ||
| 43 | - | fmt.Println("\nContent of the 'user' table:") | |
| 44 | - | for userRows.Next() { | |
| 45 | - | // Assuming users table has columns: id, name | |
| 46 | - | var id int | |
| 47 | - | var name string | |
| 48 | - | if err := userRows.Scan(&id, &name); err != nil { | |
| 49 | - | return err | |
| 50 | - | } | |
| 51 | - | fmt.Printf("ID: %d, Name: %s\n", id, name) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Print the content of accounts table | |
| 55 | - | accountRows, err := database.Query("SELECT * FROM account") | |
| 56 | - | if err != nil { | |
| 57 | - | return err | |
| 58 | - | } | |
| 59 | - | defer accountRows.Close() | |
| 60 | - | ||
| 61 | - | fmt.Println("\nContent of the 'account' table:") | |
| 62 | - | for accountRows.Next() { | |
| 63 | - | // Assuming accounts table has columns: id, name, user_id | |
| 64 | - | var id int | |
| 65 | - | var name string | |
| 66 | - | var userID int | |
| 67 | - | if err := accountRows.Scan(&id, &name, &userID); err != nil { | |
| 68 | - | return err | |
| 69 | - | } | |
| 70 | - | fmt.Printf("ID: %d, Name: %s, UserID: %d\n", id, name, userID) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | return nil | |
| 74 | - | } | |
D internal/db/queries/provider.go +0 -185
| @@ -1,185 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/db" | |
| 7 | - | "github.com/google/uuid" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // ProviderQueries contains all SQL queries for provider operations | |
| 11 | - | type ProviderQueries struct { | |
| 12 | - | InsertProvider string | |
| 13 | - | PopulateProviderFromDB string | |
| 14 | - | CheckProviderExists string | |
| 15 | - | GetProviderByAccountLogin string | |
| 16 | - | CountProvidersQuery string | |
| 17 | - | InsertProviderForAccountQuery string | |
| 18 | - | GetUserByProviderLoginQuery string | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // NewProviderQueries returns a new instance of ProviderQueries | |
| 22 | - | func NewProviderQueries() *ProviderQueries { | |
| 23 | - | return &ProviderQueries{ | |
| 24 | - | InsertProvider: "INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 25 | - | PopulateProviderFromDB: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE account_id = ?", | |
| 26 | - | CheckProviderExists: "SELECT EXISTS(SELECT 1 FROM provider WHERE login = ? AND email = ?)", | |
| 27 | - | GetProviderByAccountLogin: "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider WHERE login = ? AND email = ?", | |
| 28 | - | CountProvidersQuery: "SELECT COUNT(*) FROM provider", | |
| 29 | - | InsertProviderForAccountQuery: ` | |
| 30 | - | INSERT INTO provider (id, account_id, name, login, avatar_url, profile_url, email) | |
| 31 | - | VALUES (?, ?, ?, ?, ?, ?, ?)`, | |
| 32 | - | GetUserByProviderLoginQuery: ` | |
| 33 | - | SELECT u.id, u.name, u.email, p.account_id | |
| 34 | - | FROM provider p | |
| 35 | - | JOIN account a ON p.account_id = a.id | |
| 36 | - | JOIN user u ON a.user_id = u.id | |
| 37 | - | WHERE p.name = ? AND p.login = ?`, | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // CountProviders returns the total number of provider records in the database. | |
| 42 | - | func CountProviders(database *sql.DB) (int, error) { | |
| 43 | - | var count int | |
| 44 | - | err := database.QueryRow(NewProviderQueries().CountProvidersQuery).Scan(&count) | |
| 45 | - | if err != nil { | |
| 46 | - | return 0, err | |
| 47 | - | } | |
| 48 | - | return count, nil | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // Inserts a new provider record linked to an existing account. | |
| 52 | - | func InsertProviderForAccount(database *sql.DB, accountID string, providerName string, userInfo *db.GithubUserInfo) error { | |
| 53 | - | providerID := uuid.NewString() | |
| 54 | - | primaryEmail := "" | |
| 55 | - | if len(userInfo.Emails) > 0 { | |
| 56 | - | primaryEmail = userInfo.Emails[0] | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | _, err := database.Exec( | |
| 60 | - | NewProviderQueries().InsertProviderForAccountQuery, | |
| 61 | - | providerID, | |
| 62 | - | accountID, | |
| 63 | - | providerName, | |
| 64 | - | userInfo.Login, | |
| 65 | - | userInfo.AvatarURL, | |
| 66 | - | userInfo.ProfileURL, | |
| 67 | - | primaryEmail, | |
| 68 | - | ) | |
| 69 | - | return err | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // Retrieves the User and Account ID associated with a specific provider login. | |
| 73 | - | func GetUserByProviderLogin(database *sql.DB, providerName string, providerLogin string) (*db.User, string, error) { | |
| 74 | - | user := &db.User{} | |
| 75 | - | var accountID string | |
| 76 | - | ||
| 77 | - | err := database.QueryRow( | |
| 78 | - | NewProviderQueries().GetUserByProviderLoginQuery, | |
| 79 | - | providerName, | |
| 80 | - | providerLogin, | |
| 81 | - | ).Scan( | |
| 82 | - | &user.ID, | |
| 83 | - | &user.Name, | |
| 84 | - | &user.Email, | |
| 85 | - | &accountID, | |
| 86 | - | ) | |
| 87 | - | ||
| 88 | - | if err != nil { | |
| 89 | - | return nil, "", err | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | return user, accountID, nil | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // CreateDBGitHubProvider creates a new GitHub provider in the database. | |
| 96 | - | func CreateDBGitHubProvider(database *sql.DB, accountID string, userInfo *db.GithubUserInfo) error { | |
| 97 | - | providerID := uuid.NewString() | |
| 98 | - | ||
| 99 | - | primaryEmail := "" | |
| 100 | - | if len(userInfo.Emails) > 0 { | |
| 101 | - | primaryEmail = userInfo.Emails[0] | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | _, err := database.Exec( | |
| 105 | - | NewProviderQueries().InsertProvider, | |
| 106 | - | providerID, | |
| 107 | - | accountID, | |
| 108 | - | "github", | |
| 109 | - | userInfo.Login, | |
| 110 | - | userInfo.AvatarURL, | |
| 111 | - | userInfo.ProfileURL, | |
| 112 | - | primaryEmail, | |
| 113 | - | ) | |
| 114 | - | ||
| 115 | - | return err | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // PopulateDBProviderFromDB populates provider information from the database. | |
| 119 | - | func PopulateDBProviderFromDB(database *sql.DB, accountID string) (*db.Provider, error) { | |
| 120 | - | provider := &db.Provider{} | |
| 121 | - | ||
| 122 | - | err := database.QueryRow( | |
| 123 | - | NewProviderQueries().PopulateProviderFromDB, | |
| 124 | - | accountID, | |
| 125 | - | ).Scan( | |
| 126 | - | &provider.ID, | |
| 127 | - | &provider.AccountID, | |
| 128 | - | &provider.Name, | |
| 129 | - | &provider.Login, | |
| 130 | - | &provider.AvatarURL, | |
| 131 | - | &provider.ProfileURL, | |
| 132 | - | &provider.Email, | |
| 133 | - | ) | |
| 134 | - | ||
| 135 | - | if err != nil { | |
| 136 | - | return nil, err | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | return provider, nil | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | // CheckDBProviderExists checks if a provider exists in the database. | |
| 143 | - | func CheckDBProviderExists(database *sql.DB, login string, email string) (bool, error) { | |
| 144 | - | var exists bool | |
| 145 | - | ||
| 146 | - | err := database.QueryRow( | |
| 147 | - | NewProviderQueries().CheckProviderExists, | |
| 148 | - | login, | |
| 149 | - | email, | |
| 150 | - | ).Scan(&exists) | |
| 151 | - | ||
| 152 | - | if err != nil { | |
| 153 | - | if err == sql.ErrNoRows { | |
| 154 | - | return false, nil | |
| 155 | - | } | |
| 156 | - | return false, err | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | return exists, nil | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | // GetProviderByLoginAndEmail returns a provider from the database by login and email. | |
| 163 | - | func GetProviderByLoginAndEmail(database *sql.DB, login string, email string) (*db.Provider, error) { | |
| 164 | - | provider := &db.Provider{} | |
| 165 | - | ||
| 166 | - | err := database.QueryRow( | |
| 167 | - | NewProviderQueries().GetProviderByAccountLogin, | |
| 168 | - | login, | |
| 169 | - | email, | |
| 170 | - | ).Scan( | |
| 171 | - | &provider.ID, | |
| 172 | - | &provider.AccountID, | |
| 173 | - | &provider.Name, | |
| 174 | - | &provider.Login, | |
| 175 | - | &provider.AvatarURL, | |
| 176 | - | &provider.ProfileURL, | |
| 177 | - | &provider.Email, | |
| 178 | - | ) | |
| 179 | - | ||
| 180 | - | if err != nil { | |
| 181 | - | return nil, err | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | return provider, nil | |
| 185 | - | } | |
D internal/db/queries/proxy.go +0 -173
| @@ -1,173 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | // ProxyQueries contains all SQL queries used by the proxy package | |
| 4 | - | type ProxyQueries struct { | |
| 5 | - | // Route queries | |
| 6 | - | GetActiveRoutes string | |
| 7 | - | GetRouteByDomain string | |
| 8 | - | GetRouteIDAndContainerIDByDomainName string | |
| 9 | - | GetRouteIPByID string | |
| 10 | - | InsertDomain string | |
| 11 | - | GetDomainByName string | |
| 12 | - | GetFirstAccount string | |
| 13 | - | UpdateRoute string | |
| 14 | - | UpdateRecreatedRoute string | |
| 15 | - | InsertRoute string | |
| 16 | - | DeleteRouteByDomainID string | |
| 17 | - | DeleteDomainByID string | |
| 18 | - | UpdateRouteIP string | |
| 19 | - | GetAllRoutes string | |
| 20 | - | MarkRouteInactive string | |
| 21 | - | ||
| 22 | - | // ACME queries | |
| 23 | - | GetDomainAcmeConfig string | |
| 24 | - | UpdateDomainAcmeConfig string | |
| 25 | - | GetCertificateByDomain string | |
| 26 | - | UpdateCertificate string | |
| 27 | - | ||
| 28 | - | // Admin domain queries | |
| 29 | - | CheckDomainExists string | |
| 30 | - | CreateAdminDomain string | |
| 31 | - | UpdateAdminDomainAcme string | |
| 32 | - | ||
| 33 | - | // ACME Account queries (NEW) | |
| 34 | - | GetAcmeAccountByEmail string | |
| 35 | - | InsertOrReplaceAcmeAccount string | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // NewProxyQueries returns a new instance of ProxyQueries | |
| 39 | - | func NewProxyQueries() *ProxyQueries { | |
| 40 | - | return &ProxyQueries{ | |
| 41 | - | // Route queries | |
| 42 | - | GetActiveRoutes: ` | |
| 43 | - | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 44 | - | FROM proxy_route pr | |
| 45 | - | JOIN domain d ON pr.domain_id = d.id | |
| 46 | - | WHERE pr.active = 1 | |
| 47 | - | `, | |
| 48 | - | GetRouteByDomain: ` | |
| 49 | - | SELECT id, container_id, container_port | |
| 50 | - | FROM proxy_route | |
| 51 | - | WHERE domain_id = ? | |
| 52 | - | `, | |
| 53 | - | GetRouteIDAndContainerIDByDomainName: ` | |
| 54 | - | SELECT pr.id, pr.container_id | |
| 55 | - | FROM proxy_route pr | |
| 56 | - | JOIN domain d ON pr.domain_id = d.id | |
| 57 | - | WHERE d.name = ? | |
| 58 | - | `, | |
| 59 | - | GetRouteIPByID: ` | |
| 60 | - | SELECT container_ip | |
| 61 | - | FROM proxy_route | |
| 62 | - | WHERE id = ? | |
| 63 | - | `, | |
| 64 | - | InsertDomain: ` | |
| 65 | - | INSERT INTO domain (id, name, account_id, created_at, updated_at) | |
| 66 | - | VALUES (?, ?, ?, ?, ?) | |
| 67 | - | `, | |
| 68 | - | GetDomainByName: ` | |
| 69 | - | SELECT id FROM domain WHERE name = ? | |
| 70 | - | `, | |
| 71 | - | GetFirstAccount: ` | |
| 72 | - | SELECT id FROM account LIMIT 1 | |
| 73 | - | `, | |
| 74 | - | UpdateRoute: ` | |
| 75 | - | UPDATE proxy_route SET | |
| 76 | - | container_id = ?, | |
| 77 | - | container_ip = ?, | |
| 78 | - | container_port = ?, | |
| 79 | - | protocol = ?, | |
| 80 | - | path = ?, | |
| 81 | - | active = ?, | |
| 82 | - | updated_at = ? | |
| 83 | - | WHERE id = ? | |
| 84 | - | `, | |
| 85 | - | UpdateRecreatedRoute: ` | |
| 86 | - | UPDATE proxy_route | |
| 87 | - | SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ? | |
| 88 | - | WHERE id = ? | |
| 89 | - | `, | |
| 90 | - | InsertRoute: ` | |
| 91 | - | INSERT INTO proxy_route ( | |
| 92 | - | id, domain_id, container_id, container_ip, container_port, | |
| 93 | - | protocol, path, active, created_at, updated_at | |
| 94 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 95 | - | `, | |
| 96 | - | DeleteRouteByDomainID: ` | |
| 97 | - | DELETE FROM proxy_route WHERE domain_id = ? | |
| 98 | - | `, | |
| 99 | - | DeleteDomainByID: ` | |
| 100 | - | DELETE FROM domain WHERE id = ? | |
| 101 | - | `, | |
| 102 | - | UpdateRouteIP: ` | |
| 103 | - | UPDATE proxy_route SET container_ip = ?, active = 1, updated_at = ? | |
| 104 | - | WHERE domain_id = ? AND container_id = ? | |
| 105 | - | `, | |
| 106 | - | GetAllRoutes: ` | |
| 107 | - | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 108 | - | FROM proxy_route pr | |
| 109 | - | JOIN domain d ON pr.domain_id = d.id | |
| 110 | - | `, | |
| 111 | - | MarkRouteInactive: ` | |
| 112 | - | UPDATE proxy_route SET active = 0, updated_at = ? WHERE domain_id = ? | |
| 113 | - | `, | |
| 114 | - | ||
| 115 | - | // ACME queries | |
| 116 | - | GetDomainAcmeConfig: ` | |
| 117 | - | SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref | |
| 118 | - | FROM domain WHERE name = ? | |
| 119 | - | `, | |
| 120 | - | UpdateDomainAcmeConfig: ` | |
| 121 | - | UPDATE domain SET | |
| 122 | - | acme_enabled = ?, | |
| 123 | - | acme_challenge_type = ?, | |
| 124 | - | acme_dns_provider = ?, | |
| 125 | - | acme_dns_credentials_ref = ?, | |
| 126 | - | updated_at = ? | |
| 127 | - | WHERE name = ? | |
| 128 | - | `, | |
| 129 | - | GetCertificateByDomain: ` | |
| 130 | - | -- Reverted: Select all original columns as expected by checkExistingCertificate | |
| 131 | - | SELECT c.cert_file, c.key_file, c.issued_at, c.expires_at, c.issuer, c.status | |
| 132 | - | FROM certificate c | |
| 133 | - | JOIN domain d ON c.domain_id = d.id | |
| 134 | - | WHERE d.name = ? | |
| 135 | - | -- Note: This query might return multiple rows or non-valid certs. | |
| 136 | - | -- The calling code (checkExistingCertificate and GetCertificate) needs to handle this. | |
| 137 | - | `, | |
| 138 | - | UpdateCertificate: ` | |
| 139 | - | INSERT OR REPLACE INTO certificate ( | |
| 140 | - | id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status | |
| 141 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 142 | - | `, | |
| 143 | - | ||
| 144 | - | // Admin domain queries | |
| 145 | - | CheckDomainExists: ` | |
| 146 | - | SELECT EXISTS(SELECT 1 FROM domain WHERE name = ?) | |
| 147 | - | `, | |
| 148 | - | CreateAdminDomain: ` | |
| 149 | - | INSERT INTO domain ( | |
| 150 | - | id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type | |
| 151 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?) | |
| 152 | - | `, | |
| 153 | - | UpdateAdminDomainAcme: ` | |
| 154 | - | UPDATE domain SET | |
| 155 | - | acme_enabled = 1, | |
| 156 | - | acme_challenge_type = 'http-01', | |
| 157 | - | updated_at = ? | |
| 158 | - | WHERE name = ? | |
| 159 | - | `, | |
| 160 | - | ||
| 161 | - | // ACME Account queries (NEW) | |
| 162 | - | GetAcmeAccountByEmail: ` | |
| 163 | - | SELECT private_key, registration_info | |
| 164 | - | FROM acme_account | |
| 165 | - | WHERE email = ? | |
| 166 | - | `, | |
| 167 | - | InsertOrReplaceAcmeAccount: ` | |
| 168 | - | INSERT OR REPLACE INTO acme_account ( | |
| 169 | - | email, private_key, registration_info, created_at, updated_at | |
| 170 | - | ) VALUES (?, ?, ?, ?, ?) | |
| 171 | - | `, | |
| 172 | - | } | |
| 173 | - | } | |
D internal/db/queries/sessions.go +0 -326
| @@ -1,326 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db" | |
| 9 | - | "github.com/bnema/gordon/pkg/logger" | |
| 10 | - | "github.com/google/uuid" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // SessionQueries contains all SQL queries for session operations | |
| 14 | - | type SessionQueries struct { | |
| 15 | - | InsertSession string | |
| 16 | - | GetSessionByToken string | |
| 17 | - | UpdateSessionToken string | |
| 18 | - | DeleteSession string | |
| 19 | - | GetSessionExpiry string | |
| 20 | - | UpdateSessionExpiry string | |
| 21 | - | CheckSessionExists string | |
| 22 | - | InvalidateSession string | |
| 23 | - | MarkSessionActive string | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // NewSessionQueries returns a new instance of SessionQueries | |
| 27 | - | func NewSessionQueries() *SessionQueries { | |
| 28 | - | return &SessionQueries{ | |
| 29 | - | InsertSession: "INSERT INTO sessions (id, account_id, access_token, browser_info, expires, is_active) VALUES (?, ?, ?, ?, ?, ?)", | |
| 30 | - | GetSessionByToken: "SELECT sessions.id, sessions.account_id, sessions.access_token, sessions.browser_info, sessions.expires, sessions.is_active FROM sessions INNER JOIN account ON sessions.account_id = account.id INNER JOIN provider ON account.id = provider.account_id WHERE sessions.access_token = ? AND sessions.browser_info = ?", | |
| 31 | - | UpdateSessionToken: "UPDATE sessions SET access_token = ?, expires = ?, is_active = true WHERE id = ?", | |
| 32 | - | DeleteSession: "DELETE FROM sessions WHERE account_id = ? AND id = ?", | |
| 33 | - | GetSessionExpiry: "SELECT expires FROM sessions WHERE account_id = ? AND id = ?", | |
| 34 | - | UpdateSessionExpiry: "UPDATE sessions SET expires = ? WHERE account_id = ? AND id = ?", | |
| 35 | - | CheckSessionExists: "SELECT EXISTS(SELECT 1 FROM sessions WHERE id = ?)", | |
| 36 | - | InvalidateSession: "UPDATE sessions SET is_active = ? WHERE id = ?", | |
| 37 | - | MarkSessionActive: "UPDATE sessions SET is_active = true WHERE id = ?", | |
| 38 | - | } | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // CreateDBSession creates a session for the user. | |
| 42 | - | func CreateDBSession(database *sql.DB, browserInfo string, accessToken string, accountID string) (*db.Sessions, error) { | |
| 43 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 44 | - | sessionID := uuid.NewString() | |
| 45 | - | ||
| 46 | - | _, err := database.Exec( | |
| 47 | - | NewSessionQueries().InsertSession, | |
| 48 | - | sessionID, accountID, accessToken, browserInfo, expireTime, true, | |
| 49 | - | ) | |
| 50 | - | ||
| 51 | - | if err != nil { | |
| 52 | - | logger.Error("Failed to create session in database", "error", err, "session_id", sessionID, "account_id", accountID) | |
| 53 | - | return nil, err | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // Create a Sessions struct to return | |
| 57 | - | session := &db.Sessions{ | |
| 58 | - | ID: sessionID, | |
| 59 | - | AccountID: accountID, | |
| 60 | - | AccessToken: accessToken, | |
| 61 | - | BrowserInfo: browserInfo, | |
| 62 | - | Expires: expireTime, | |
| 63 | - | IsActive: true, | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | logger.Debug("Session created in database", "session_id", sessionID, "account_id", accountID, "expires", expireTime) | |
| 67 | - | return session, nil | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // GetDBUserSession gets the user session from the database based on the access token and browser info. | |
| 71 | - | func GetDBUserSession(database *sql.DB, accessToken string, browserInfo string) (*db.Sessions, error) { | |
| 72 | - | session := &db.Sessions{} | |
| 73 | - | ||
| 74 | - | err := database.QueryRow( | |
| 75 | - | NewSessionQueries().GetSessionByToken, | |
| 76 | - | accessToken, | |
| 77 | - | browserInfo, | |
| 78 | - | ).Scan( | |
| 79 | - | &session.ID, | |
| 80 | - | &session.AccountID, | |
| 81 | - | &session.AccessToken, | |
| 82 | - | &session.BrowserInfo, | |
| 83 | - | &session.Expires, | |
| 84 | - | &session.IsActive, | |
| 85 | - | ) | |
| 86 | - | ||
| 87 | - | if err != nil { | |
| 88 | - | if err == sql.ErrNoRows { | |
| 89 | - | return nil, nil | |
| 90 | - | } | |
| 91 | - | logger.Warn("Failed to get session", "error", err) | |
| 92 | - | return nil, fmt.Errorf("error getting session: %w", err) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | logger.Debug("Session retrieved from database", "session", session) | |
| 96 | - | ||
| 97 | - | return session, nil | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // UpdateDBSession updates an existing session with a new access token | |
| 101 | - | func UpdateDBSession(database *sql.DB, sessionID string, accessToken string) error { | |
| 102 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 103 | - | ||
| 104 | - | _, err := database.Exec( | |
| 105 | - | NewSessionQueries().UpdateSessionToken, | |
| 106 | - | accessToken, | |
| 107 | - | expireTime, | |
| 108 | - | sessionID, | |
| 109 | - | ) | |
| 110 | - | ||
| 111 | - | if err != nil { | |
| 112 | - | logger.Error("Failed to update session in database", "error", err, "session_id", sessionID) | |
| 113 | - | return err | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | logger.Debug("Session updated in database", "sessionID", sessionID, "expires", expireTime) | |
| 117 | - | return nil | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // CreateOrUpdateSession creates a new session or updates an existing one | |
| 121 | - | func CreateOrUpdateSession(database *sql.DB, accountID string, accessToken string, browserInfo string) (*db.Sessions, error) { | |
| 122 | - | // Check if a session exists for this account with the same browser info | |
| 123 | - | // (Token might change, so check by account/browser) | |
| 124 | - | existingSession, err := GetSessionByAccountAndBrowser(database, accountID, browserInfo) | |
| 125 | - | if err != nil && err != sql.ErrNoRows { | |
| 126 | - | logger.Error("Failed to check for existing session", "error", err, "accountID", accountID) | |
| 127 | - | return nil, fmt.Errorf("error checking for existing session: %w", err) | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | expireTime := time.Now().Add(time.Hour * 24).Format(time.RFC3339) | |
| 131 | - | ||
| 132 | - | // If the session exists, update its token and expiry | |
| 133 | - | if existingSession != nil { | |
| 134 | - | logger.Debug("Existing session found, updating token and expiry", "sessionID", existingSession.ID, "accountID", accountID) | |
| 135 | - | updateQuery := NewSessionQueries().UpdateSessionToken | |
| 136 | - | updateParams := []interface{}{accessToken, expireTime, existingSession.ID} | |
| 137 | - | logger.Debug("Executing session update", "query", updateQuery, "params", updateParams) | |
| 138 | - | result, err := database.Exec( | |
| 139 | - | updateQuery, | |
| 140 | - | updateParams..., | |
| 141 | - | ) | |
| 142 | - | if err != nil { | |
| 143 | - | logger.Error("Database Exec failed during session update", "error", err, "session_id", existingSession.ID) | |
| 144 | - | } else { | |
| 145 | - | rowsAffected, _ := result.RowsAffected() | |
| 146 | - | logger.Debug("Database Exec success during session update", "session_id", existingSession.ID, "rows_affected", rowsAffected) | |
| 147 | - | } | |
| 148 | - | if err != nil { | |
| 149 | - | logger.Error("Failed to update existing session", "error", err, "session_id", existingSession.ID) | |
| 150 | - | return nil, fmt.Errorf("error updating session: %w", err) | |
| 151 | - | } | |
| 152 | - | // Update the struct we have in memory | |
| 153 | - | existingSession.AccessToken = accessToken | |
| 154 | - | existingSession.Expires = expireTime | |
| 155 | - | existingSession.IsActive = true // Ensure it's marked active | |
| 156 | - | logger.Info("Session updated successfully", "sessionID", existingSession.ID) | |
| 157 | - | return existingSession, nil | |
| 158 | - | } else { | |
| 159 | - | // If the session does not exist, create a new one | |
| 160 | - | logger.Debug("No existing session found, creating new session", "accountID", accountID) | |
| 161 | - | sessionID := uuid.NewString() // Use uuid directly | |
| 162 | - | insertQuery := NewSessionQueries().InsertSession | |
| 163 | - | insertParams := []interface{}{sessionID, accountID, accessToken, browserInfo, expireTime, true} | |
| 164 | - | logger.Debug("Executing session insert", "query", insertQuery, "params", insertParams) | |
| 165 | - | result, err := database.Exec( | |
| 166 | - | insertQuery, | |
| 167 | - | insertParams..., | |
| 168 | - | ) | |
| 169 | - | if err != nil { | |
| 170 | - | logger.Error("Database Exec failed during session insert", "error", err, "accountID", accountID) | |
| 171 | - | } else { | |
| 172 | - | rowsAffected, _ := result.RowsAffected() | |
| 173 | - | lastInsertId, _ := result.LastInsertId() // Might be 0/irrelevant for SQLite UUID PKs, but log anyway | |
| 174 | - | logger.Debug("Database Exec success during session insert", "accountID", accountID, "rows_affected", rowsAffected, "last_insert_id", lastInsertId) | |
| 175 | - | } | |
| 176 | - | if err != nil { | |
| 177 | - | logger.Error("Failed to create new session", "error", err, "accountID", accountID) | |
| 178 | - | return nil, fmt.Errorf("error creating session: %w", err) | |
| 179 | - | } | |
| 180 | - | // Create the session struct to return | |
| 181 | - | newSession := &db.Sessions{ | |
| 182 | - | ID: sessionID, | |
| 183 | - | AccountID: accountID, | |
| 184 | - | AccessToken: accessToken, | |
| 185 | - | BrowserInfo: browserInfo, | |
| 186 | - | Expires: expireTime, | |
| 187 | - | IsActive: true, | |
| 188 | - | } | |
| 189 | - | logger.Info("New session created successfully", "sessionID", newSession.ID) | |
| 190 | - | return newSession, nil | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | // GetSessionByAccountAndBrowser finds a session by account ID and browser info | |
| 195 | - | func GetSessionByAccountAndBrowser(database *sql.DB, accountID string, browserInfo string) (*db.Sessions, error) { | |
| 196 | - | session := &db.Sessions{} | |
| 197 | - | // Define the query inline or add to SessionQueries struct | |
| 198 | - | query := "SELECT id, account_id, access_token, browser_info, expires, is_active FROM sessions WHERE account_id = ? AND browser_info = ?" | |
| 199 | - | err := database.QueryRow(query, accountID, browserInfo).Scan( | |
| 200 | - | &session.ID, | |
| 201 | - | &session.AccountID, | |
| 202 | - | &session.AccessToken, | |
| 203 | - | &session.BrowserInfo, | |
| 204 | - | &session.Expires, | |
| 205 | - | &session.IsActive, | |
| 206 | - | ) | |
| 207 | - | if err != nil { | |
| 208 | - | // Return sql.ErrNoRows if not found | |
| 209 | - | return nil, err | |
| 210 | - | } | |
| 211 | - | return session, nil | |
| 212 | - | } | |
| 213 | - | ||
| 214 | - | // DeleteDBSession deletes the session from the database. | |
| 215 | - | func DeleteDBSession(database *sql.DB, accountID string, sessionID string) error { | |
| 216 | - | _, err := database.Exec( | |
| 217 | - | NewSessionQueries().DeleteSession, | |
| 218 | - | accountID, | |
| 219 | - | sessionID, | |
| 220 | - | ) | |
| 221 | - | ||
| 222 | - | if err != nil { | |
| 223 | - | logger.Error("Failed to delete session from database", "error", err, "account_id", accountID, "session_id", sessionID) | |
| 224 | - | return err | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | logger.Debug("Session deleted from database", "accountID", accountID, "sessionID", sessionID) | |
| 228 | - | return nil | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // GetSessionExpiration gets the expiration time for a session | |
| 232 | - | func GetSessionExpiration(database *sql.DB, accountID string, sessionID string, currentTime time.Time) (time.Time, error) { | |
| 233 | - | var expirationTime string | |
| 234 | - | ||
| 235 | - | err := database.QueryRow( | |
| 236 | - | NewSessionQueries().GetSessionExpiry, | |
| 237 | - | accountID, | |
| 238 | - | sessionID, | |
| 239 | - | ).Scan(&expirationTime) | |
| 240 | - | ||
| 241 | - | if err != nil { | |
| 242 | - | return currentTime, err | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | // convert the expiration time to a time.Time | |
| 246 | - | expirationTimeParsed, err := time.Parse(time.RFC3339, expirationTime) | |
| 247 | - | if err != nil { | |
| 248 | - | return currentTime, err | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | logger.Debug("Session expiration time retrieved", "accountID", accountID, "sessionID", sessionID, "expirationTime", expirationTimeParsed) | |
| 252 | - | ||
| 253 | - | return expirationTimeParsed, nil | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // ExtendSessionExpiration extends the expiration time for a session | |
| 257 | - | func ExtendSessionExpiration(database *sql.DB, accountID string, sessionID string, newExpirationTime time.Time) error { | |
| 258 | - | _, err := database.Exec( | |
| 259 | - | NewSessionQueries().UpdateSessionExpiry, | |
| 260 | - | newExpirationTime.Format(time.RFC3339), | |
| 261 | - | accountID, | |
| 262 | - | sessionID, | |
| 263 | - | ) | |
| 264 | - | ||
| 265 | - | if err != nil { | |
| 266 | - | logger.Error("Failed to extend session expiration", "error", err, "account_id", accountID, "session_id", sessionID) | |
| 267 | - | return err | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | logger.Debug("Session expiration time extended", "accountID", accountID, "sessionID", sessionID, "newExpirationTime", newExpirationTime) | |
| 271 | - | return nil | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | // CheckDBSessionExists checks if a session exists | |
| 275 | - | func CheckDBSessionExists(database *sql.DB, sessionID string) (bool, error) { | |
| 276 | - | var sessionExists bool | |
| 277 | - | err := database.QueryRow( | |
| 278 | - | NewSessionQueries().CheckSessionExists, | |
| 279 | - | sessionID, | |
| 280 | - | ).Scan(&sessionExists) | |
| 281 | - | ||
| 282 | - | if err != nil { | |
| 283 | - | return false, err | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | logger.Debug("Session existence checked", "sessionID", sessionID, "exists", sessionExists) | |
| 287 | - | return sessionExists, nil | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | // MarkSessionInactive marks a session as inactive (sets is_active to false) | |
| 291 | - | func MarkSessionInactive(database *sql.DB, sessionID string) error { | |
| 292 | - | _, err := database.Exec( | |
| 293 | - | NewSessionQueries().InvalidateSession, | |
| 294 | - | false, | |
| 295 | - | sessionID, | |
| 296 | - | ) | |
| 297 | - | ||
| 298 | - | if err != nil { | |
| 299 | - | logger.Error("Failed to mark session inactive", "error", err, "session_id", sessionID) | |
| 300 | - | return err | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | logger.Debug("Session marked as inactive", "sessionID", sessionID) | |
| 304 | - | return nil | |
| 305 | - | } | |
| 306 | - | ||
| 307 | - | // MarkSessionActive sets the is_active flag to true for a given session ID. | |
| 308 | - | func MarkSessionActive(database *sql.DB, sessionID string) error { | |
| 309 | - | query := NewSessionQueries().MarkSessionActive | |
| 310 | - | result, err := database.Exec(query, sessionID) | |
| 311 | - | ||
| 312 | - | if err != nil { | |
| 313 | - | logger.Error("Failed to mark session active in database", "error", err, "session_id", sessionID) | |
| 314 | - | return fmt.Errorf("failed to execute MarkSessionActive query: %w", err) | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | rowsAffected, _ := result.RowsAffected() | |
| 318 | - | if rowsAffected == 0 { | |
| 319 | - | logger.Warn("MarkSessionActive query executed but did not affect any rows", "sessionID", sessionID) | |
| 320 | - | // This might indicate the session ID didn't exist, though the query technically succeeded. | |
| 321 | - | // Consider if this should return an error or not. For now, logging a warning. | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | logger.Debug("Marked session active in database", "sessionID", sessionID, "rows_affected", rowsAffected) | |
| 325 | - | return nil | |
| 326 | - | } | |
D internal/db/queries/user.go +0 -195
| @@ -1,195 +0,0 @@ | |||
| 1 | - | package queries | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/db" | |
| 8 | - | "github.com/bnema/gordon/pkg/logger" | |
| 9 | - | "github.com/google/uuid" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // UserQueries contains all SQL queries for user operations | |
| 13 | - | type UserQueries struct { | |
| 14 | - | InsertUser string | |
| 15 | - | CheckUserExists string | |
| 16 | - | GetUserByProvider string | |
| 17 | - | GetAccountCount string | |
| 18 | - | GetFirstUserAndAccountQuery string | |
| 19 | - | UpdateUserDetailsQuery string | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // NewUserQueries returns a new instance of UserQueries | |
| 23 | - | func NewUserQueries() *UserQueries { | |
| 24 | - | return &UserQueries{ | |
| 25 | - | InsertUser: "INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | |
| 26 | - | CheckUserExists: "SELECT EXISTS(SELECT 1 FROM user LIMIT 1)", | |
| 27 | - | GetUserByProvider: "SELECT user.id, user.name, user.email FROM user INNER JOIN account ON user.id = account.user_id INNER JOIN provider ON account.id = provider.account_id WHERE provider.login = ? AND provider.email = ?", | |
| 28 | - | GetAccountCount: "SELECT COUNT(*) FROM account", | |
| 29 | - | GetFirstUserAndAccountQuery: ` | |
| 30 | - | SELECT u.id, u.name, u.email, a.id | |
| 31 | - | FROM user u | |
| 32 | - | JOIN account a ON u.id = a.user_id | |
| 33 | - | ORDER BY u.rowid ASC -- Rely on implicit rowid for 'first' | |
| 34 | - | LIMIT 1`, | |
| 35 | - | UpdateUserDetailsQuery: "UPDATE user SET name = ?, email = ? WHERE id = ?", | |
| 36 | - | } | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // GetFirstUserAndAccount retrieves the first user and their associated account ID. Assumes this is the seeded admin. | |
| 40 | - | func GetFirstUserAndAccount(database *sql.DB) (*db.User, string, error) { | |
| 41 | - | user := &db.User{} | |
| 42 | - | var accountID string | |
| 43 | - | err := database.QueryRow(NewUserQueries().GetFirstUserAndAccountQuery).Scan( | |
| 44 | - | &user.ID, | |
| 45 | - | &user.Name, | |
| 46 | - | &user.Email, | |
| 47 | - | &accountID, | |
| 48 | - | ) | |
| 49 | - | if err != nil { | |
| 50 | - | return nil, "", err | |
| 51 | - | } | |
| 52 | - | return user, accountID, nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // UpdateUserDetails updates the name and email for a specific user ID. | |
| 56 | - | func UpdateUserDetails(database *sql.DB, userID string, newName string, newEmail string) error { | |
| 57 | - | _, err := database.Exec(NewUserQueries().UpdateUserDetailsQuery, newName, newEmail, userID) | |
| 58 | - | return err | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // CreateUser creates a new user along with the associated account, provider, and session. | |
| 62 | - | func CreateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 63 | - | // Check if a user already exists. If so, return an error. | |
| 64 | - | logger.Debug("Starting CreateUser database function") | |
| 65 | - | if exists, err := CheckDBUserExists(database); err != nil || exists { | |
| 66 | - | logger.Warn("User already exists or error checking", "error", err, "exists", exists) | |
| 67 | - | return nil, "", nil, fmt.Errorf("error checking user or user already exists: %v", err) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | logger.Debug("Creating database user", "login", userInfo.Login, "email", userInfo.Emails[0]) | |
| 71 | - | user, err := CreateDBUser(database, userInfo) | |
| 72 | - | if err != nil { | |
| 73 | - | logger.Error("Failed to create database user", "error", err) | |
| 74 | - | return nil, "", nil, err | |
| 75 | - | } | |
| 76 | - | logger.Info("Database user created successfully", "user_id", user.ID) | |
| 77 | - | ||
| 78 | - | logger.Debug("Creating database account for user", "user_id", user.ID) | |
| 79 | - | account, err := CreateDBAccount(database, user.ID) | |
| 80 | - | if err != nil { | |
| 81 | - | logger.Error("Failed to create database account", "error", err) | |
| 82 | - | return nil, "", nil, err | |
| 83 | - | } | |
| 84 | - | logger.Info("Database account created successfully", "account_id", account.ID) | |
| 85 | - | ||
| 86 | - | logger.Debug("Creating GitHub provider for account", "account_id", account.ID) | |
| 87 | - | err = InsertProviderForAccount(database, account.ID, "github", userInfo) | |
| 88 | - | if err != nil { | |
| 89 | - | logger.Error("Failed to create GitHub provider", "error", err) | |
| 90 | - | return nil, "", nil, err | |
| 91 | - | } | |
| 92 | - | logger.Info("GitHub provider created successfully") | |
| 93 | - | ||
| 94 | - | logger.Debug("Creating session for account", "account_id", account.ID) | |
| 95 | - | session, err := CreateOrUpdateSession(database, account.ID, accessToken, browserInfo) | |
| 96 | - | if err != nil { | |
| 97 | - | logger.Error("Failed to create session", "error", err) | |
| 98 | - | return nil, "", nil, err | |
| 99 | - | } | |
| 100 | - | logger.Info("Session created successfully") | |
| 101 | - | ||
| 102 | - | return user, account.ID, session, nil | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // CreateDBUser creates a new user in the database | |
| 106 | - | func CreateDBUser(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, error) { | |
| 107 | - | primaryEmail := "" | |
| 108 | - | if len(userInfo.Emails) > 0 { | |
| 109 | - | primaryEmail = userInfo.Emails[0] | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | user := &db.User{ | |
| 113 | - | ID: uuid.NewString(), | |
| 114 | - | Name: userInfo.Login, | |
| 115 | - | Email: primaryEmail, | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | _, err := database.Exec( | |
| 119 | - | NewUserQueries().InsertUser, | |
| 120 | - | user.ID, user.Name, user.Email, | |
| 121 | - | ) | |
| 122 | - | return user, err | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // CheckDBUserExists checks if any user exists in the database | |
| 126 | - | func CheckDBUserExists(database *sql.DB) (bool, error) { | |
| 127 | - | var exists bool | |
| 128 | - | err := database.QueryRow(NewUserQueries().CheckUserExists).Scan(&exists) | |
| 129 | - | if err != nil { | |
| 130 | - | return false, err | |
| 131 | - | } | |
| 132 | - | return exists, nil | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // CheckDBUserIsGood checks if the user matches the provided GitHub info | |
| 136 | - | func CheckDBUserIsGood(database *sql.DB, userInfo *db.GithubUserInfo) (*db.User, bool, error) { | |
| 137 | - | login := userInfo.Login | |
| 138 | - | primaryEmail := "" | |
| 139 | - | if len(userInfo.Emails) > 0 { | |
| 140 | - | primaryEmail = userInfo.Emails[0] | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | user := &db.User{} | |
| 144 | - | ||
| 145 | - | // Check if the user exists based on github login and email | |
| 146 | - | err := database.QueryRow(NewUserQueries().GetUserByProvider, login, primaryEmail).Scan( | |
| 147 | - | &user.ID, &user.Name, &user.Email, | |
| 148 | - | ) | |
| 149 | - | ||
| 150 | - | if err != nil { | |
| 151 | - | if err == sql.ErrNoRows { | |
| 152 | - | return nil, false, nil | |
| 153 | - | } | |
| 154 | - | return nil, false, err | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | if user.ID != "" { | |
| 158 | - | return user, true, nil | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | return nil, false, nil | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // UpdateUser updates the user session with new access token | |
| 165 | - | func UpdateUser(database *sql.DB, accessToken string, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 166 | - | // First, get the user info to get their account ID | |
| 167 | - | user, accountID, err := GetUserByProviderLogin(database, "github", userInfo.Login) | |
| 168 | - | if err != nil { | |
| 169 | - | if err == sql.ErrNoRows { | |
| 170 | - | return nil, "", nil, fmt.Errorf("user not found in database for provider login '%s'", userInfo.Login) | |
| 171 | - | } | |
| 172 | - | return nil, "", nil, fmt.Errorf("error checking user by provider login: %w", err) | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // If user found, update/create their session | |
| 176 | - | session, err := CreateOrUpdateSession(database, accountID, accessToken, browserInfo) | |
| 177 | - | if err != nil { | |
| 178 | - | return nil, "", nil, fmt.Errorf("error creating/updating session: %w", err) | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | logger.Debug("Session updated/created in database", "accountID", accountID, "sessionID", session.ID) | |
| 182 | - | ||
| 183 | - | return user, accountID, session, nil | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // GetAccountCount returns the number of accounts in the database | |
| 187 | - | func GetAccountCount(database *sql.DB) (int, error) { | |
| 188 | - | var count int | |
| 189 | - | err := database.QueryRow(NewUserQueries().GetAccountCount).Scan(&count) | |
| 190 | - | if err != nil { | |
| 191 | - | return 0, err | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | return count, nil | |
| 195 | - | } | |
D internal/httpserve/handlers/admin_view.go +0 -101
| @@ -1,101 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |
| 8 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 9 | - | "github.com/bnema/gordon/pkg/docker" | |
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // GetLocalizedData returns the data for the localization | |
| 14 | - | func GetLocalizedData(c echo.Context, a *server.App) (map[string]string, error) { | |
| 15 | - | lang := c.Get("LangKey") | |
| 16 | - | if lang == nil { | |
| 17 | - | return nil, fmt.Errorf("LangKey not found in context") | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | langStr, ok := lang.(string) | |
| 21 | - | if !ok { | |
| 22 | - | return nil, fmt.Errorf("LangKey is not a string") | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | yamlDataInterface, err := render.GetLocalization(langStr, a) | |
| 26 | - | if err != nil { | |
| 27 | - | return nil, fmt.Errorf("failed to get localization: %w", err) | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // Convert map[string]interface{} to map[string]string | |
| 31 | - | langContent := make(map[string]string) | |
| 32 | - | for key, value := range yamlDataInterface { | |
| 33 | - | strValue, ok := value.(string) | |
| 34 | - | if !ok { | |
| 35 | - | // Handle cases where the value is not a string, e.g., log a warning or skip | |
| 36 | - | // For now, we'll skip non-string values | |
| 37 | - | continue | |
| 38 | - | } | |
| 39 | - | langContent[key] = strValue | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Return map[string]string directly | |
| 43 | - | return langContent, nil | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // AdminRoute is the route for the admin panel | |
| 47 | - | func AdminRoute(c echo.Context, a *server.App) error { | |
| 48 | - | langContent, err := GetLocalizedData(c, a) | |
| 49 | - | if err != nil { | |
| 50 | - | return sendError(c, err) // Assuming sendError exists | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | data := admin.IndexPageData{ | |
| 54 | - | Title: "Admin Dashboard", // Or get from LangContent if available | |
| 55 | - | BuildVersion: a.Config.Build.BuildVersion, | |
| 56 | - | AdminPath: a.Config.Admin.Path, | |
| 57 | - | UserSettings: a.Config.Admin.Path + "/user", // Assuming this path | |
| 58 | - | LogoutURL: a.Config.Admin.Path + "/logout", | |
| 59 | - | LangContent: langContent, | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // Use the Templ renderer | |
| 63 | - | renderer := render.NewTemplRenderer(a) | |
| 64 | - | component := admin.IndexPage(data) | |
| 65 | - | ||
| 66 | - | return renderer.RenderTempl(c, component) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // AdminManagerRoute is the route of the manager page | |
| 70 | - | func AdminManagerRoute(c echo.Context, a *server.App) error { | |
| 71 | - | // Fetch container and image counts | |
| 72 | - | containers, err := docker.ListRunningContainers() | |
| 73 | - | if err != nil { | |
| 74 | - | return sendError(c, fmt.Errorf("failed to list containers: %w", err)) | |
| 75 | - | } | |
| 76 | - | images, err := docker.ListContainerImages() | |
| 77 | - | if err != nil { | |
| 78 | - | return sendError(c, fmt.Errorf("failed to list images: %w", err)) | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | containerCount := len(containers) | |
| 82 | - | imageCount := len(images) | |
| 83 | - | ||
| 84 | - | title := "Container Manager" // Set a default title | |
| 85 | - | userSettingsURL := a.Config.Admin.Path + "/user" // Assuming this path | |
| 86 | - | logoutURL := a.Config.Admin.Path + "/logout" | |
| 87 | - | ||
| 88 | - | // Use the Templ renderer | |
| 89 | - | renderer := render.NewTemplRenderer(a) | |
| 90 | - | component := admin.ManagerPage( | |
| 91 | - | title, | |
| 92 | - | a.Config.Build.BuildVersion, | |
| 93 | - | a.Config.Admin.Path, | |
| 94 | - | userSettingsURL, | |
| 95 | - | logoutURL, | |
| 96 | - | containerCount, | |
| 97 | - | imageCount, | |
| 98 | - | ) | |
| 99 | - | ||
| 100 | - | return renderer.RenderTempl(c, component) | |
| 101 | - | } | |
D internal/httpserve/handlers/container_crud.go +0 -246
| @@ -1,246 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 10 | - | templcomponents "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 11 | - | templadmin "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |
| 12 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 13 | - | "github.com/bnema/gordon/pkg/docker" | |
| 14 | - | "github.com/bnema/gordon/pkg/sanitize" | |
| 15 | - | "github.com/charmbracelet/log" | |
| 16 | - | "github.com/labstack/echo/v4" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | // FromShortIDToImageID converts a short image ID to a full image ID | |
| 20 | - | func FromShortIDToImageID(ShortID string) (string, error) { | |
| 21 | - | imageID, exists := safelyInteractWithIDMap(Fetch, ShortID) | |
| 22 | - | if !exists { | |
| 23 | - | log.Debug("No mapping found for short ID: %s", ShortID) | |
| 24 | - | return "", fmt.Errorf("image ID not found") | |
| 25 | - | } | |
| 26 | - | return imageID, nil | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // CreateContainerRoute is the route for creating a new container | |
| 30 | - | func CreateContainerGET(c echo.Context, a *server.App) error { | |
| 31 | - | log.Debug("Full request URL: %s", c.Request().URL.String()) | |
| 32 | - | // Retrieve the ShortID of the image from the URL | |
| 33 | - | shortID := c.Param("ID") | |
| 34 | - | log.Debug("Received ShortID: %s", shortID) | |
| 35 | - | ||
| 36 | - | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 37 | - | if !exists { | |
| 38 | - | log.Debug("No mapping found for short ID: %s", shortID) | |
| 39 | - | return RenderNotFoundPage(c, a) | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | log.Debug("Found mapping: %s -> %s", shortID, fullID) | |
| 43 | - | ||
| 44 | - | // Get the image info | |
| 45 | - | imageInfo, err := docker.GetImageInfo(fullID) | |
| 46 | - | if err != nil { | |
| 47 | - | return sendError(c, err) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Extract image name | |
| 51 | - | var imageName string | |
| 52 | - | if len(imageInfo.RepoTags) > 0 { | |
| 53 | - | imageName = imageInfo.RepoTags[0] | |
| 54 | - | } else { | |
| 55 | - | imageName = imageInfo.ID | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | // Setup form data | |
| 59 | - | formData := templcomponents.ContainerFormData{ | |
| 60 | - | ImageName: imageName, | |
| 61 | - | ImageID: fullID, | |
| 62 | - | ShortID: shortID, | |
| 63 | - | ErrorMessage: "", | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Get the renderer | |
| 67 | - | renderer := render.NewTemplRenderer(a) | |
| 68 | - | ||
| 69 | - | // Render the component | |
| 70 | - | return renderer.RenderTempl(c, templcomponents.CreateContainerForm(formData)) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // CreateContainerFullGET handles the full HTML page for creating a new container | |
| 74 | - | func CreateContainerFullGET(c echo.Context, a *server.App) error { | |
| 75 | - | // Retrieve the ShortID of the image from the URL | |
| 76 | - | shortID := c.Param("ID") | |
| 77 | - | log.Debug("Received ShortID: %s", shortID) | |
| 78 | - | ||
| 79 | - | fullID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 80 | - | if !exists { | |
| 81 | - | log.Debug("No mapping found for short ID: %s", shortID) | |
| 82 | - | return RenderNotFoundPage(c, a) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | log.Debug("Found mapping: %s -> %s", shortID, fullID) | |
| 86 | - | ||
| 87 | - | // Get the image info | |
| 88 | - | imageInfo, err := docker.GetImageInfo(fullID) | |
| 89 | - | if err != nil { | |
| 90 | - | return sendError(c, err) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Extract image name | |
| 94 | - | var imageName string | |
| 95 | - | if len(imageInfo.RepoTags) > 0 { | |
| 96 | - | imageName = imageInfo.RepoTags[0] | |
| 97 | - | } else { | |
| 98 | - | imageName = imageInfo.ID | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | // Setup form data | |
| 102 | - | formData := templcomponents.ContainerFormData{ | |
| 103 | - | ImageName: imageName, | |
| 104 | - | ImageID: fullID, | |
| 105 | - | ShortID: shortID, | |
| 106 | - | ErrorMessage: "", | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // Get the renderer | |
| 110 | - | renderer := render.NewTemplRenderer(a) | |
| 111 | - | ||
| 112 | - | // Render the createcontainerfull template | |
| 113 | - | pageData := templadmin.CreateContainerFullData{ | |
| 114 | - | Title: "Create Container | Gordon", | |
| 115 | - | BuildVersion: a.Config.Build.BuildVersion, | |
| 116 | - | AdminPath: a.Config.Admin.Path, | |
| 117 | - | UserSettings: "/user", | |
| 118 | - | LogoutURL: a.Config.Admin.Path + "/logout", | |
| 119 | - | ContainerData: formData, | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | return renderer.RenderTempl(c, templadmin.CreateContainerFullPage(pageData)) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // CreateContainerPOST handles the create container form submission | |
| 126 | - | func CreateContainerPOST(c echo.Context, a *server.App) error { | |
| 127 | - | log.Debug("CreateContainerPOST handler called") | |
| 128 | - | ||
| 129 | - | // Retrieve all form parameters | |
| 130 | - | formParams, err := c.FormParams() | |
| 131 | - | if err != nil { | |
| 132 | - | return sendError(c, err) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // Initialize a map to store sanitized values | |
| 136 | - | sanitizedInputs := make(map[string]string) | |
| 137 | - | ||
| 138 | - | // Iterate over all form parameters and sanitize them | |
| 139 | - | for k, v := range formParams { | |
| 140 | - | if len(v) > 0 { | |
| 141 | - | sanitized, err := sanitize.SanitizeHTML(v[0]) | |
| 142 | - | if err != nil { | |
| 143 | - | return sendError(c, err) | |
| 144 | - | } | |
| 145 | - | sanitizedInputs[k] = sanitized | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // Get the full image ID directly from the sanitized form inputs | |
| 150 | - | imageID, ok := sanitizedInputs["image_id"] | |
| 151 | - | if !ok || imageID == "" { | |
| 152 | - | log.Error("Full image ID not found in form submission") | |
| 153 | - | // You might want to return a more specific error message to the user here | |
| 154 | - | return sendError(c, fmt.Errorf("missing image ID in form submission")) | |
| 155 | - | } | |
| 156 | - | log.Debug("Retrieved full image ID from form:", "imageID", imageID) | |
| 157 | - | ||
| 158 | - | // Get the image info (Optional but good practice: verify the image ID still exists) | |
| 159 | - | _, err = docker.GetImageInfo(imageID) | |
| 160 | - | if err != nil { | |
| 161 | - | log.Error("Error getting image info:", "error", err) | |
| 162 | - | return sendError(c, err) | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | cmdParams, err := cmdparams.FromInputsToCmdParams(sanitizedInputs, a) | |
| 166 | - | if err != nil { | |
| 167 | - | return sendError(c, err) | |
| 168 | - | } | |
| 169 | - | ||
| 170 | - | // Create the container | |
| 171 | - | containerID, err := docker.CreateContainer(cmdParams) | |
| 172 | - | if err != nil { | |
| 173 | - | return sendError(c, err) | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | // Start the container | |
| 177 | - | err = docker.StartContainer(containerID) | |
| 178 | - | if err != nil { | |
| 179 | - | // If start fails, try to cleanup the container | |
| 180 | - | log.Warn("Failed to start container", "error", err) | |
| 181 | - | _ = docker.RemoveContainer(containerID) | |
| 182 | - | return sendError(c, fmt.Errorf("failed to start container: %w", err)) | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Get the container name for logging and as fallback | |
| 186 | - | containerName, err := docker.GetContainerName(containerID) | |
| 187 | - | if err != nil { | |
| 188 | - | log.Warn("Failed to get container name", "error", err) | |
| 189 | - | } else { | |
| 190 | - | containerName = strings.TrimPrefix(containerName, "/") | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | // Check if we should skip proxy setup | |
| 194 | - | skipProxySetup := false | |
| 195 | - | if skipProxyValue, exists := sanitizedInputs["skip_proxy_setup"]; exists { | |
| 196 | - | skipProxySetup = skipProxyValue == "true" || skipProxyValue == "1" || skipProxyValue == "yes" | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Only set up proxy if not skipped | |
| 200 | - | if !skipProxySetup { | |
| 201 | - | // Get container IP | |
| 202 | - | containerIP := GetContainerIP(a, containerID, containerName) | |
| 203 | - | ||
| 204 | - | // Construct target domain from form inputs | |
| 205 | - | var targetDomain string | |
| 206 | - | if sanitizedInputs["container_subdomain"] == "" { | |
| 207 | - | // If subdomain is empty, use just the domain | |
| 208 | - | targetDomain = fmt.Sprintf("%s://%s", | |
| 209 | - | sanitizedInputs["container_protocol"], | |
| 210 | - | sanitizedInputs["container_domain"]) | |
| 211 | - | } else { | |
| 212 | - | // If subdomain is provided, use subdomain.domain format | |
| 213 | - | targetDomain = fmt.Sprintf("%s://%s.%s", | |
| 214 | - | sanitizedInputs["container_protocol"], | |
| 215 | - | sanitizedInputs["container_subdomain"], | |
| 216 | - | sanitizedInputs["container_domain"]) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // Add proxy route for the new container | |
| 220 | - | err = AddProxyRoute(a, containerID, containerIP, sanitizedInputs["container_port"], targetDomain) | |
| 221 | - | if err != nil { | |
| 222 | - | log.Error("Failed to add proxy route", "error", err) | |
| 223 | - | // Continue anyway, as the container is already created and started | |
| 224 | - | } | |
| 225 | - | } else { | |
| 226 | - | log.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Prepare success message payload | |
| 230 | - | successPayload := map[string]string{ | |
| 231 | - | "level": "success", | |
| 232 | - | "message": fmt.Sprintf("Container '%s' created successfully.", containerName), | |
| 233 | - | } | |
| 234 | - | jsonPayload, err := json.Marshal(successPayload) | |
| 235 | - | if err != nil { | |
| 236 | - | // Log the error, but proceed with redirect as container creation was likely successful | |
| 237 | - | log.Error("Failed to marshal success payload for HX-Trigger", "error", err) | |
| 238 | - | } else { | |
| 239 | - | c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload))) | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // Render a success message fragment instead of redirecting | |
| 243 | - | renderer := render.NewTemplRenderer(a) | |
| 244 | - | successMsg := fmt.Sprintf("Container '%s' created successfully.", containerName) | |
| 245 | - | return renderer.RenderTempl(c, templcomponents.Success(successMsg)) // Use the correct Success component | |
| 246 | - | } | |
D internal/httpserve/handlers/container_management.go +0 -389
| @@ -1,389 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "encoding/json" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | "strings" | |
| 8 | - | "sync" | |
| 9 | - | "time" | |
| 10 | - | ||
| 11 | - | "github.com/bnema/gordon/internal/server" | |
| 12 | - | components "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 13 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 14 | - | "github.com/bnema/gordon/pkg/docker" | |
| 15 | - | "github.com/bnema/gordon/pkg/humanize" | |
| 16 | - | "github.com/bnema/gordon/pkg/logger" | |
| 17 | - | "github.com/docker/docker/api/types" | |
| 18 | - | "github.com/labstack/echo/v4" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // Shared state for last prune result | |
| 22 | - | var lastPruneResult struct { | |
| 23 | - | Done bool | |
| 24 | - | Success bool | |
| 25 | - | Message string | |
| 26 | - | mutex sync.Mutex | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | type MapOperation string | |
| 30 | - | ||
| 31 | - | var ( | |
| 32 | - | idMap = make(map[string]string) | |
| 33 | - | idMapMutex sync.Mutex | |
| 34 | - | ) | |
| 35 | - | ||
| 36 | - | const ( | |
| 37 | - | Fetch MapOperation = "Fetch" | |
| 38 | - | Update MapOperation = "Update" | |
| 39 | - | Delete MapOperation = "Delete" | |
| 40 | - | ) | |
| 41 | - | ||
| 42 | - | func safelyInteractWithIDMap(op MapOperation, key string, value ...string) (string, bool) { | |
| 43 | - | idMapMutex.Lock() | |
| 44 | - | defer idMapMutex.Unlock() | |
| 45 | - | ||
| 46 | - | switch op { | |
| 47 | - | case Fetch: | |
| 48 | - | val, exists := idMap[key] | |
| 49 | - | return val, exists | |
| 50 | - | case Update: | |
| 51 | - | if len(value) > 0 { | |
| 52 | - | idMap[key] = value[0] | |
| 53 | - | } | |
| 54 | - | return "", true | |
| 55 | - | case Delete: | |
| 56 | - | delete(idMap, key) | |
| 57 | - | return "", true | |
| 58 | - | default: | |
| 59 | - | return "", false | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | type HumanReadableContainerImage struct { | |
| 64 | - | ID string | |
| 65 | - | Name string | |
| 66 | - | ShortID string | |
| 67 | - | CreatedStr string | |
| 68 | - | SizeStr string | |
| 69 | - | RepoDigests []string | |
| 70 | - | RepoTags []string | |
| 71 | - | Repository string | |
| 72 | - | Tag string | |
| 73 | - | Size string | |
| 74 | - | Created string | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | type HumanReadableContainer struct { | |
| 78 | - | *types.Container | |
| 79 | - | Name string | |
| 80 | - | Ports []string | |
| 81 | - | ShortID string | |
| 82 | - | CreatedStr string | |
| 83 | - | SizeStr string | |
| 84 | - | UpSince string | |
| 85 | - | StateColor string | |
| 86 | - | ProxyPort string | |
| 87 | - | } | |
| 88 | - | ||
| 89 | - | type ContainerDisplay struct { | |
| 90 | - | ID string | |
| 91 | - | Name string | |
| 92 | - | Ports []docker.PortMapping | |
| 93 | - | ProxyPort string | |
| 94 | - | CreatedStr string | |
| 95 | - | Status string | |
| 96 | - | ImageName string | |
| 97 | - | IsGordonContainer bool | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // ActionSuccess returns the success HTML fragment | |
| 101 | - | func ActionSuccess(a *server.App) string { | |
| 102 | - | ||
| 103 | - | // return just a string | |
| 104 | - | return "success" | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // ImageManagerComponent handles the /image-manager route (HTMX route) | |
| 108 | - | func ImageManagerComponent(c echo.Context, a *server.App) error { | |
| 109 | - | // Check last prune status and add trigger header if needed | |
| 110 | - | lastPruneResult.mutex.Lock() | |
| 111 | - | if lastPruneResult.Done { | |
| 112 | - | var level string | |
| 113 | - | if lastPruneResult.Success { | |
| 114 | - | level = "success" | |
| 115 | - | } else { | |
| 116 | - | level = "error" | |
| 117 | - | } | |
| 118 | - | // Prepare JSON payload for HX-Trigger | |
| 119 | - | payload := map[string]string{"level": level, "message": lastPruneResult.Message} | |
| 120 | - | jsonPayload, _ := json.Marshal(payload) // Error handling omitted for brevity | |
| 121 | - | c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload))) | |
| 122 | - | ||
| 123 | - | // Reset the flag | |
| 124 | - | lastPruneResult.Done = false | |
| 125 | - | } | |
| 126 | - | lastPruneResult.mutex.Unlock() | |
| 127 | - | ||
| 128 | - | images, err := docker.ListContainerImages() | |
| 129 | - | if err != nil { | |
| 130 | - | return sendError(c, err) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | var humanReadableImages []components.HumanReadableImage | |
| 134 | - | for _, img := range images { | |
| 135 | - | shortID := img.ID[7:19] // Display a shorter version of the ID | |
| 136 | - | safelyInteractWithIDMap(Update, shortID, img.ID) | |
| 137 | - | createdStr := humanize.TimeAgo(time.Unix(img.Created, 0)) | |
| 138 | - | sizeStr := humanize.BytesToReadableSize(img.Size) | |
| 139 | - | ||
| 140 | - | // Parse RepoTags to extract repository and tag | |
| 141 | - | var repository, tag string | |
| 142 | - | repoTag := "<none>:<none>" | |
| 143 | - | if len(img.RepoTags) > 0 { | |
| 144 | - | repoTag = img.RepoTags[0] | |
| 145 | - | parts := strings.Split(repoTag, ":") | |
| 146 | - | if len(parts) == 2 { | |
| 147 | - | repository = parts[0] | |
| 148 | - | tag = parts[1] | |
| 149 | - | } else { | |
| 150 | - | repository = repoTag // Handle cases like "image" without a tag | |
| 151 | - | tag = "latest" | |
| 152 | - | } | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // Create instance of components.HumanReadableImage | |
| 156 | - | humanReadableImages = append(humanReadableImages, components.HumanReadableImage{ | |
| 157 | - | ID: img.ID, | |
| 158 | - | ShortID: shortID, | |
| 159 | - | Created: createdStr, | |
| 160 | - | Size: sizeStr, | |
| 161 | - | Name: repoTag, | |
| 162 | - | RepoDigests: img.RepoDigests, | |
| 163 | - | RepoTags: img.RepoTags, | |
| 164 | - | Repository: repository, | |
| 165 | - | Tag: tag, | |
| 166 | - | }) | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | logger.Debug("Rendering imagelist template with", len(humanReadableImages), "images") | |
| 170 | - | ||
| 171 | - | // Use the Templ renderer | |
| 172 | - | renderer := render.NewTemplRenderer(a) | |
| 173 | - | component := components.ImageList(humanReadableImages) | |
| 174 | - | ||
| 175 | - | return renderer.RenderTempl(c, component) | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // ImageManagerDelete handles the /image-manager/delete route | |
| 179 | - | func ImageManagerDelete(c echo.Context, a *server.App) error { | |
| 180 | - | ShortID := c.Param("ID") | |
| 181 | - | ||
| 182 | - | imageID, exists := safelyInteractWithIDMap(Fetch, ShortID) | |
| 183 | - | if !exists { | |
| 184 | - | return c.String(http.StatusBadRequest, "Invalid ShortImgID") | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | err := docker.DeleteContainerImage(imageID) | |
| 188 | - | if err != nil { | |
| 189 | - | c.Response().Header().Set("X-Error-Type", "image") | |
| 190 | - | return c.String(http.StatusInternalServerError, err.Error()) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | safelyInteractWithIDMap(Delete, ShortID) | |
| 194 | - | ||
| 195 | - | return c.String(http.StatusOK, ActionSuccess(a)) | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | // ImageManagerPrune handles the /image-manager/prune route | |
| 199 | - | func ImageManagerPrune(c echo.Context, a *server.App) error { | |
| 200 | - | // Run pruning in a background goroutine | |
| 201 | - | go func() { | |
| 202 | - | var success bool | |
| 203 | - | var message string | |
| 204 | - | logger.Info("Starting background image prune...") | |
| 205 | - | numPurged, err := docker.PruneImages() // Remove context argument | |
| 206 | - | if err != nil { | |
| 207 | - | logger.Error("Background image prune failed", "error", err) | |
| 208 | - | success = false | |
| 209 | - | message = fmt.Sprintf("Image prune failed: %v", err) | |
| 210 | - | // Optionally, notify the user or admin about the failure (e.g., via websocket, SSE, or a status endpoint) | |
| 211 | - | return | |
| 212 | - | } else { | |
| 213 | - | logger.Info("Background image prune completed", "purged_count", numPurged) | |
| 214 | - | success = true | |
| 215 | - | message = fmt.Sprintf("Successfully purged %d images.", numPurged) | |
| 216 | - | // Optionally, notify the user about completion (e.g., via websocket, SSE, or updating the UI) | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // Update shared state | |
| 220 | - | lastPruneResult.mutex.Lock() | |
| 221 | - | lastPruneResult.Done = true | |
| 222 | - | lastPruneResult.Success = success | |
| 223 | - | lastPruneResult.Message = message | |
| 224 | - | lastPruneResult.mutex.Unlock() | |
| 225 | - | }() | |
| 226 | - | ||
| 227 | - | // Immediately return a placeholder that initiates polling | |
| 228 | - | // Note: We need to modify imagelist.templ to handle this response properly | |
| 229 | - | // For now, just return 202 Accepted and indicate work started. | |
| 230 | - | // The actual polling trigger will be added in the template. | |
| 231 | - | c.Response().WriteHeader(http.StatusAccepted) | |
| 232 | - | // We'll return a small snippet that the template swap can use | |
| 233 | - | // This snippet should include the polling mechanism itself. | |
| 234 | - | // Example (needs refinement in the template): | |
| 235 | - | return c.HTML(http.StatusAccepted, `<div id="prune-status-poller" hx-get="/htmx/image-manager/prune-status" hx-trigger="load, every 3s" hx-target="#prune-status-poller" hx-swap="outerHTML">Pruning images in background... Checking status.</div>`) | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // ImageManagerPruneStatus checks the status of a background prune operation | |
| 239 | - | func ImageManagerPruneStatus(c echo.Context, a *server.App) error { | |
| 240 | - | lastPruneResult.mutex.Lock() | |
| 241 | - | defer lastPruneResult.mutex.Unlock() | |
| 242 | - | ||
| 243 | - | // If prune is not done, return an empty response or keep the poller active | |
| 244 | - | if !lastPruneResult.Done { | |
| 245 | - | // Returning 204 No Content will stop polling if using hx-swap="none" | |
| 246 | - | // However, our poller replaces itself, so we just return the same polling div | |
| 247 | - | // or an updated status message within it. | |
| 248 | - | // Let's keep the existing poller HTML but maybe update the text slightly. | |
| 249 | - | return c.HTML(http.StatusOK, `<div id="prune-status-poller" hx-get="/htmx/image-manager/prune-status" hx-trigger="every 3s" hx-target="#prune-status-poller" hx-swap="outerHTML">Still pruning... Checking status.</div>`) | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | // Prune is done, prepare the final response | |
| 253 | - | logger.Info("Prune complete, preparing final response for poller.") | |
| 254 | - | ||
| 255 | - | // Prepare JSON payload for HX-Trigger toast message | |
| 256 | - | var level string | |
| 257 | - | if lastPruneResult.Success { | |
| 258 | - | level = "success" | |
| 259 | - | } else { | |
| 260 | - | level = "error" | |
| 261 | - | } | |
| 262 | - | payload := map[string]string{"level": level, "message": lastPruneResult.Message} | |
| 263 | - | jsonPayload, marshalErr := json.Marshal(payload) | |
| 264 | - | if marshalErr != nil { | |
| 265 | - | logger.Error("Failed to marshal prune result payload for poller", "error", marshalErr) | |
| 266 | - | // Handle marshalling error - maybe return an error component | |
| 267 | - | } else { | |
| 268 | - | c.Response().Header().Set("HX-Trigger", fmt.Sprintf(`{"showMessage": %s}`, string(jsonPayload))) | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | // Reset the flag *after* preparing the response | |
| 272 | - | lastPruneResult.Done = false | |
| 273 | - | message := lastPruneResult.Message // Store message before resetting if needed | |
| 274 | - | success := lastPruneResult.Success | |
| 275 | - | // Reset message/success if needed, or maybe they are only relevant for the toast | |
| 276 | - | // lastPruneResult.Message = "" | |
| 277 | - | // lastPruneResult.Success = false | |
| 278 | - | ||
| 279 | - | // Fetch the updated image list (copy logic from ImageManagerComponent) | |
| 280 | - | images, listErr := docker.ListContainerImages() | |
| 281 | - | if listErr != nil { | |
| 282 | - | logger.Error("Failed to list images after prune for poller", "error", listErr) | |
| 283 | - | // Return an error FRAGMENT instead of a full page. | |
| 284 | - | // HX-Trigger for the prune status itself was already set. | |
| 285 | - | pruneStatusMsg := "Prune finished but failed to refresh list." | |
| 286 | - | if !success { // Add prune status detail if it also failed | |
| 287 | - | pruneStatusMsg = fmt.Sprintf("Prune status: %s. Failed to refresh list.", message) | |
| 288 | - | } | |
| 289 | - | return c.HTML(http.StatusOK, fmt.Sprintf("<div class='alert alert-error'>%s Error: %v. Please refresh manually.</div>", pruneStatusMsg, listErr)) | |
| 290 | - | } | |
| 291 | - | ||
| 292 | - | var humanReadableImages []components.HumanReadableImage | |
| 293 | - | for _, img := range images { | |
| 294 | - | shortID := img.ID[7:19] | |
| 295 | - | safelyInteractWithIDMap(Update, shortID, img.ID) | |
| 296 | - | createdStr := humanize.TimeAgo(time.Unix(img.Created, 0)) | |
| 297 | - | sizeStr := humanize.BytesToReadableSize(img.Size) | |
| 298 | - | var repository, tag string | |
| 299 | - | repoTag := "<none>:<none>" | |
| 300 | - | if len(img.RepoTags) > 0 { | |
| 301 | - | repoTag = img.RepoTags[0] | |
| 302 | - | parts := strings.Split(repoTag, ":") | |
| 303 | - | if len(parts) == 2 { | |
| 304 | - | repository = parts[0] | |
| 305 | - | tag = parts[1] | |
| 306 | - | } else { | |
| 307 | - | repository = repoTag | |
| 308 | - | tag = "latest" | |
| 309 | - | } | |
| 310 | - | } | |
| 311 | - | humanReadableImages = append(humanReadableImages, components.HumanReadableImage{ | |
| 312 | - | ID: img.ID, | |
| 313 | - | ShortID: shortID, | |
| 314 | - | Created: createdStr, | |
| 315 | - | Size: sizeStr, | |
| 316 | - | Name: repoTag, | |
| 317 | - | RepoDigests: img.RepoDigests, | |
| 318 | - | RepoTags: img.RepoTags, | |
| 319 | - | Repository: repository, | |
| 320 | - | Tag: tag, | |
| 321 | - | }) | |
| 322 | - | } | |
| 323 | - | ||
| 324 | - | // Render the final ImageList component to replace the poller | |
| 325 | - | renderer := render.NewTemplRenderer(a) | |
| 326 | - | component := components.ImageList(humanReadableImages) | |
| 327 | - | logger.Debug("Rendering final ImageList for poller") | |
| 328 | - | return renderer.RenderTempl(c, component) | |
| 329 | - | } | |
| 330 | - | ||
| 331 | - | // ContainerManagerComponent handles the /container-manager route | |
| 332 | - | func ContainerManagerComponent(c echo.Context, a *server.App) error { | |
| 333 | - | logger.Debug("ContainerManagerComponent called", "url", c.Request().URL.String()) | |
| 334 | - | ||
| 335 | - | containers, err := docker.ListRunningContainers() | |
| 336 | - | if err != nil { | |
| 337 | - | logger.Error("Error listing containers", "error", err) | |
| 338 | - | return sendError(c, err) | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | logger.Debug("Containers found", "count", len(containers)) | |
| 342 | - | for i, container := range containers { | |
| 343 | - | logger.Debug("Container details", "index", i, "id", container.ID, "names", container.Names, "state", container.State) | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | // Use the Templ renderer | |
| 347 | - | renderer := render.NewTemplRenderer(a) | |
| 348 | - | component := components.ContainerList(containers, len(containers) == 0) | |
| 349 | - | ||
| 350 | - | return renderer.RenderTempl(c, component) | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // ContainerManagerDelete handles the /container-manager/delete route | |
| 354 | - | func ContainerManagerDelete(c echo.Context, a *server.App) error { | |
| 355 | - | err := docker.RemoveContainer(c.Param("ID")) | |
| 356 | - | if err != nil { | |
| 357 | - | return sendError(c, err) | |
| 358 | - | } | |
| 359 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | // ContainerManagerStop handles the /container-manager/stop route | |
| 363 | - | func ContainerManagerStop(c echo.Context, a *server.App) error { | |
| 364 | - | // Stop the container gracefully with a timeout | |
| 365 | - | stopped, err := docker.StopContainerGracefully(c.Param("ID"), 3*time.Second) | |
| 366 | - | if err != nil { | |
| 367 | - | return sendError(c, err) | |
| 368 | - | } | |
| 369 | - | // if stopped is true, the container was stopped gracefully | |
| 370 | - | if stopped { | |
| 371 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 372 | - | } | |
| 373 | - | // If stopped is false, the container was not stopped gracefully we force stop it | |
| 374 | - | err = docker.StopContainerRagefully(c.Param("ID")) | |
| 375 | - | if err != nil { | |
| 376 | - | return sendError(c, err) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | // ContainerManagerStart handles the /container-manager/start route | |
| 383 | - | func ContainerManagerStart(c echo.Context, a *server.App) error { | |
| 384 | - | err := docker.StartContainer(c.Param("ID")) | |
| 385 | - | if err != nil { | |
| 386 | - | return sendError(c, err) | |
| 387 | - | } | |
| 388 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 389 | - | } | |
D internal/httpserve/handlers/container_transactions.go +0 -292
| @@ -1,292 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 9 | - | "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 10 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 11 | - | "github.com/bnema/gordon/pkg/docker" | |
| 12 | - | "github.com/bnema/gordon/pkg/logger" | |
| 13 | - | "github.com/labstack/echo/v4" | |
| 14 | - | "gopkg.in/yaml.v3" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func generateOrderedYAML(infoMap map[string]interface{}) (string, error) { | |
| 18 | - | orderedKeys := []string{"Name", "Image", "Hostname", "Ports", "Environment", "Labels", "Network", "Volumes", "Restart"} | |
| 19 | - | ||
| 20 | - | var yamlString string | |
| 21 | - | for _, key := range orderedKeys { | |
| 22 | - | value, exists := infoMap[key] | |
| 23 | - | if !exists { | |
| 24 | - | continue | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // Special handling for empty Ports and Volumes maps | |
| 28 | - | if key == "Ports" || key == "Volumes" { | |
| 29 | - | if mapVal, ok := value.(map[string]string); ok && len(mapVal) == 0 { | |
| 30 | - | yamlString += key + ": []\n" // Output as empty list instead of map | |
| 31 | - | continue // Skip normal marshalling for this key | |
| 32 | - | } | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Default marshalling for other keys or non-empty maps | |
| 36 | - | yamlBytes, err := yaml.Marshal(map[string]interface{}{key: value}) | |
| 37 | - | if err != nil { | |
| 38 | - | return "", err | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | yamlString += string(yamlBytes) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | return yamlString, nil | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // ContainerManagerEditGET displays the edit container view | |
| 48 | - | func ContainerManagerEditGET(c echo.Context, a *server.App) error { | |
| 49 | - | containerID := c.Param("ID") | |
| 50 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 51 | - | if err != nil { | |
| 52 | - | return sendError(c, err) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | portMappings := make(map[string]string) | |
| 56 | - | for internalPort, hostBindings := range containerInfo.HostConfig.PortBindings { | |
| 57 | - | if len(hostBindings) > 0 { | |
| 58 | - | hostBinding := hostBindings[0] | |
| 59 | - | portMappings[string(internalPort)] = fmt.Sprintf("%s:%s", hostBinding.HostIP, hostBinding.HostPort) | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | volumeMappings := make(map[string]string) | |
| 64 | - | for _, mount := range containerInfo.Mounts { | |
| 65 | - | volumeMappings[mount.Source] = mount.Destination | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | networkNames := []string{} | |
| 69 | - | for name := range containerInfo.NetworkSettings.Networks { | |
| 70 | - | networkNames = append(networkNames, name) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | infoMap := map[string]interface{}{ | |
| 74 | - | "Name": strings.TrimPrefix(containerInfo.Name, "/"), | |
| 75 | - | "Image": containerInfo.Config.Image, | |
| 76 | - | "CMD": containerInfo.Config.Cmd, | |
| 77 | - | "Entrypoint": containerInfo.Config.Entrypoint, | |
| 78 | - | } | |
| 79 | - | infoMap["Hostname"] = containerInfo.Config.Hostname | |
| 80 | - | infoMap["Ports"] = portMappings | |
| 81 | - | infoMap["Volumes"] = volumeMappings | |
| 82 | - | infoMap["Environment"] = containerInfo.Config.Env | |
| 83 | - | infoMap["Labels"] = containerInfo.Config.Labels | |
| 84 | - | infoMap["Network"] = networkNames | |
| 85 | - | infoMap["Restart"] = containerInfo.HostConfig.RestartPolicy.Name | |
| 86 | - | ||
| 87 | - | // Convert the infoMap to YAML format | |
| 88 | - | yamlString, err := generateOrderedYAML(infoMap) | |
| 89 | - | if err != nil { | |
| 90 | - | return sendError(c, err) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // Use the Templ renderer | |
| 94 | - | renderer := render.NewTemplRenderer(a) | |
| 95 | - | component := components.EditContainer(containerID, yamlString) | |
| 96 | - | ||
| 97 | - | return renderer.RenderTempl(c, component) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | type TransactionStep func() error | |
| 101 | - | type RollbackStep func() error | |
| 102 | - | ||
| 103 | - | type TransactionQueue struct { | |
| 104 | - | NewContainerID string | |
| 105 | - | NewContainerName string | |
| 106 | - | TempContainerName string | |
| 107 | - | OldContainerID string | |
| 108 | - | OldContainerName string | |
| 109 | - | steps []TransactionStep | |
| 110 | - | rollbacks []RollbackStep | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func (tq *TransactionQueue) Add(step TransactionStep, rollback RollbackStep) { | |
| 114 | - | tq.steps = append(tq.steps, step) | |
| 115 | - | tq.rollbacks = append(tq.rollbacks, rollback) | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | func (tq *TransactionQueue) Execute() error { | |
| 119 | - | var transactionErrors []error | |
| 120 | - | var rollbackErrors []error | |
| 121 | - | for i, step := range tq.steps { | |
| 122 | - | err := step() | |
| 123 | - | if err != nil { | |
| 124 | - | logger.Error(fmt.Sprintf("Error occurred at step index %d: %v", i, err)) | |
| 125 | - | transactionErrors = append(transactionErrors, err) | |
| 126 | - | for j := i - 1; j >= 0; j-- { | |
| 127 | - | rollback := tq.rollbacks[j] | |
| 128 | - | if rollback != nil { | |
| 129 | - | if rollbackErr := rollback(); rollbackErr != nil { | |
| 130 | - | logger.Error(fmt.Sprintf("Failed to execute rollback at index %d: %v", j, rollbackErr)) | |
| 131 | - | rollbackErrors = append(rollbackErrors, rollbackErr) | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | return fmt.Errorf("transaction failed: %v; rollback errors: %v", transactionErrors, rollbackErrors) | |
| 136 | - | } | |
| 137 | - | } | |
| 138 | - | return nil | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | // ContainerManagerEditPOST handles the edit container form submission | |
| 142 | - | func ContainerManagerEditPOST(c echo.Context, a *server.App) error { | |
| 143 | - | tq := &TransactionQueue{} | |
| 144 | - | oldContainerID := c.Param("ID") | |
| 145 | - | ||
| 146 | - | oldContainerInfo, err := docker.GetContainerInfo(oldContainerID) | |
| 147 | - | if err != nil { | |
| 148 | - | return sendError(c, fmt.Errorf("getting old container info failed: %w", err)) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | containerConfig := c.FormValue("container_config") | |
| 152 | - | var containerParams cmdparams.YAMLContainerParams | |
| 153 | - | err = yaml.Unmarshal([]byte(containerConfig), &containerParams) | |
| 154 | - | if err != nil { | |
| 155 | - | return sendError(c, fmt.Errorf("YAML unmarshal failed: %w", err)) | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // Use render.FromYAMLStructToCmdParams to convert YAMLContainerParams to ContainerCommandParams | |
| 159 | - | cmdParams, err := cmdparams.FromYAMLStructToCmdParams(containerParams) | |
| 160 | - | if err != nil { | |
| 161 | - | return fmt.Errorf("failed to convert YAML: %w", err) | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | tq.OldContainerID = oldContainerID | |
| 165 | - | tq.OldContainerName = strings.TrimPrefix(oldContainerInfo.Name, "/") | |
| 166 | - | tq.NewContainerName = cmdParams.ContainerName | |
| 167 | - | tq.TempContainerName = fmt.Sprintf("%s-temp", tq.NewContainerName) | |
| 168 | - | ||
| 169 | - | cmdParams.ContainerName = tq.TempContainerName | |
| 170 | - | ||
| 171 | - | // 1. Stop the old container | |
| 172 | - | tq.Add(StopOldContainerStep(tq), StartOldContainerRollback(tq)) | |
| 173 | - | ||
| 174 | - | // 2. Create the new container | |
| 175 | - | tq.Add(CreateNewContainerStep(tq, cmdParams), RemoveNewContainerRollback(tq)) | |
| 176 | - | ||
| 177 | - | // 3. Start the new container | |
| 178 | - | tq.Add(StartNewContainerStep(tq), StopNewContainerRollback(tq)) | |
| 179 | - | ||
| 180 | - | // 4. Rename the new container to the original name | |
| 181 | - | tq.Add(RemoveOldContainerStep(tq), nil) | |
| 182 | - | ||
| 183 | - | // 5. Remove the old container | |
| 184 | - | tq.Add(RenameNewContainerStep(tq), RenameNewContainerRollback(tq)) | |
| 185 | - | ||
| 186 | - | err = tq.Execute() | |
| 187 | - | if err != nil { | |
| 188 | - | return sendError(c, fmt.Errorf("transaction failed: %w", err)) | |
| 189 | - | } | |
| 190 | - | ||
| 191 | - | return c.HTML(200, ActionSuccess(a)) | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | // CreateNewContainerStep creates a new container with the given parameters | |
| 195 | - | func CreateNewContainerStep(tq *TransactionQueue, cmdParams docker.ContainerCommandParams) TransactionStep { | |
| 196 | - | return func() error { | |
| 197 | - | var err error | |
| 198 | - | tq.NewContainerID, err = docker.CreateContainer(cmdParams) | |
| 199 | - | if err != nil { | |
| 200 | - | return fmt.Errorf("creating new container failed: %w", err) | |
| 201 | - | } | |
| 202 | - | return nil | |
| 203 | - | } | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | // StartNewContainerStep starts the new container | |
| 207 | - | func StartNewContainerStep(tq *TransactionQueue) TransactionStep { | |
| 208 | - | return func() error { | |
| 209 | - | err := docker.StartContainer(tq.NewContainerID) | |
| 210 | - | if err != nil { | |
| 211 | - | return fmt.Errorf("starting new container failed: %w", err) | |
| 212 | - | } | |
| 213 | - | return nil | |
| 214 | - | } | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // StopOldContainerStep stops the old container | |
| 218 | - | func StopOldContainerStep(tq *TransactionQueue) TransactionStep { | |
| 219 | - | return func() error { | |
| 220 | - | err := docker.StopContainer(tq.OldContainerID) | |
| 221 | - | if err != nil { | |
| 222 | - | return fmt.Errorf("stopping old container failed: %w", err) | |
| 223 | - | } | |
| 224 | - | return nil | |
| 225 | - | } | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | // RemoveOldContainerStep removes the old container | |
| 229 | - | func RemoveOldContainerStep(tq *TransactionQueue) TransactionStep { | |
| 230 | - | return func() error { | |
| 231 | - | err := docker.RemoveContainer(tq.OldContainerID) | |
| 232 | - | if err != nil { | |
| 233 | - | return fmt.Errorf("removing old container failed: %w", err) | |
| 234 | - | } | |
| 235 | - | return nil | |
| 236 | - | } | |
| 237 | - | } | |
| 238 | - | ||
| 239 | - | // RemoveNewContainerRollback removes the new container if the transaction fails | |
| 240 | - | func RemoveNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 241 | - | return func() error { | |
| 242 | - | err := docker.RemoveContainer(tq.NewContainerID) | |
| 243 | - | if err != nil { | |
| 244 | - | return fmt.Errorf("rollback: removing new container failed: %w", err) | |
| 245 | - | } | |
| 246 | - | return nil | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // StopNewContainerRollback stops the new container if the transaction fails | |
| 251 | - | func StopNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 252 | - | return func() error { | |
| 253 | - | err := docker.StopContainer(tq.NewContainerID) | |
| 254 | - | if err != nil { | |
| 255 | - | return fmt.Errorf("rollback: stopping new container failed: %w", err) | |
| 256 | - | } | |
| 257 | - | return nil | |
| 258 | - | } | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | // StartOldContainerRollback starts the old container if the transaction fails | |
| 262 | - | func StartOldContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 263 | - | return func() error { | |
| 264 | - | err := docker.StartContainer(tq.OldContainerID) | |
| 265 | - | if err != nil { | |
| 266 | - | return fmt.Errorf("rollback: starting old container failed: %w", err) | |
| 267 | - | } | |
| 268 | - | return nil | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // RenameNewContainerStep renames the new temporary container to the original name | |
| 273 | - | func RenameNewContainerStep(tq *TransactionQueue) TransactionStep { | |
| 274 | - | return func() error { | |
| 275 | - | err := docker.RenameContainer(tq.NewContainerID, tq.NewContainerName) | |
| 276 | - | if err != nil { | |
| 277 | - | return fmt.Errorf("renaming new container failed: %w", err) | |
| 278 | - | } | |
| 279 | - | return nil | |
| 280 | - | } | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | // RenameNewContainerRollback renames the new container back to the temporary name if it fails | |
| 284 | - | func RenameNewContainerRollback(tq *TransactionQueue) RollbackStep { | |
| 285 | - | return func() error { | |
| 286 | - | err := docker.RenameContainer(tq.NewContainerID, tq.TempContainerName) | |
| 287 | - | if err != nil { | |
| 288 | - | return fmt.Errorf("rollback: renaming new container back failed: %w", err) | |
| 289 | - | } | |
| 290 | - | return nil | |
| 291 | - | } | |
| 292 | - | } | |
D internal/httpserve/handlers/endpoint_deploy.go +0 -630
| @@ -1,630 +0,0 @@ | |||
| 1 | - | // endpoint_deploy.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "database/sql" | |
| 6 | - | "encoding/json" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "strings" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | authToken "github.com/bnema/gordon/internal/cli/auth" | |
| 16 | - | "github.com/bnema/gordon/internal/common" | |
| 17 | - | "github.com/bnema/gordon/internal/proxy" | |
| 18 | - | "github.com/bnema/gordon/internal/server" | |
| 19 | - | "github.com/bnema/gordon/pkg/docker" | |
| 20 | - | "github.com/bnema/gordon/pkg/logger" | |
| 21 | - | "github.com/labstack/echo/v4" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | // PostDeploy handles the container deployment request | |
| 25 | - | func PostDeploy(c echo.Context, a *server.App) error { | |
| 26 | - | payload, err := validateAndPrepareDeployPayload(c) | |
| 27 | - | if err != nil { | |
| 28 | - | // Debug | |
| 29 | - | logger.Error("Failed to validate and prepare payload", "error", err) | |
| 30 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 31 | - | Success: false, | |
| 32 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 33 | - | StatusCode: http.StatusBadRequest, | |
| 34 | - | }) | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | _, err = saveAndImportImage(c, a, payload) | |
| 38 | - | if err != nil { | |
| 39 | - | // Debug | |
| 40 | - | logger.Error("Failed to save and import image", "error", err) | |
| 41 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 42 | - | Success: false, | |
| 43 | - | Message: fmt.Sprintf("Failed to save or import image: %v", err), | |
| 44 | - | StatusCode: http.StatusInternalServerError, | |
| 45 | - | }) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | containerID, containerName, err := createAndStartContainer(a, payload) | |
| 49 | - | if err != nil { | |
| 50 | - | if strings.Contains(err.Error(), "is already in use") { | |
| 51 | - | logger.Warn("Container already exists", "error", err) | |
| 52 | - | existingContainerID := extractContainerID(err.Error()) | |
| 53 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 54 | - | // Remove the / from the container name | |
| 55 | - | existingContainerName = strings.TrimLeft(existingContainerName, "/") | |
| 56 | - | ||
| 57 | - | if existingContainerID == "" || existingContainerName == "" { | |
| 58 | - | logger.Error("Failed to extract container ID or Name from error message") | |
| 59 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 60 | - | Success: false, | |
| 61 | - | Message: "A container for this deploy already exists but could not be identified.", | |
| 62 | - | StatusCode: http.StatusConflict, | |
| 63 | - | }) | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 67 | - | Success: false, | |
| 68 | - | Message: "A container for this deploy already exists.", | |
| 69 | - | ContainerID: existingContainerID, | |
| 70 | - | ContainerName: existingContainerName, | |
| 71 | - | StatusCode: http.StatusConflict, | |
| 72 | - | }) | |
| 73 | - | } | |
| 74 | - | logger.Error("Failed to create or start container", "error", err) | |
| 75 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 76 | - | Success: false, | |
| 77 | - | Message: fmt.Sprintf("Failed to create or start container: %v", err), | |
| 78 | - | StatusCode: http.StatusInternalServerError, | |
| 79 | - | }) | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Check if we should skip proxy setup | |
| 83 | - | skipProxySetup := false | |
| 84 | - | if payload.SkipProxySetup { | |
| 85 | - | skipProxySetup = true | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | // After successfully deploying a container, add a proxy route if not skipped | |
| 89 | - | if !skipProxySetup { | |
| 90 | - | containerIP := GetContainerIP(a, containerID, containerName) | |
| 91 | - | err = AddProxyRoute(a, containerID, containerIP, payload.Port, payload.TargetDomain) | |
| 92 | - | if err != nil { | |
| 93 | - | logger.Error("Failed to add proxy route", "error", err) | |
| 94 | - | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 95 | - | } | |
| 96 | - | } else { | |
| 97 | - | logger.Info("Skipping proxy setup as requested", "container_id", containerID, "container_name", containerName) | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | // Arrived here means deployment was successful | |
| 101 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 102 | - | Success: true, | |
| 103 | - | Message: "Deployment successful", | |
| 104 | - | Domain: payload.TargetDomain, | |
| 105 | - | ContainerID: containerID, | |
| 106 | - | ContainerName: containerName, | |
| 107 | - | }) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // PostDeployChunked handles the chunked container deployment request | |
| 111 | - | func PostDeployChunked(c echo.Context, a *server.App) error { | |
| 112 | - | return handleChunkedDeploy(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func handleChunkedDeploy(c echo.Context, a *server.App, metadataStr string) error { | |
| 116 | - | var metadata ChunkMetadata | |
| 117 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 118 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 119 | - | Success: false, | |
| 120 | - | Message: "Invalid chunk metadata", | |
| 121 | - | StatusCode: http.StatusBadRequest, | |
| 122 | - | }) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | payload, err := validateAndPrepareDeployPayload(c) | |
| 126 | - | if err != nil { | |
| 127 | - | logger.Error("Failed to validate and prepare payload", "error", err) | |
| 128 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 129 | - | Success: false, | |
| 130 | - | Message: fmt.Sprintf("Invalid payload: %v", err), | |
| 131 | - | StatusCode: http.StatusBadRequest, | |
| 132 | - | }) | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // Auth verification steps | |
| 136 | - | ||
| 137 | - | auth := c.Request().Header.Get("Authorization") | |
| 138 | - | if auth == "" { | |
| 139 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 140 | - | Success: false, | |
| 141 | - | Message: "Authentication required", | |
| 142 | - | StatusCode: http.StatusUnauthorized, | |
| 143 | - | }) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Validate the token through GitHub API | |
| 147 | - | token := strings.TrimPrefix(auth, "Bearer ") | |
| 148 | - | if !authToken.IsValidGitHubToken(token) { | |
| 149 | - | return sendJSONResponse(c, http.StatusUnauthorized, DeployResponse{ | |
| 150 | - | Success: false, | |
| 151 | - | Message: "Invalid or expired token", | |
| 152 | - | StatusCode: http.StatusUnauthorized, | |
| 153 | - | }) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // TODO: Here we should probably check that the github user is the same as the one in DB | |
| 157 | - | ||
| 158 | - | if metadata.TotalChunks == 1 { | |
| 159 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 160 | - | if err != nil { | |
| 161 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 162 | - | Success: false, | |
| 163 | - | Message: "Failed to read chunk data", | |
| 164 | - | StatusCode: http.StatusInternalServerError, | |
| 165 | - | }) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Store the data directly | |
| 169 | - | chunkStore.mu.Lock() | |
| 170 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 171 | - | chunkStore.chunks[metadata.TransferID][0] = chunkData | |
| 172 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 173 | - | chunkStore.mu.Unlock() | |
| 174 | - | ||
| 175 | - | // Process the complete transfer immediately | |
| 176 | - | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // Store chunk data | |
| 180 | - | chunkStore.mu.Lock() | |
| 181 | - | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 182 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 183 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 187 | - | if err != nil { | |
| 188 | - | chunkStore.mu.Unlock() | |
| 189 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 190 | - | Success: false, | |
| 191 | - | Message: "Failed to read chunk data", | |
| 192 | - | StatusCode: http.StatusInternalServerError, | |
| 193 | - | }) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | // Chunks validation steps | |
| 197 | - | // | |
| 198 | - | if len(chunkData) == 0 { | |
| 199 | - | logger.Error("Received empty chunk") | |
| 200 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 201 | - | Success: false, | |
| 202 | - | Message: "Received empty chunk", | |
| 203 | - | StatusCode: http.StatusBadRequest, | |
| 204 | - | }) | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | // Verify chunk size matches metadata | |
| 208 | - | if int64(len(chunkData)) != metadata.ChunkSize { | |
| 209 | - | logger.Error("Chunk size mismatch", | |
| 210 | - | "expected", metadata.ChunkSize, | |
| 211 | - | "received", len(chunkData)) | |
| 212 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 213 | - | Success: false, | |
| 214 | - | Message: fmt.Sprintf("Chunk size mismatch: expected %d, got %d", | |
| 215 | - | metadata.ChunkSize, len(chunkData)), | |
| 216 | - | StatusCode: http.StatusBadRequest, | |
| 217 | - | }) | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | // Store the complete chunk into our store | |
| 221 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 222 | - | chunkStore.mu.Unlock() | |
| 223 | - | ||
| 224 | - | // If this is the last chunk, process the complete transfer | |
| 225 | - | if isTransferComplete(metadata.TransferID) { | |
| 226 | - | return processCompleteChunkedDeployTansfert(c, a, metadata.TransferID) | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // Include domain information in intermediate chunk responses | |
| 230 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 231 | - | Success: true, | |
| 232 | - | Message: fmt.Sprintf("Chunk %d/%d received", metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 233 | - | Domain: payload.TargetDomain, | |
| 234 | - | StatusCode: http.StatusOK, | |
| 235 | - | }) | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transferID string) error { | |
| 239 | - | logger.Info("Starting complete deploy transfer processing", | |
| 240 | - | "transferID", transferID) | |
| 241 | - | ||
| 242 | - | // Get transfer data | |
| 243 | - | metadata, chunks, err := getTransferData(transferID) | |
| 244 | - | if err != nil { | |
| 245 | - | return sendDeployErrorResponse(c, "Failed to get transfer data", err) | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | // Create temporary directory for processing | |
| 249 | - | tmpDir, err := os.MkdirTemp("", "docker-deploy-*") | |
| 250 | - | if err != nil { | |
| 251 | - | return sendDeployErrorResponse(c, "Failed to create temp directory", err) | |
| 252 | - | } | |
| 253 | - | defer os.RemoveAll(tmpDir) | |
| 254 | - | ||
| 255 | - | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 256 | - | if err != nil { | |
| 257 | - | return sendDeployErrorResponse(c, "Failed to create temp file", err) | |
| 258 | - | } | |
| 259 | - | defer tmpFile.Close() | |
| 260 | - | ||
| 261 | - | // Write chunks to file | |
| 262 | - | if err := writeChunksToFile(tmpFile, chunks, metadata.TotalChunks); err != nil { | |
| 263 | - | return sendDeployErrorResponse(c, "Failed to write chunks", err) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | // Get deployment-specific headers | |
| 267 | - | port := normalizePort(c.Request().Header.Get("X-Ports")) | |
| 268 | - | targetDomain := normalizeTargetDomain(c.Request().Header.Get("X-Target-Domain")) | |
| 269 | - | imageName := c.Request().Header.Get("X-Image-Name") | |
| 270 | - | ||
| 271 | - | if port == "" || targetDomain == "" || imageName == "" { | |
| 272 | - | return sendDeployErrorResponse(c, "Missing required deployment parameters", | |
| 273 | - | fmt.Errorf("port: %s, domain: %s, image: %s", port, targetDomain, imageName)) | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | // Extract container name from domain and perform safety checks | |
| 277 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 278 | - | ||
| 279 | - | // Check if the domain has a subdomain | |
| 280 | - | domainParts := strings.Split(cleanDomain, ".") | |
| 281 | - | var containerName string | |
| 282 | - | ||
| 283 | - | if len(domainParts) >= 2 { | |
| 284 | - | // If there's a subdomain (e.g., sub.domain.tld), use it as the container name | |
| 285 | - | containerName = domainParts[0] | |
| 286 | - | } else { | |
| 287 | - | // If there's no subdomain (e.g., domain.tld), use the domain as the container name | |
| 288 | - | containerName = strings.ReplaceAll(cleanDomain, ".", "-") | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | if containerName == "" { | |
| 292 | - | return sendDeployErrorResponse(c, "Invalid target domain", | |
| 293 | - | fmt.Errorf("couldn't extract container name from domain: %s", targetDomain)) | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | // Check for existing container | |
| 297 | - | existingContainerID := docker.GetContainerIDByName(containerName) | |
| 298 | - | if existingContainerID != "" { | |
| 299 | - | logger.Warn("Container already exists", | |
| 300 | - | "name", containerName, | |
| 301 | - | "id", existingContainerID) | |
| 302 | - | return sendDeployErrorResponse(c, "Container already exists", nil) | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | // Import the image | |
| 306 | - | logger.Info("Importing image from file", | |
| 307 | - | "path", tmpFile.Name(), | |
| 308 | - | "imageName", imageName) | |
| 309 | - | ||
| 310 | - | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 311 | - | if err != nil { | |
| 312 | - | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 313 | - | logger.Error("Platform compatibility check failed", | |
| 314 | - | "error", err, | |
| 315 | - | "imageName", imageName) | |
| 316 | - | return sendDeployErrorResponse(c, "Platform compatibility check failed", err) | |
| 317 | - | } | |
| 318 | - | return sendDeployErrorResponse(c, "Failed to import image", err) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | // Prepare deployment payload | |
| 322 | - | deployPayload := &common.DeployPayload{ | |
| 323 | - | Port: port, | |
| 324 | - | TargetDomain: targetDomain, | |
| 325 | - | ImageName: imageName, | |
| 326 | - | ImageID: imageID, | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | // Create and start the container | |
| 330 | - | containerID, containerName, err := createAndStartContainer(a, deployPayload) | |
| 331 | - | if err != nil { | |
| 332 | - | if strings.Contains(err.Error(), "is already in use") { | |
| 333 | - | existingContainerID := extractContainerID(err.Error()) | |
| 334 | - | existingContainerName, _ := docker.GetContainerName(existingContainerID) | |
| 335 | - | existingContainerName = strings.TrimPrefix(existingContainerName, "/") | |
| 336 | - | ||
| 337 | - | return sendDeployErrorResponse(c, "Container already in use", err) | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | return sendDeployErrorResponse(c, "Failed to create or start container", err) | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | // Cleanup transfer data | |
| 344 | - | cleanupTransfer(transferID) | |
| 345 | - | ||
| 346 | - | logger.Info("Deployment completed successfully", | |
| 347 | - | "containerID", containerID, | |
| 348 | - | "containerName", containerName, | |
| 349 | - | "domain", targetDomain) | |
| 350 | - | ||
| 351 | - | // After successfully deploying a container, add a proxy route | |
| 352 | - | containerIP := GetContainerIP(a, containerID, containerName) | |
| 353 | - | err = AddProxyRoute(a, containerID, containerIP, port, targetDomain) | |
| 354 | - | if err != nil { | |
| 355 | - | logger.Error("Failed to add proxy route", "error", err) | |
| 356 | - | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 360 | - | Success: true, | |
| 361 | - | Message: "Deployment successful", | |
| 362 | - | Domain: targetDomain, | |
| 363 | - | ContainerID: containerID, | |
| 364 | - | ContainerName: strings.TrimPrefix(containerName, "/"), | |
| 365 | - | }) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | func sendDeployErrorResponse(c echo.Context, message string, err error) error { | |
| 369 | - | // The error becomes the message | |
| 370 | - | errorMsg := fmt.Sprintf("%s: %s", message, err.Error()) | |
| 371 | - | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 372 | - | Success: false, | |
| 373 | - | Message: errorMsg, | |
| 374 | - | StatusCode: http.StatusInternalServerError, | |
| 375 | - | }) | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // func waitForDeployment(domain string, containerID string) error { | |
| 379 | - | // client := &http.Client{Timeout: 10 * time.Second} | |
| 380 | - | // maxRetries := 10 | |
| 381 | - | // retryInterval := time.Second | |
| 382 | - | ||
| 383 | - | // var shortContainerID string | |
| 384 | - | // if containerID != "" { | |
| 385 | - | // shortContainerID = containerID[:12] | |
| 386 | - | // } | |
| 387 | - | ||
| 388 | - | // log.Info("Waiting for deployment to be reachable", | |
| 389 | - | // "domain", domain, | |
| 390 | - | // "container_id", shortContainerID) | |
| 391 | - | ||
| 392 | - | // for i := 0; i < maxRetries; i++ { | |
| 393 | - | // resp, err := client.Get(domain) | |
| 394 | - | // if err == nil { | |
| 395 | - | // defer resp.Body.Close() | |
| 396 | - | // if resp.StatusCode == http.StatusOK { | |
| 397 | - | // log.Info("Deployment successful", | |
| 398 | - | // "domain", domain, | |
| 399 | - | // "container_id", shortContainerID) | |
| 400 | - | // return nil | |
| 401 | - | // } | |
| 402 | - | // body, _ := io.ReadAll(resp.Body) | |
| 403 | - | // if strings.Contains(string(body), "failed to create container:") { | |
| 404 | - | // return fmt.Errorf("deployment failed: %s", string(body)) | |
| 405 | - | // } | |
| 406 | - | // } | |
| 407 | - | // log.Warn("Deployment not ready yet, retrying", | |
| 408 | - | // "attempt", fmt.Sprintf("%d/%d", i+1, maxRetries)) | |
| 409 | - | // time.Sleep(retryInterval) | |
| 410 | - | // } | |
| 411 | - | ||
| 412 | - | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 413 | - | // } | |
| 414 | - | ||
| 415 | - | // After successfully deploying a container, add a proxy route | |
| 416 | - | func AddProxyRoute(a *server.App, containerID, containerIP, containerPort, targetDomain string) error { | |
| 417 | - | // Create a new proxy instance | |
| 418 | - | p, err := proxy.NewProxy(a) | |
| 419 | - | if err != nil { | |
| 420 | - | return fmt.Errorf("failed to create proxy: %w", err) | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // Double check that we have a valid container IP - critical for recreated containers | |
| 424 | - | if containerIP == "" || containerIP == containerID || containerIP == "localhost" { | |
| 425 | - | logger.Warn("Invalid container IP detected, attempting to get correct IP", | |
| 426 | - | "containerID", containerID, | |
| 427 | - | "invalid_ip", containerIP) | |
| 428 | - | ||
| 429 | - | // Try to get the container name for better logging | |
| 430 | - | containerName, err := docker.GetContainerName(containerID) | |
| 431 | - | if err != nil { | |
| 432 | - | containerName = containerID | |
| 433 | - | } else { | |
| 434 | - | containerName = strings.TrimPrefix(containerName, "/") | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // Get the correct container IP with retry logic | |
| 438 | - | newContainerIP := GetContainerIP(a, containerID, containerName) | |
| 439 | - | if newContainerIP != containerIP && newContainerIP != containerName { | |
| 440 | - | logger.Info("Updated container IP for proxy route", | |
| 441 | - | "containerID", containerID, | |
| 442 | - | "old_ip", containerIP, | |
| 443 | - | "new_ip", newContainerIP) | |
| 444 | - | containerIP = newContainerIP | |
| 445 | - | } | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | // Get container info to check labels | |
| 449 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 450 | - | if err != nil { | |
| 451 | - | logger.Warn("Failed to get container info", "error", err) | |
| 452 | - | } else { | |
| 453 | - | // Check if the container has a gordon.proxy.port label that might override containerPort | |
| 454 | - | if portLabel, exists := containerInfo.Config.Labels["gordon.proxy.port"]; exists && portLabel != "" { | |
| 455 | - | if portLabel != containerPort { | |
| 456 | - | logger.Info("Using container label port instead of provided port", | |
| 457 | - | "label_port", portLabel, | |
| 458 | - | "provided_port", containerPort) | |
| 459 | - | containerPort = portLabel | |
| 460 | - | } | |
| 461 | - | } | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | // Extract the protocol from the target domain | |
| 465 | - | protocol := "http" | |
| 466 | - | ||
| 467 | - | // Check if the target domain specifies HTTPS | |
| 468 | - | if strings.HasPrefix(strings.ToLower(targetDomain), "https://") { | |
| 469 | - | protocol = "https" | |
| 470 | - | logger.Debug("Using HTTPS protocol for proxy route based on target domain", | |
| 471 | - | "domain", targetDomain, | |
| 472 | - | "protocol", protocol) | |
| 473 | - | } else { | |
| 474 | - | // If target domain doesn't specify protocol, check the container labels | |
| 475 | - | if err == nil { // Only check if we have containerInfo | |
| 476 | - | // Check if gordon.proxy.ssl label is set to true | |
| 477 | - | if sslValue, exists := containerInfo.Config.Labels["gordon.proxy.ssl"]; exists && | |
| 478 | - | (sslValue == "true" || sslValue == "1" || sslValue == "yes") { | |
| 479 | - | protocol = "https" | |
| 480 | - | logger.Debug("Using HTTPS protocol for proxy route based on container labels", | |
| 481 | - | "domain", targetDomain, | |
| 482 | - | "containerID", containerID, | |
| 483 | - | "label_value", sslValue, | |
| 484 | - | "protocol", protocol) | |
| 485 | - | } | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | // Clean domain if it includes protocol | |
| 490 | - | cleanDomain := targetDomain | |
| 491 | - | if strings.Contains(cleanDomain, "://") { | |
| 492 | - | cleanDomain = strings.Split(cleanDomain, "://")[1] | |
| 493 | - | } | |
| 494 | - | ||
| 495 | - | // Remove any leading dot that might have been added | |
| 496 | - | cleanDomain = strings.TrimPrefix(cleanDomain, ".") | |
| 497 | - | ||
| 498 | - | // Log that we're adding the domain for Let's Encrypt | |
| 499 | - | logger.Debug("Adding domain for Let's Encrypt verification", | |
| 500 | - | "domain", cleanDomain) | |
| 501 | - | ||
| 502 | - | // Get container name for better logging | |
| 503 | - | containerName, err := docker.GetContainerName(containerID) | |
| 504 | - | if err != nil { | |
| 505 | - | logger.Warn("Unable to get container name", "containerID", containerID, "error", err) | |
| 506 | - | } else { | |
| 507 | - | containerName = strings.TrimPrefix(containerName, "/") | |
| 508 | - | logger.Debug("Container name for proxy route", "name", containerName) | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | // Check the database for any existing routes with this domain, regardless of container ID | |
| 512 | - | // This handles the case where a container is recreated with the same name but a different ID | |
| 513 | - | tx, err := a.GetDB().Begin() | |
| 514 | - | if err != nil { | |
| 515 | - | return fmt.Errorf("failed to begin transaction: %w", err) | |
| 516 | - | } | |
| 517 | - | defer tx.Rollback() | |
| 518 | - | ||
| 519 | - | var existingRouteID string | |
| 520 | - | var existingContainerID string | |
| 521 | - | err = tx.QueryRow(` | |
| 522 | - | SELECT pr.id, pr.container_id | |
| 523 | - | FROM proxy_route pr | |
| 524 | - | JOIN domain d ON pr.domain_id = d.id | |
| 525 | - | WHERE d.name = ?`, cleanDomain).Scan(&existingRouteID, &existingContainerID) | |
| 526 | - | ||
| 527 | - | if err == nil { | |
| 528 | - | // Domain exists in database, check if this is a recreated container | |
| 529 | - | logger.Info("Found existing proxy route for domain", | |
| 530 | - | "domain", cleanDomain, | |
| 531 | - | "old_container_id", existingContainerID, | |
| 532 | - | "new_container_id", containerID) | |
| 533 | - | ||
| 534 | - | // Verify container IP one last time before updating the route | |
| 535 | - | containerNetworkInfo, err := docker.GetContainerInfo(containerID) | |
| 536 | - | if err == nil && containerNetworkInfo.NetworkSettings != nil { | |
| 537 | - | // Check the container's network settings for the configured network | |
| 538 | - | networkName := a.Config.ContainerEngine.Network | |
| 539 | - | if networkSettings, exists := containerNetworkInfo.NetworkSettings.Networks[networkName]; exists && | |
| 540 | - | networkSettings.IPAddress != "" && networkSettings.IPAddress != containerIP { | |
| 541 | - | logger.Info("Updating container IP before database update", | |
| 542 | - | "domain", cleanDomain, | |
| 543 | - | "old_ip", containerIP, | |
| 544 | - | "new_ip", networkSettings.IPAddress) | |
| 545 | - | containerIP = networkSettings.IPAddress | |
| 546 | - | } | |
| 547 | - | } | |
| 548 | - | ||
| 549 | - | // If the container IDs are different, this is likely a recreated container | |
| 550 | - | // Update the route directly in the database | |
| 551 | - | now := time.Now().Format(time.RFC3339) | |
| 552 | - | _, err = tx.Exec(` | |
| 553 | - | UPDATE proxy_route | |
| 554 | - | SET container_id = ?, container_ip = ?, container_port = ?, updated_at = ? | |
| 555 | - | WHERE id = ?`, | |
| 556 | - | containerID, containerIP, containerPort, now, existingRouteID) | |
| 557 | - | ||
| 558 | - | if err != nil { | |
| 559 | - | return fmt.Errorf("failed to update proxy route: %w", err) | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | logger.Info("Updated proxy route for recreated container", | |
| 563 | - | "domain", cleanDomain, | |
| 564 | - | "old_container_id", existingContainerID, | |
| 565 | - | "new_container_id", containerID, | |
| 566 | - | "container_ip", containerIP, | |
| 567 | - | "container_port", containerPort) | |
| 568 | - | ||
| 569 | - | // Commit transaction | |
| 570 | - | if err := tx.Commit(); err != nil { | |
| 571 | - | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 572 | - | } | |
| 573 | - | ||
| 574 | - | // Verify the database update with a direct query | |
| 575 | - | var updatedContainerIP string | |
| 576 | - | err = a.GetDB().QueryRow(p.Queries.GetRouteIPByID, existingRouteID).Scan(&updatedContainerIP) | |
| 577 | - | ||
| 578 | - | if err == nil && updatedContainerIP != containerIP { | |
| 579 | - | logger.Warn("Database verification failed: container IP mismatch", | |
| 580 | - | "expected", containerIP, | |
| 581 | - | "actual", updatedContainerIP) | |
| 582 | - | } else { | |
| 583 | - | logger.Debug("Database verification passed: container IP updated correctly", | |
| 584 | - | "container_ip", containerIP) | |
| 585 | - | } | |
| 586 | - | ||
| 587 | - | // Reload the routes | |
| 588 | - | if err := p.Reload(); err != nil { | |
| 589 | - | return fmt.Errorf("failed to reload routes: %w", err) | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | // Final verification: check that the route was loaded with the correct IP | |
| 593 | - | routes := p.GetRoutes() | |
| 594 | - | if route, exists := routes[cleanDomain]; exists { | |
| 595 | - | if route.ContainerIP != containerIP { | |
| 596 | - | logger.Warn("Route reload verification failed: container IP mismatch", | |
| 597 | - | "domain", cleanDomain, | |
| 598 | - | "expected", containerIP, | |
| 599 | - | "actual", route.ContainerIP) | |
| 600 | - | } else { | |
| 601 | - | logger.Debug("Route reload verification passed: container IP loaded correctly", | |
| 602 | - | "domain", cleanDomain, | |
| 603 | - | "container_ip", containerIP) | |
| 604 | - | } | |
| 605 | - | } | |
| 606 | - | ||
| 607 | - | return nil | |
| 608 | - | } else if err != sql.ErrNoRows { | |
| 609 | - | // A database error other than "not found" | |
| 610 | - | return fmt.Errorf("failed to query database for existing route: %w", err) | |
| 611 | - | } | |
| 612 | - | ||
| 613 | - | // Route doesn't exist or we had ErrNoRows, continue with normal AddRoute | |
| 614 | - | // Add the route - this will also add to the domain database for Let's Encrypt | |
| 615 | - | if err := p.AddRoute(cleanDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | |
| 616 | - | return fmt.Errorf("failed to add proxy route: %w", err) | |
| 617 | - | } | |
| 618 | - | ||
| 619 | - | // Register this container to prevent immediate recreation logic | |
| 620 | - | // and avoid database locking conflicts | |
| 621 | - | p.RegisterNewlyCreatedContainer(containerID) | |
| 622 | - | ||
| 623 | - | logger.Debug("Added proxy route", | |
| 624 | - | "domain", targetDomain, | |
| 625 | - | "containerIP", containerIP, | |
| 626 | - | "containerPort", containerPort, | |
| 627 | - | "protocol", protocol, | |
| 628 | - | ) | |
| 629 | - | return nil | |
| 630 | - | } | |
D internal/httpserve/handlers/endpoint_deploy_check.go +0 -75
| @@ -1,75 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | "github.com/bnema/gordon/pkg/docker" | |
| 10 | - | "github.com/charmbracelet/log" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | func CheckDeployConflict(c echo.Context, a *server.App) error { | |
| 15 | - | targetDomain := c.QueryParam("domain") | |
| 16 | - | if targetDomain == "" { | |
| 17 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 18 | - | Success: false, | |
| 19 | - | Message: "Target domain is required", | |
| 20 | - | StatusCode: http.StatusBadRequest, | |
| 21 | - | }) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // Extract container name from the target domain | |
| 25 | - | cleanDomain := strings.TrimPrefix(strings.TrimPrefix(targetDomain, "https://"), "http://") | |
| 26 | - | containerName := strings.Split(cleanDomain, ".")[0] | |
| 27 | - | ||
| 28 | - | if containerName == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, DeployResponse{ | |
| 30 | - | Success: false, | |
| 31 | - | Message: "Invalid target domain format", | |
| 32 | - | StatusCode: http.StatusBadRequest, | |
| 33 | - | }) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // Check if container exists | |
| 37 | - | containerID := docker.GetContainerIDByName(containerName) | |
| 38 | - | if containerID == "" { | |
| 39 | - | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 40 | - | Success: true, | |
| 41 | - | Message: "No conflicts found", | |
| 42 | - | StatusCode: http.StatusOK, | |
| 43 | - | }) | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // Get container details | |
| 47 | - | state, err := docker.GetContainerState(containerID) | |
| 48 | - | if err != nil { | |
| 49 | - | log.Error("Failed to get container state", "error", err) | |
| 50 | - | state = "unknown" | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | ports, err := docker.GetContainerPorts(containerID) | |
| 54 | - | if err != nil { | |
| 55 | - | log.Error("Failed to get container ports", "error", err) | |
| 56 | - | ports = "unknown" | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | uptime, err := docker.GetContainerUptime(containerID) | |
| 60 | - | if err != nil { | |
| 61 | - | log.Error("Failed to get container uptime", "error", err) | |
| 62 | - | uptime = "unknown" | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | return sendJSONResponse(c, http.StatusConflict, DeployResponse{ | |
| 66 | - | Success: false, | |
| 67 | - | Message: fmt.Sprintf("Container with name '%s' already exists", containerName), | |
| 68 | - | ContainerID: containerID, | |
| 69 | - | ContainerName: containerName, | |
| 70 | - | State: state, | |
| 71 | - | RunningTime: uptime, | |
| 72 | - | Ports: ports, | |
| 73 | - | StatusCode: http.StatusConflict, | |
| 74 | - | }) | |
| 75 | - | } | |
D internal/httpserve/handlers/endpoint_helpers.go +0 -560
| @@ -1,560 +0,0 @@ | |||
| 1 | - | // endpoint_helpers.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "bytes" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "regexp" | |
| 12 | - | "strings" | |
| 13 | - | "sync" | |
| 14 | - | "time" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/internal/common" | |
| 17 | - | "github.com/bnema/gordon/internal/server" | |
| 18 | - | "github.com/bnema/gordon/internal/templating/cmdparams" | |
| 19 | - | "github.com/bnema/gordon/pkg/docker" | |
| 20 | - | "github.com/bnema/gordon/pkg/filestore" | |
| 21 | - | "github.com/bnema/gordon/pkg/logger" | |
| 22 | - | "github.com/labstack/echo/v4" | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | type ChunkMetadata common.ChunkMetadata | |
| 26 | - | ||
| 27 | - | type ChunkStore struct { | |
| 28 | - | mu sync.Mutex | |
| 29 | - | chunks map[string]map[int][]byte | |
| 30 | - | metadata map[string]*ChunkMetadata | |
| 31 | - | started map[string]time.Time | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | type InfoResponse struct { | |
| 35 | - | Uptime string `json:"uptime"` | |
| 36 | - | Version string `json:"version"` | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | type DeployResponse = common.DeployResponse | |
| 40 | - | type PushResponse = common.PushResponse | |
| 41 | - | type StopResponse = common.StopResponse | |
| 42 | - | type RemoveResponse = common.RemoveResponse | |
| 43 | - | ||
| 44 | - | var chunkStore = &ChunkStore{ | |
| 45 | - | chunks: make(map[string]map[int][]byte), | |
| 46 | - | metadata: make(map[string]*ChunkMetadata), | |
| 47 | - | started: make(map[string]time.Time), | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | const ( | |
| 51 | - | cleanupInterval = 5 * time.Minute | |
| 52 | - | transferTimeout = 30 * time.Minute | |
| 53 | - | ) | |
| 54 | - | ||
| 55 | - | func init() { | |
| 56 | - | // Start a cleanup goroutine | |
| 57 | - | go func() { | |
| 58 | - | ticker := time.NewTicker(5 * time.Minute) | |
| 59 | - | for range ticker.C { | |
| 60 | - | cleanupOldTransfers() | |
| 61 | - | } | |
| 62 | - | }() | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Populate fills the InfoResponse struct with data from the App | |
| 66 | - | func (info *InfoResponse) Populate(a *server.App) { | |
| 67 | - | info.Uptime = a.GetUptime() | |
| 68 | - | info.Version = a.GetVersionstring() | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | func sendJSONResponse(c echo.Context, statusCode int, response interface{}) error { | |
| 72 | - | err := c.JSON(statusCode, response) | |
| 73 | - | if err != nil { | |
| 74 | - | logger.Error("Failed to send JSON response", | |
| 75 | - | "error", err, | |
| 76 | - | "statusCode", statusCode) | |
| 77 | - | } | |
| 78 | - | return err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // GetInfos handles GET requests on /api/ping endpoint | |
| 82 | - | func GetInfos(c echo.Context, a *server.App) error { | |
| 83 | - | payload := new(common.RequestPayload) | |
| 84 | - | if err := c.Bind(payload); err != nil { | |
| 85 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 86 | - | } | |
| 87 | - | ||
| 88 | - | if payload.Type != "ping" { | |
| 89 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | pingPayload, ok := payload.Payload.(common.PingPayload) | |
| 93 | - | if !ok { | |
| 94 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | if pingPayload.Message != "ping" { | |
| 98 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid ping message") | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | info := &InfoResponse{} | |
| 102 | - | info.Populate(a) | |
| 103 | - | ||
| 104 | - | return sendJSONResponse(c, http.StatusOK, info) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // validateAndPrepareDeployPayload validates and prepares the deploy payload | |
| 108 | - | func validateAndPrepareDeployPayload(c echo.Context) (*common.DeployPayload, error) { | |
| 109 | - | payload := &common.DeployPayload{ | |
| 110 | - | Port: c.Request().Header.Get("X-Ports"), | |
| 111 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 112 | - | TargetDomain: c.Request().Header.Get("X-Target-Domain"), | |
| 113 | - | SkipProxySetup: c.Request().Header.Get("X-Skip-Proxy-Setup") == "true", | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | if payload.ImageName == "" { | |
| 117 | - | return nil, errors.New("invalid image name") | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | payload.Port = normalizePort(payload.Port) | |
| 121 | - | payload.TargetDomain = normalizeTargetDomain(payload.TargetDomain) | |
| 122 | - | ||
| 123 | - | return payload, nil | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // ssaveAndImportImage saves and imports an image from the request body | |
| 127 | - | func saveAndImportImage(c echo.Context, a *server.App, payload interface{}) (string, error) { | |
| 128 | - | var imageName, imageID string | |
| 129 | - | ||
| 130 | - | // Determine payload type and extract image name | |
| 131 | - | switch p := payload.(type) { | |
| 132 | - | case *common.PushPayload: | |
| 133 | - | imageName = p.ImageName | |
| 134 | - | defer func() { p.ImageID = imageID }() | |
| 135 | - | case *common.DeployPayload: | |
| 136 | - | imageName = p.ImageName | |
| 137 | - | defer func() { p.ImageID = imageID }() | |
| 138 | - | default: | |
| 139 | - | return "", fmt.Errorf("unsupported payload type: %T", payload) | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | imageReader := c.Request().Body | |
| 143 | - | defer imageReader.Close() | |
| 144 | - | ||
| 145 | - | imageFileName := sanitizeImageFileName(imageName) | |
| 146 | - | ||
| 147 | - | imagePath, err := filestore.SaveImageToStorage(&a.Config, imageFileName, imageReader) | |
| 148 | - | if err != nil { | |
| 149 | - | return "", fmt.Errorf("failed to save image: %v", err) | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | imageID, err = docker.ImportImageToEngine(imagePath) | |
| 153 | - | if err != nil { | |
| 154 | - | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | |
| 155 | - | logger.Error("Failed to remove temporary image file after import failure", | |
| 156 | - | "error", removeErr, | |
| 157 | - | "path", imagePath) | |
| 158 | - | } | |
| 159 | - | return "", fmt.Errorf("failed to import image: %v", err) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | if imageID == "" { | |
| 163 | - | if removeErr := filestore.RemoveFromStorage(imagePath); removeErr != nil { | |
| 164 | - | logger.Error("Failed to remove temporary image file after empty ID", | |
| 165 | - | "error", removeErr, | |
| 166 | - | "path", imagePath) | |
| 167 | - | } | |
| 168 | - | return "", errors.New("imported image ID is empty") | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | err = filestore.RemoveFromStorage(imagePath) | |
| 172 | - | if err != nil { | |
| 173 | - | logger.Error("Failed to remove temporary image file after successful import", | |
| 174 | - | "error", err, | |
| 175 | - | "path", imagePath) | |
| 176 | - | return "", fmt.Errorf("failed to remove temporary image file: %v", err) | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | return imagePath, nil | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | // createAndStartContainer creates a container from the image, starts it and returns the container ID | |
| 183 | - | func createAndStartContainer(a *server.App, payload *common.DeployPayload) (string, string, error) { | |
| 184 | - | logger.Info("Starting container creation process", | |
| 185 | - | "imageName", payload.ImageName, | |
| 186 | - | "imageID", payload.ImageID, | |
| 187 | - | "port", payload.Port) | |
| 188 | - | ||
| 189 | - | params, err := cmdparams.FromPayloadStructToCmdParams(payload, a, payload.ImageID) | |
| 190 | - | if err != nil { | |
| 191 | - | return "", "", fmt.Errorf("failed to create command parameters: %v", err) | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | logger.Debug("Container parameters prepared", "params", params) | |
| 195 | - | ||
| 196 | - | containerID, err := docker.CreateContainer(params) | |
| 197 | - | if err != nil { | |
| 198 | - | return "", "", fmt.Errorf("failed to create container: %v", err) | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | logger.Info("Container created successfully", "containerID", containerID) | |
| 202 | - | ||
| 203 | - | containerName, err := docker.GetContainerName(containerID) | |
| 204 | - | if err != nil { | |
| 205 | - | return "", "", fmt.Errorf("failed to get container name: %v", err) | |
| 206 | - | } | |
| 207 | - | ||
| 208 | - | logger.Info("Starting container", | |
| 209 | - | "containerID", containerID, | |
| 210 | - | "name", containerName) | |
| 211 | - | ||
| 212 | - | // Use regular StartContainer instead of StartContainerWithContext | |
| 213 | - | err = docker.StartContainer(containerID) | |
| 214 | - | if err != nil { | |
| 215 | - | // Get container logs if start failed | |
| 216 | - | logs, logErr := docker.GetContainerLogs(containerID) | |
| 217 | - | if logErr == nil { | |
| 218 | - | logger.Error("Container start failed - container logs", | |
| 219 | - | "logs", logs, | |
| 220 | - | "containerID", containerID) | |
| 221 | - | } | |
| 222 | - | ||
| 223 | - | // Cleanup the failed container | |
| 224 | - | cleanupErr := docker.RemoveContainer(containerID) | |
| 225 | - | if cleanupErr != nil { | |
| 226 | - | logger.Error("Failed to cleanup container after start failure", | |
| 227 | - | "containerID", containerID, | |
| 228 | - | "error", cleanupErr) | |
| 229 | - | } | |
| 230 | - | return "", "", fmt.Errorf("failed to start container: %v", err) | |
| 231 | - | } | |
| 232 | - | ||
| 233 | - | logger.Info("Container started successfully", | |
| 234 | - | "containerID", containerID, | |
| 235 | - | "name", containerName) | |
| 236 | - | ||
| 237 | - | return containerID, containerName, nil | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // Helpers for sanitizing and normalizing input | |
| 241 | - | func sanitizeImageFileName(imageName string) string { | |
| 242 | - | imageName = regexp.MustCompile(`^([a-zA-Z0-9\-_.]+\/)?`).ReplaceAllString(imageName, "") | |
| 243 | - | imageName = regexp.MustCompile(`(:[a-zA-Z0-9\-_.]+)?$`).ReplaceAllString(imageName, "") | |
| 244 | - | return imageName + ".tar" | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | // normalizePort ensures that the port is in the format of "port/protocol" | |
| 248 | - | func normalizePort(port string) string { | |
| 249 | - | if !regexp.MustCompile(`\/(tcp|udp)$`).MatchString(port) { | |
| 250 | - | return port + "/tcp" | |
| 251 | - | } | |
| 252 | - | return port | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // normalizeTargetDomain ensures that the target domain is in the format of "https://domain" or "http://domain" | |
| 256 | - | func normalizeTargetDomain(domain string) string { | |
| 257 | - | // First, remove any leading dot from the domain | |
| 258 | - | domain = strings.TrimPrefix(domain, ".") | |
| 259 | - | ||
| 260 | - | // If domain contains a dot prefix in the middle (like "example.com/.domain.tld"), fix it | |
| 261 | - | domain = strings.ReplaceAll(domain, "/.", "/") | |
| 262 | - | ||
| 263 | - | // Then check if we need to add the protocol | |
| 264 | - | if !regexp.MustCompile(`^https?:\/\/`).MatchString(domain) { | |
| 265 | - | return "https://" + domain | |
| 266 | - | } | |
| 267 | - | return domain | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | // generateShortID generates a short ID from the full image ID | |
| 271 | - | func generateShortID(imageID string) string { | |
| 272 | - | shortID := imageID[7:] | |
| 273 | - | if len(shortID) > 12 { | |
| 274 | - | shortID = shortID[:12] | |
| 275 | - | } | |
| 276 | - | return shortID | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // generateCreateContainerURL generates the URL for creating a container | |
| 280 | - | func generateCreateContainerURL(a *server.App, shortID string) string { | |
| 281 | - | return fmt.Sprintf("%s://%s%s/cc/%s", | |
| 282 | - | a.Config.Http.Protocol(), | |
| 283 | - | a.Config.Http.FullDomain(), | |
| 284 | - | a.Config.Admin.Path, | |
| 285 | - | shortID) | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | // generateContainerLogsURL generates the URL for viewing container logs | |
| 289 | - | func isTransferComplete(transferID string) bool { | |
| 290 | - | chunkStore.mu.Lock() | |
| 291 | - | defer chunkStore.mu.Unlock() | |
| 292 | - | ||
| 293 | - | metadata := chunkStore.metadata[transferID] | |
| 294 | - | if metadata == nil { | |
| 295 | - | logger.Warn("No metadata found for transfer", "transferID", transferID) | |
| 296 | - | return false | |
| 297 | - | } | |
| 298 | - | ||
| 299 | - | chunksReceived := len(chunkStore.chunks[transferID]) | |
| 300 | - | isComplete := chunksReceived == metadata.TotalChunks | |
| 301 | - | ||
| 302 | - | logger.Debug("Transfer completion check", | |
| 303 | - | "transferID", transferID, | |
| 304 | - | "chunksReceived", chunksReceived, | |
| 305 | - | "totalExpected", metadata.TotalChunks, | |
| 306 | - | "isComplete", isComplete) | |
| 307 | - | ||
| 308 | - | return isComplete | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | // Cleanup helpers for old transfers | |
| 312 | - | func cleanupTransfer(transferID string) { | |
| 313 | - | chunkStore.mu.Lock() | |
| 314 | - | defer chunkStore.mu.Unlock() | |
| 315 | - | ||
| 316 | - | delete(chunkStore.chunks, transferID) | |
| 317 | - | delete(chunkStore.metadata, transferID) | |
| 318 | - | delete(chunkStore.started, transferID) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | // cleanupOldTransfers periodically checks for and cleans up old transfers | |
| 322 | - | func cleanupOldTransfers() { | |
| 323 | - | ticker := time.NewTicker(cleanupInterval) | |
| 324 | - | defer ticker.Stop() // Good practice to clean up the ticker | |
| 325 | - | ||
| 326 | - | for range ticker.C { | |
| 327 | - | now := time.Now() | |
| 328 | - | chunkStore.mu.Lock() | |
| 329 | - | for id, startTime := range chunkStore.started { | |
| 330 | - | if now.Sub(startTime) > transferTimeout { | |
| 331 | - | logger.Debug("Cleaning up stale transfer", | |
| 332 | - | "transferID", id, | |
| 333 | - | "age", now.Sub(startTime)) | |
| 334 | - | cleanupTransfer(id) | |
| 335 | - | } | |
| 336 | - | } | |
| 337 | - | chunkStore.mu.Unlock() | |
| 338 | - | } | |
| 339 | - | } | |
| 340 | - | ||
| 341 | - | // extractContainerID extracts the container ID from an error message | |
| 342 | - | func extractContainerID(errorMessage string) string { | |
| 343 | - | re := regexp.MustCompile(`by\s+([0-9a-f]+)\.`) | |
| 344 | - | match := re.FindStringSubmatch(errorMessage) | |
| 345 | - | if len(match) > 1 { | |
| 346 | - | return match[1] | |
| 347 | - | } | |
| 348 | - | return "" | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | // storeIDMapping stores a mapping between a short ID and a full ID | |
| 352 | - | func storeIDMapping(shortID, fullID string) { | |
| 353 | - | safelyInteractWithIDMap(Update, shortID, fullID) | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | func writeChunksToFile(file *os.File, chunks map[int][]byte, totalChunks int) error { | |
| 357 | - | // Validate input parameters | |
| 358 | - | if file == nil { | |
| 359 | - | return errors.New("nil file handle provided") | |
| 360 | - | } | |
| 361 | - | if len(chunks) == 0 { | |
| 362 | - | return errors.New("no chunks provided") | |
| 363 | - | } | |
| 364 | - | if totalChunks <= 0 { | |
| 365 | - | return fmt.Errorf("invalid total chunks count: %d", totalChunks) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | // Verify we have all chunks before starting | |
| 369 | - | if len(chunks) != totalChunks { | |
| 370 | - | return fmt.Errorf("chunk count mismatch: got %d, expected %d", len(chunks), totalChunks) | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | // Calculate total expected size | |
| 374 | - | var totalSize int64 | |
| 375 | - | for _, chunk := range chunks { | |
| 376 | - | totalSize += int64(len(chunk)) | |
| 377 | - | } | |
| 378 | - | ||
| 379 | - | logger.Debug("Starting chunk assembly", | |
| 380 | - | "totalChunks", totalChunks, | |
| 381 | - | "totalSize", fmt.Sprintf("%.2f MB", float64(totalSize)/(1024*1024))) | |
| 382 | - | ||
| 383 | - | // First, seek to the beginning of the file | |
| 384 | - | if _, err := file.Seek(0, 0); err != nil { | |
| 385 | - | return fmt.Errorf("failed to seek to beginning: %w", err) | |
| 386 | - | } | |
| 387 | - | ||
| 388 | - | // Create a temporary buffer with capacity hint | |
| 389 | - | tempBuffer := bytes.NewBuffer(make([]byte, 0, totalSize)) | |
| 390 | - | ||
| 391 | - | // Write chunks in order with validation | |
| 392 | - | for i := 0; i < totalChunks; i++ { | |
| 393 | - | chunk, exists := chunks[i] | |
| 394 | - | if !exists { | |
| 395 | - | return fmt.Errorf("missing chunk %d", i) | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | if len(chunk) == 0 { | |
| 399 | - | return fmt.Errorf("empty chunk found at index %d", i) | |
| 400 | - | } | |
| 401 | - | ||
| 402 | - | n, err := tempBuffer.Write(chunk) | |
| 403 | - | if err != nil { | |
| 404 | - | return fmt.Errorf("failed to write chunk %d to buffer: %w", i, err) | |
| 405 | - | } | |
| 406 | - | if n != len(chunk) { | |
| 407 | - | return fmt.Errorf("incomplete chunk write: wrote %d of %d bytes for chunk %d", | |
| 408 | - | n, len(chunk), i) | |
| 409 | - | } | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | // Verify buffer size matches expected total | |
| 413 | - | if int64(tempBuffer.Len()) != totalSize { | |
| 414 | - | return fmt.Errorf("buffer size mismatch: got %d, expected %d", | |
| 415 | - | tempBuffer.Len(), totalSize) | |
| 416 | - | } | |
| 417 | - | ||
| 418 | - | // Write the complete buffer to file | |
| 419 | - | written, err := io.Copy(file, tempBuffer) | |
| 420 | - | if err != nil { | |
| 421 | - | return fmt.Errorf("failed to write buffer to file: %w", err) | |
| 422 | - | } | |
| 423 | - | if written != totalSize { | |
| 424 | - | return fmt.Errorf("file write size mismatch: wrote %d, expected %d", | |
| 425 | - | written, totalSize) | |
| 426 | - | } | |
| 427 | - | ||
| 428 | - | // Ensure all data is written to disk | |
| 429 | - | if err := file.Sync(); err != nil { | |
| 430 | - | return fmt.Errorf("failed to sync file: %w", err) | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | // Reset file pointer to beginning | |
| 434 | - | if _, err := file.Seek(0, 0); err != nil { | |
| 435 | - | return fmt.Errorf("failed to reset file pointer: %w", err) | |
| 436 | - | } | |
| 437 | - | ||
| 438 | - | logger.Debug("Chunk assembly completed successfully", | |
| 439 | - | "totalBytesWritten", written, | |
| 440 | - | "sizeInMB", fmt.Sprintf("%.2f MB", float64(written)/(1024*1024))) | |
| 441 | - | ||
| 442 | - | return nil | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | func getTransferData(transferID string) (*ChunkMetadata, map[int][]byte, error) { | |
| 446 | - | chunkStore.mu.Lock() | |
| 447 | - | defer chunkStore.mu.Unlock() | |
| 448 | - | ||
| 449 | - | metadata := chunkStore.metadata[transferID] | |
| 450 | - | if metadata == nil { | |
| 451 | - | return nil, nil, fmt.Errorf("transfer metadata not found") | |
| 452 | - | } | |
| 453 | - | return metadata, chunkStore.chunks[transferID], nil | |
| 454 | - | } | |
| 455 | - | ||
| 456 | - | // GetContainerIP gets the IP address of a container within its Docker network | |
| 457 | - | func GetContainerIP(a *server.App, containerID, containerName string) string { | |
| 458 | - | // Add retry logic with exponential backoff to ensure container network is ready | |
| 459 | - | maxRetries := 3 | |
| 460 | - | var containerIP string | |
| 461 | - | var lastErr error | |
| 462 | - | ||
| 463 | - | for retry := 0; retry < maxRetries; retry++ { | |
| 464 | - | // If this is a retry, add an increasing delay | |
| 465 | - | if retry > 0 { | |
| 466 | - | sleepTime := time.Duration(500*retry) * time.Millisecond | |
| 467 | - | logger.Debug("Retrying to get container IP", | |
| 468 | - | "attempt", retry+1, | |
| 469 | - | "containerID", containerID, | |
| 470 | - | "delay", sleepTime) | |
| 471 | - | time.Sleep(sleepTime) | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | // Try to get container info | |
| 475 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 476 | - | if err != nil { | |
| 477 | - | lastErr = err | |
| 478 | - | continue // Try again | |
| 479 | - | } | |
| 480 | - | ||
| 481 | - | // Make sure we have the most up-to-date container info by refreshing if needed | |
| 482 | - | // This is crucial for recreated containers | |
| 483 | - | if containerInfo.State == nil || !containerInfo.State.Running { | |
| 484 | - | // Container might still be starting, wait a bit longer | |
| 485 | - | time.Sleep(500 * time.Millisecond) | |
| 486 | - | refreshedInfo, err := docker.GetContainerInfo(containerID) | |
| 487 | - | if err == nil { | |
| 488 | - | containerInfo = refreshedInfo | |
| 489 | - | logger.Debug("Refreshed container info for IP retrieval", | |
| 490 | - | "containerName", containerName, | |
| 491 | - | "containerID", containerID) | |
| 492 | - | } else { | |
| 493 | - | lastErr = err | |
| 494 | - | continue // Try again | |
| 495 | - | } | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | // Extract IP address from container info | |
| 499 | - | if containerInfo.NetworkSettings != nil { | |
| 500 | - | // Look for the network specified in the config | |
| 501 | - | networkName := a.Config.ContainerEngine.Network | |
| 502 | - | if networkSettings, exists := containerInfo.NetworkSettings.Networks[networkName]; exists && networkSettings.IPAddress != "" { | |
| 503 | - | containerIP = networkSettings.IPAddress | |
| 504 | - | logger.Debug("Found container IP", | |
| 505 | - | "containerName", containerName, | |
| 506 | - | "containerID", containerID, | |
| 507 | - | "containerIP", containerIP, | |
| 508 | - | "network", networkName) | |
| 509 | - | return containerIP | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | // Look for any network if the specified one isn't found | |
| 513 | - | for networkName, networkSettings := range containerInfo.NetworkSettings.Networks { | |
| 514 | - | if networkSettings.IPAddress != "" { | |
| 515 | - | containerIP = networkSettings.IPAddress | |
| 516 | - | logger.Debug("Found container IP in alternative network", | |
| 517 | - | "containerName", containerName, | |
| 518 | - | "containerID", containerID, | |
| 519 | - | "containerIP", containerIP, | |
| 520 | - | "network", networkName) | |
| 521 | - | return containerIP | |
| 522 | - | } | |
| 523 | - | } | |
| 524 | - | } | |
| 525 | - | ||
| 526 | - | // If we've gotten this far, let's try inspecting the network directly | |
| 527 | - | networkName := a.Config.ContainerEngine.Network | |
| 528 | - | networkInfo, err := docker.GetNetworkInfo(networkName) | |
| 529 | - | if err == nil { | |
| 530 | - | // Search for this container in the network | |
| 531 | - | for contID, endpoint := range networkInfo.Containers { | |
| 532 | - | if contID == containerID && endpoint.IPv4Address != "" { | |
| 533 | - | // Extract just the IP address from the CIDR format | |
| 534 | - | ipCIDR := endpoint.IPv4Address | |
| 535 | - | containerIP = strings.Split(ipCIDR, "/")[0] | |
| 536 | - | logger.Debug("Found container IP via network inspection", | |
| 537 | - | "containerName", containerName, | |
| 538 | - | "containerID", containerID, | |
| 539 | - | "containerIP", containerIP, | |
| 540 | - | "network", networkName) | |
| 541 | - | return containerIP | |
| 542 | - | } | |
| 543 | - | } | |
| 544 | - | } | |
| 545 | - | } | |
| 546 | - | ||
| 547 | - | // If we've tried multiple times and couldn't get an IP | |
| 548 | - | if lastErr != nil { | |
| 549 | - | logger.Warn("Failed to get container info after multiple attempts", | |
| 550 | - | "error", lastErr, | |
| 551 | - | "containerID", containerID) | |
| 552 | - | } | |
| 553 | - | ||
| 554 | - | logger.Warn("Could not find container IP, using container name as fallback", | |
| 555 | - | "containerName", containerName, | |
| 556 | - | "containerID", containerID) | |
| 557 | - | ||
| 558 | - | // Use container name as a fallback | |
| 559 | - | return containerName | |
| 560 | - | } | |
D internal/httpserve/handlers/endpoint_push.go +0 -249
| @@ -1,249 +0,0 @@ | |||
| 1 | - | // endpoint_push.go | |
| 2 | - | package handlers | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "encoding/json" | |
| 6 | - | "errors" | |
| 7 | - | "fmt" | |
| 8 | - | "io" | |
| 9 | - | "net/http" | |
| 10 | - | "os" | |
| 11 | - | "path/filepath" | |
| 12 | - | "strings" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/common" | |
| 15 | - | "github.com/bnema/gordon/internal/server" | |
| 16 | - | "github.com/bnema/gordon/pkg/docker" | |
| 17 | - | "github.com/charmbracelet/log" | |
| 18 | - | "github.com/labstack/echo/v4" | |
| 19 | - | ) | |
| 20 | - | ||
| 21 | - | // PostPush handles the image push request | |
| 22 | - | func PostPush(c echo.Context, a *server.App) error { | |
| 23 | - | requestID := c.Response().Header().Get(echo.HeaderXRequestID) | |
| 24 | - | log.Info("PostPush started", | |
| 25 | - | "requestID", requestID, | |
| 26 | - | "path", c.Request().URL.Path) | |
| 27 | - | payload, err := validateAndPreparePushPayload(c) | |
| 28 | - | if err != nil { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 30 | - | Success: false, | |
| 31 | - | Message: err.Error(), | |
| 32 | - | }) | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | _, err = saveAndImportImage(c, a, payload) | |
| 36 | - | if err != nil { | |
| 37 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 38 | - | Success: false, | |
| 39 | - | Message: err.Error(), | |
| 40 | - | }) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | shortID := generateShortID(payload.ImageID) | |
| 44 | - | storeIDMapping(shortID, payload.ImageID) | |
| 45 | - | ||
| 46 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 47 | - | ||
| 48 | - | return sendJSONResponse(c, http.StatusOK, common.PushResponse{ | |
| 49 | - | Success: true, | |
| 50 | - | Message: "Image pushed and imported successfully", | |
| 51 | - | CreateContainerURL: createContainerURL, | |
| 52 | - | }) | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // PostPushChunked handles chunked (large) image pushes | |
| 56 | - | func PostPushChunked(c echo.Context, a *server.App) error { | |
| 57 | - | return handleChunkedPush(c, a, c.Request().Header.Get("X-Chunk-Metadata")) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func handleChunkedPush(c echo.Context, a *server.App, metadataStr string) error { | |
| 61 | - | var metadata ChunkMetadata | |
| 62 | - | if err := json.Unmarshal([]byte(metadataStr), &metadata); err != nil { | |
| 63 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 64 | - | Success: false, | |
| 65 | - | Message: "Invalid chunk metadata", | |
| 66 | - | }) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Initialize chunk storage first | |
| 70 | - | chunkStore.mu.Lock() | |
| 71 | - | if chunkStore.chunks[metadata.TransferID] == nil { | |
| 72 | - | chunkStore.chunks[metadata.TransferID] = make(map[int][]byte) | |
| 73 | - | chunkStore.metadata[metadata.TransferID] = &metadata | |
| 74 | - | } | |
| 75 | - | chunkStore.mu.Unlock() | |
| 76 | - | ||
| 77 | - | chunkData, err := io.ReadAll(c.Request().Body) | |
| 78 | - | if err != nil { | |
| 79 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 80 | - | Success: false, | |
| 81 | - | Message: "Failed to read chunk data", | |
| 82 | - | }) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Validate chunk data | |
| 86 | - | if err := validatePushChunk(chunkData, &metadata); err != nil { | |
| 87 | - | log.Error("Chunk validation failed", | |
| 88 | - | "error", err, | |
| 89 | - | "chunkNumber", metadata.ChunkNumber, | |
| 90 | - | "transferID", metadata.TransferID) | |
| 91 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 92 | - | Success: false, | |
| 93 | - | Message: fmt.Sprintf("Invalid chunk: %v", err), | |
| 94 | - | }) | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // Store chunk data | |
| 98 | - | chunkStore.mu.Lock() | |
| 99 | - | chunkStore.chunks[metadata.TransferID][metadata.ChunkNumber] = chunkData | |
| 100 | - | chunkStore.mu.Unlock() | |
| 101 | - | ||
| 102 | - | // Process complete transfer if this was the last chunk | |
| 103 | - | if isTransferComplete(metadata.TransferID) { | |
| 104 | - | return processCompleteChunkedPushTransfer(c, a, metadata.TransferID) | |
| 105 | - | } | |
| 106 | - | ||
| 107 | - | // Acknowledge intermediate chunk | |
| 108 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 109 | - | Success: true, | |
| 110 | - | Message: fmt.Sprintf("Chunk %d/%d received successfully", | |
| 111 | - | metadata.ChunkNumber+1, metadata.TotalChunks), | |
| 112 | - | }) | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | func processCompleteChunkedPushTransfer(c echo.Context, a *server.App, transferID string) error { | |
| 116 | - | log.Info("Starting complete push transfer processing", | |
| 117 | - | "transferID", transferID) | |
| 118 | - | ||
| 119 | - | // Get transfer data | |
| 120 | - | metadata, chunks, err := getTransferData(transferID) | |
| 121 | - | if err != nil { | |
| 122 | - | return sendPushErrorResponse(c, "Failed to get transfer data", err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Validate we have all chunks | |
| 126 | - | if len(chunks) != metadata.TotalChunks { | |
| 127 | - | return sendPushErrorResponse(c, "Incomplete transfer", | |
| 128 | - | fmt.Errorf("expected %d chunks, got %d", metadata.TotalChunks, len(chunks))) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Create temporary directory with specific naming | |
| 132 | - | tmpDir, err := os.MkdirTemp("", "docker-push-*") | |
| 133 | - | if err != nil { | |
| 134 | - | return sendPushErrorResponse(c, "Failed to create temp directory", err) | |
| 135 | - | } | |
| 136 | - | defer os.RemoveAll(tmpDir) | |
| 137 | - | ||
| 138 | - | // Create temporary file with .tar extension | |
| 139 | - | tmpFile, err := os.Create(filepath.Join(tmpDir, "image.tar")) | |
| 140 | - | if err != nil { | |
| 141 | - | return sendPushErrorResponse(c, "Failed to create temp file", err) | |
| 142 | - | } | |
| 143 | - | defer tmpFile.Close() | |
| 144 | - | ||
| 145 | - | // Write chunks in order to ensure proper tar assembly | |
| 146 | - | for i := 0; i < metadata.TotalChunks; i++ { | |
| 147 | - | chunk, exists := chunks[i] | |
| 148 | - | if !exists { | |
| 149 | - | return sendPushErrorResponse(c, "Missing chunk", | |
| 150 | - | fmt.Errorf("chunk %d not found", i)) | |
| 151 | - | } | |
| 152 | - | if _, err := tmpFile.Write(chunk); err != nil { | |
| 153 | - | return sendPushErrorResponse(c, "Failed to write chunk", | |
| 154 | - | fmt.Errorf("error writing chunk %d: %v", i, err)) | |
| 155 | - | } | |
| 156 | - | } | |
| 157 | - | ||
| 158 | - | // Ensure all data is written to disk | |
| 159 | - | if err := tmpFile.Sync(); err != nil { | |
| 160 | - | return sendPushErrorResponse(c, "Failed to sync file", err) | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Reset file pointer to beginning | |
| 164 | - | if _, err := tmpFile.Seek(0, 0); err != nil { | |
| 165 | - | return sendPushErrorResponse(c, "Failed to reset file pointer", err) | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Verify file size | |
| 169 | - | fileInfo, err := tmpFile.Stat() | |
| 170 | - | if err != nil { | |
| 171 | - | return sendPushErrorResponse(c, "Failed to get file info", err) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | log.Info("Image file created", | |
| 175 | - | "size", fmt.Sprintf("%.2f MB", float64(fileInfo.Size())/(1024*1024)), | |
| 176 | - | "path", tmpFile.Name()) | |
| 177 | - | ||
| 178 | - | imageName := c.Request().Header.Get("X-Image-Name") | |
| 179 | - | if imageName == "" { | |
| 180 | - | return sendPushErrorResponse(c, "Missing image name", fmt.Errorf("X-Image-Name header is required")) | |
| 181 | - | } | |
| 182 | - | ||
| 183 | - | log.Info("Importing image", | |
| 184 | - | "path", tmpFile.Name(), | |
| 185 | - | "imageName", imageName) | |
| 186 | - | ||
| 187 | - | // Import the image | |
| 188 | - | imageID, err := docker.ImportImageToEngine(tmpFile.Name()) | |
| 189 | - | if err != nil { | |
| 190 | - | if strings.Contains(err.Error(), "platform compatibility check failed") { | |
| 191 | - | return sendJSONResponse(c, http.StatusBadRequest, PushResponse{ | |
| 192 | - | Success: false, | |
| 193 | - | Message: fmt.Sprintf("Image platform not compatible with server: %v", err), | |
| 194 | - | }) | |
| 195 | - | } | |
| 196 | - | return sendPushErrorResponse(c, "Failed to import image", err) | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Cleanup and prepare response | |
| 200 | - | cleanupTransfer(transferID) | |
| 201 | - | shortID := generateShortID(imageID) | |
| 202 | - | storeIDMapping(shortID, imageID) | |
| 203 | - | createContainerURL := generateCreateContainerURL(a, shortID) | |
| 204 | - | ||
| 205 | - | return sendJSONResponse(c, http.StatusOK, PushResponse{ | |
| 206 | - | Success: true, | |
| 207 | - | Message: "Image pushed and imported successfully", | |
| 208 | - | CreateContainerURL: createContainerURL, | |
| 209 | - | ImageID: imageID, | |
| 210 | - | }) | |
| 211 | - | } | |
| 212 | - | ||
| 213 | - | func validatePushChunk(chunk []byte, metadata *ChunkMetadata) error { | |
| 214 | - | if len(chunk) == 0 { | |
| 215 | - | return errors.New("empty chunk received") | |
| 216 | - | } | |
| 217 | - | ||
| 218 | - | if metadata == nil { | |
| 219 | - | return errors.New("missing chunk metadata") | |
| 220 | - | } | |
| 221 | - | ||
| 222 | - | if int64(len(chunk)) != metadata.ChunkSize { | |
| 223 | - | return fmt.Errorf("chunk size mismatch: expected %d, got %d", | |
| 224 | - | metadata.ChunkSize, len(chunk)) | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | return nil | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | func sendPushErrorResponse(c echo.Context, message string, err error) error { | |
| 231 | - | log.Error(message, "error", err) | |
| 232 | - | return sendJSONResponse(c, http.StatusInternalServerError, PushResponse{ | |
| 233 | - | Success: false, | |
| 234 | - | Message: message, | |
| 235 | - | }) | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // validateAndPreparePushPayload validates and prepares the push payload | |
| 239 | - | func validateAndPreparePushPayload(c echo.Context) (*common.PushPayload, error) { | |
| 240 | - | payload := &common.PushPayload{ | |
| 241 | - | ImageName: c.Request().Header.Get("X-Image-Name"), | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | if payload.ImageName == "" { | |
| 245 | - | return nil, errors.New("invalid image name") | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | return payload, nil | |
| 249 | - | } | |
D internal/httpserve/handlers/endpoint_remove.go +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // PostContainerRemove handles the container remove request | |
| 13 | - | func PostContainerRemove(c echo.Context, a *server.App) error { | |
| 14 | - | payload := new(common.RequestPayload) | |
| 15 | - | if err := c.Bind(payload); err != nil { | |
| 16 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | if payload.Type != "remove" { | |
| 20 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | removePayload, ok := payload.Payload.(common.RemovePayload) | |
| 24 | - | if !ok { | |
| 25 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | if removePayload.ContainerID == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID") | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | err := docker.RemoveContainer(removePayload.ContainerID) | |
| 33 | - | if err != nil { | |
| 34 | - | return sendJSONResponse(c, http.StatusInternalServerError, RemoveResponse{ | |
| 35 | - | Success: false, | |
| 36 | - | Message: err.Error(), | |
| 37 | - | }) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return sendJSONResponse(c, http.StatusOK, RemoveResponse{ | |
| 41 | - | Success: true, | |
| 42 | - | Message: "Container removed successfully", | |
| 43 | - | }) | |
| 44 | - | } | |
D internal/httpserve/handlers/endpoint_stop.go +0 -44
| @@ -1,44 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/bnema/gordon/pkg/docker" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // PostContainerStop handles the container stop request | |
| 13 | - | func PostContainerStop(c echo.Context, a *server.App) error { | |
| 14 | - | payload := new(common.RequestPayload) | |
| 15 | - | if err := c.Bind(payload); err != nil { | |
| 16 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload: "+err.Error()) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | if payload.Type != "stop" { | |
| 20 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload type") | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | stopPayload, ok := payload.Payload.(common.StopPayload) | |
| 24 | - | if !ok { | |
| 25 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid payload structure") | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | if stopPayload.ContainerID == "" { | |
| 29 | - | return sendJSONResponse(c, http.StatusBadRequest, "Invalid container ID") | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | err := docker.StopContainer(stopPayload.ContainerID) | |
| 33 | - | if err != nil { | |
| 34 | - | return sendJSONResponse(c, http.StatusInternalServerError, StopResponse{ | |
| 35 | - | Success: false, | |
| 36 | - | Message: err.Error(), | |
| 37 | - | }) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | return sendJSONResponse(c, http.StatusOK, StopResponse{ | |
| 41 | - | Success: true, | |
| 42 | - | Message: "Container stopped successfully", | |
| 43 | - | }) | |
| 44 | - | } | |
D internal/httpserve/handlers/error_handlers.go +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | "github.com/bnema/gordon/internal/templating/models/templ/pages/errors" | |
| 8 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 9 | - | "github.com/labstack/echo/v4" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // RenderNotFoundPage renders a 404 error page using templ | |
| 13 | - | func RenderNotFoundPage(c echo.Context, a *server.App) error { | |
| 14 | - | data := errors.ErrorPageData{ | |
| 15 | - | Title: "Page Not Found | Gordon", | |
| 16 | - | AdminPath: a.Config.Admin.Path, | |
| 17 | - | StatusCode: http.StatusNotFound, | |
| 18 | - | Message: "Sorry, the page you are looking for does not exist.", | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | renderer := render.NewTemplRenderer(a) | |
| 22 | - | return renderer.RenderTempl(c, errors.NotFoundPage(data)) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // RenderForbiddenPage renders a 403 error page using templ | |
| 26 | - | func RenderForbiddenPage(c echo.Context, a *server.App) error { | |
| 27 | - | data := errors.ErrorPageData{ | |
| 28 | - | Title: "Access Forbidden | Gordon", | |
| 29 | - | AdminPath: a.Config.Admin.Path, | |
| 30 | - | StatusCode: http.StatusForbidden, | |
| 31 | - | Message: "You don't have permission to access this resource.", | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | renderer := render.NewTemplRenderer(a) | |
| 35 | - | return renderer.RenderTempl(c, errors.ForbiddenPage(data)) | |
| 36 | - | } ⏎̸ | |
D internal/httpserve/handlers/login.go +0 -595
| @@ -1,595 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "encoding/base64" | |
| 6 | - | "encoding/json" | |
| 7 | - | "errors" | |
| 8 | - | "fmt" | |
| 9 | - | "io" | |
| 10 | - | "net/http" | |
| 11 | - | "net/url" | |
| 12 | - | "strings" | |
| 13 | - | "time" | |
| 14 | - | ||
| 15 | - | "github.com/bnema/gordon/internal/db" | |
| 16 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 17 | - | "github.com/bnema/gordon/internal/server" | |
| 18 | - | "github.com/bnema/gordon/pkg/logger" | |
| 19 | - | "github.com/golang-jwt/jwt/v5" | |
| 20 | - | "github.com/gorilla/sessions" | |
| 21 | - | _ "github.com/joho/godotenv/autoload" | |
| 22 | - | "github.com/labstack/echo-contrib/session" | |
| 23 | - | "github.com/labstack/echo/v4" | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | // --- JWT Claims --- | |
| 27 | - | type JwtCustomClaims struct { | |
| 28 | - | AccountID string `json:"account_id"` | |
| 29 | - | jwt.RegisteredClaims | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // StartOAuthGithub starts the Github OAuth flow. | |
| 33 | - | func StartOAuthGithub(c echo.Context, a *server.App) error { | |
| 34 | - | logger.Info("Attempting to get an existing session") | |
| 35 | - | sess, err := getSession(c) | |
| 36 | - | if err != nil || sess == nil || sess.Values == nil { | |
| 37 | - | logger.Info("Session retrieval failed, initiating new GitHub OAuth flow") | |
| 38 | - | return initiateGithubOAuthFlow(c, a) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | logger.Info("Attempting to assert session values") | |
| 42 | - | sessionID, ok1 := sess.Values["sessionID"].(string) | |
| 43 | - | accountID, ok2 := sess.Values["accountID"].(string) | |
| 44 | - | authenticated, ok3 := sess.Values["authenticated"].(bool) | |
| 45 | - | ||
| 46 | - | if !ok1 || !ok2 || !ok3 || !authenticated { | |
| 47 | - | logger.Info("Session assertion failed, or user not authenticated. Initiating new OAuth flow") | |
| 48 | - | return initiateGithubOAuthFlow(c, a) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | logger.Info("Checking if session exists in DB") | |
| 52 | - | sessionExists, err := queries.CheckDBSessionExists(a.DB, sessionID) | |
| 53 | - | if err != nil { | |
| 54 | - | logger.Info("Error checking if session exists", "error", err) | |
| 55 | - | return fmt.Errorf("could not check if session exists: %w", err) | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | if !sessionExists { | |
| 59 | - | logger.Info("Session does not exist in DB, initiating new OAuth flow") | |
| 60 | - | return initiateGithubOAuthFlow(c, a) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | logger.Info("Getting the expiration time of the session") | |
| 64 | - | currentTime := time.Now() | |
| 65 | - | sessionExpiration, err := queries.GetSessionExpiration(a.DB, accountID, sessionID, currentTime) | |
| 66 | - | if err != nil { | |
| 67 | - | logger.Info("Error getting session expiration", "error", err) | |
| 68 | - | return fmt.Errorf("could not get session expiration: %w", err) | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | if currentTime.After(sessionExpiration) { | |
| 72 | - | logger.Info("Current time is after session expiration, initiating new OAuth flow") | |
| 73 | - | return initiateGithubOAuthFlow(c, a) | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | logger.Info("Extending session expiration time by 24 hours") | |
| 77 | - | newExpirationTime := currentTime.Add(time.Hour * 24) | |
| 78 | - | err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, newExpirationTime) | |
| 79 | - | if err != nil { | |
| 80 | - | logger.Info("Error extending session expiration", "error", err) | |
| 81 | - | return fmt.Errorf("could not extend session expiration: %w", err) | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | // Explicitly mark the session as active since we've validated it | |
| 85 | - | logger.Info("Marking existing session as active", "sessionID", sessionID) | |
| 86 | - | err = queries.MarkSessionActive(a.DB, sessionID) | |
| 87 | - | if err != nil { | |
| 88 | - | logger.Error("Failed to mark session as active", "error", err, "sessionID", sessionID) | |
| 89 | - | return fmt.Errorf("failed to mark session active: %w", err) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | logger.Info("Session is valid and extended. Redirecting to admin panel") | |
| 93 | - | return c.Redirect(http.StatusFound, a.Config.Admin.Path) | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // initiateGithubOAuthFlow starts a new GitHub OAuth flow | |
| 97 | - | func initiateGithubOAuthFlow(c echo.Context, a *server.App) error { | |
| 98 | - | clientID := "" // IDK why it needs that even empty, but it does | |
| 99 | - | // Use the corrected generation function | |
| 100 | - | redirectDomain := a.GenerateOauthCallbackURL() | |
| 101 | - | ||
| 102 | - | encodedState := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | |
| 103 | - | ||
| 104 | - | // Redirect to GitHub's OAuth endpoint via the proxy | |
| 105 | - | proxyURL := a.Config.Build.ProxyURL | |
| 106 | - | oauthURL := fmt.Sprintf( | |
| 107 | - | "%s/github/authorize?client_id=%s&redirect_uri=%s&state=%s", | |
| 108 | - | proxyURL, | |
| 109 | - | clientID, | |
| 110 | - | redirectDomain, // Pass the correctly generated public URL to the proxy | |
| 111 | - | encodedState, | |
| 112 | - | ) | |
| 113 | - | logger.Debug("Initiating OAuth flow, redirecting to proxy", "proxy_url", oauthURL) | |
| 114 | - | return c.Redirect(http.StatusFound, oauthURL) | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | type Sessions struct { | |
| 118 | - | *db.Sessions | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | // OAuthCallback handles the callback response from Github OAuth | |
| 122 | - | func OAuthCallback(c echo.Context, a *server.App) error { | |
| 123 | - | logger.Info("Starting OAuth callback handler") | |
| 124 | - | redirectPath := a.Config.Admin.Path | |
| 125 | - | ||
| 126 | - | accessToken, encodedState, err := parseQueryParams(c) | |
| 127 | - | if err != nil { | |
| 128 | - | logger.Error("Failed to parse query params from OAuth callback", "error", err) | |
| 129 | - | return c.String(http.StatusBadRequest, err.Error()) | |
| 130 | - | } | |
| 131 | - | logger.Debug("Parsed access token and state from query params", "token_length", len(accessToken), "encoded_state", encodedState) | |
| 132 | - | ||
| 133 | - | // update the struct with the new access token | |
| 134 | - | a.DBTables.Sessions.AccessToken = accessToken | |
| 135 | - | ||
| 136 | - | // Compare the state parameter with the correctly generated redirect domain | |
| 137 | - | redirectDomain := a.GenerateOauthCallbackURL() | |
| 138 | - | encodedRedirectDomain := base64.StdEncoding.EncodeToString([]byte("redirectDomain:" + redirectDomain)) | |
| 139 | - | if encodedState != encodedRedirectDomain { | |
| 140 | - | logger.Error("Invalid state parameter", "expected", encodedRedirectDomain, "received", encodedState) | |
| 141 | - | return c.String(http.StatusBadRequest, "invalid state parameter") | |
| 142 | - | } | |
| 143 | - | logger.Debug("State parameter validation successful") | |
| 144 | - | ||
| 145 | - | sess, err := getSession(c) | |
| 146 | - | if err != nil { | |
| 147 | - | logger.Error("Failed to get session", "error", err) | |
| 148 | - | return err | |
| 149 | - | } | |
| 150 | - | logger.Debug("Session retrieved successfully") | |
| 151 | - | ||
| 152 | - | browserInfo := c.Request().UserAgent() | |
| 153 | - | userInfo, err := githubGetUserDetails(c) | |
| 154 | - | if err != nil { | |
| 155 | - | logger.Error("Failed to get GitHub user details", "error", err) | |
| 156 | - | return err | |
| 157 | - | } | |
| 158 | - | logger.Debug("Retrieved GitHub user details", "login", userInfo.Login, "email_count", len(userInfo.Emails)) | |
| 159 | - | ||
| 160 | - | user, accountID, session, err := handleUser(a, accessToken, browserInfo, userInfo) | |
| 161 | - | if err != nil { | |
| 162 | - | logger.Error("Failed to handle user during OAuth callback", "error", err) | |
| 163 | - | ||
| 164 | - | // --- MODIFIED: Redirect to login with specific error based on err type --- | |
| 165 | - | loginPath := a.Config.Admin.Path + "/login" | |
| 166 | - | errorParam := "error=unknown_error" // Default error | |
| 167 | - | ||
| 168 | - | var httpErr *echo.HTTPError | |
| 169 | - | if errors.As(err, &httpErr) { | |
| 170 | - | switch httpErr.Code { | |
| 171 | - | case http.StatusUnauthorized: | |
| 172 | - | // Check the message to differentiate unauthorized reasons | |
| 173 | - | if strings.Contains(httpErr.Message.(string), "does not match the allowed email") { | |
| 174 | - | errorParam = "error=email_mismatch" | |
| 175 | - | } else if strings.Contains(httpErr.Message.(string), "no public email address") { | |
| 176 | - | errorParam = "error=no_email" | |
| 177 | - | } else { | |
| 178 | - | errorParam = "error=unauthorized" | |
| 179 | - | } | |
| 180 | - | case http.StatusInternalServerError: | |
| 181 | - | // Check for specific internal errors if needed, e.g., config issue | |
| 182 | - | if strings.Contains(httpErr.Message.(string), "Admin email not set") { | |
| 183 | - | errorParam = "error=config_email_missing" | |
| 184 | - | } else { | |
| 185 | - | errorParam = "error=server_error" | |
| 186 | - | } | |
| 187 | - | default: | |
| 188 | - | errorParam = "error=server_error" // Fallback for other HTTP errors | |
| 189 | - | } | |
| 190 | - | } else { | |
| 191 | - | // Handle non-HTTP errors (like DB connection issues, etc.) | |
| 192 | - | errorParam = "error=server_error" | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | redirectURL := loginPath + "?" + errorParam | |
| 196 | - | logger.Debug("Redirecting user to login page with error", "url", redirectURL) | |
| 197 | - | return c.Redirect(http.StatusSeeOther, redirectURL) | |
| 198 | - | // --- END MODIFICATION --- | |
| 199 | - | } | |
| 200 | - | logger.Info("User handled successfully", "user_id", user.ID, "account_id", accountID) | |
| 201 | - | ||
| 202 | - | logger.Debug("Before setting session values", "account_id", accountID, "session", session) | |
| 203 | - | err = setSessionValues(c, sess, accountID, session) | |
| 204 | - | if err != nil { | |
| 205 | - | // Error is already logged within setSessionValues | |
| 206 | - | // Redirect to login page with a server error message | |
| 207 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login?error=server_error") | |
| 208 | - | } | |
| 209 | - | // Log success ONLY after setSessionValues returns without error | |
| 210 | - | logger.Info("Session values set and saved successfully") | |
| 211 | - | ||
| 212 | - | logger.Info("OAuth callback successful, redirecting to", "path", redirectPath) | |
| 213 | - | return c.Redirect(http.StatusFound, redirectPath) | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // parseQueryParams parses the query parameters from the callback URL | |
| 217 | - | func parseQueryParams(c echo.Context) (string, string, error) { | |
| 218 | - | accessToken := c.QueryParam("access_token") | |
| 219 | - | encodedState := c.QueryParam("state") | |
| 220 | - | ||
| 221 | - | _, err := base64.StdEncoding.DecodeString(encodedState) | |
| 222 | - | if err != nil { | |
| 223 | - | return "", "", fmt.Errorf("invalid state parameter") | |
| 224 | - | } | |
| 225 | - | ||
| 226 | - | return accessToken, encodedState, nil | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // getSession gets the session from the context | |
| 230 | - | func getSession(c echo.Context) (*sessions.Session, error) { | |
| 231 | - | sess, err := session.Get("session", c) | |
| 232 | - | if err != nil { | |
| 233 | - | return nil, echo.NewHTTPError(http.StatusInternalServerError, "Could not get session") | |
| 234 | - | } | |
| 235 | - | return sess, nil | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // handleUser handles the user creation or update | |
| 239 | - | func handleUser(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 240 | - | logger.Debug("Starting handleUser function", "github_login", userInfo.Login) | |
| 241 | - | ||
| 242 | - | // --- Check provider count --- | |
| 243 | - | providerCount, err := queries.CountProviders(a.DB) | |
| 244 | - | if err != nil { | |
| 245 | - | logger.Error("Error checking provider count", "error", err) | |
| 246 | - | return nil, "", nil, fmt.Errorf("could not check provider count: %w", err) | |
| 247 | - | } | |
| 248 | - | logger.Debug("Provider count check result", "count", providerCount) | |
| 249 | - | ||
| 250 | - | // --- Case 1: First ever GitHub login --- | |
| 251 | - | if providerCount == 0 { | |
| 252 | - | logger.Info("First GitHub login detected. Linking to seeded admin account.") | |
| 253 | - | ||
| 254 | - | // --- Link to Seeded Admin --- | |
| 255 | - | // No token check needed anymore, proceed directly to linking. | |
| 256 | - | seededUser, seededAccountID, session, err := linkProviderToSeededAdmin(a, accessToken, browserInfo, userInfo) | |
| 257 | - | if err != nil { | |
| 258 | - | logger.Error("Failed to link provider to seeded admin", "error", err) | |
| 259 | - | return nil, "", nil, err | |
| 260 | - | } | |
| 261 | - | logger.Info("Successfully linked GitHub provider to seeded admin account", "user_id", seededUser.ID, "account_id", seededAccountID) | |
| 262 | - | return seededUser, seededAccountID, session, nil | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | // --- Case 2: Subsequent GitHub logins --- | |
| 266 | - | if providerCount > 0 { | |
| 267 | - | logger.Debug("Existing provider(s) found. Checking if incoming GitHub user matches.") | |
| 268 | - | ||
| 269 | - | // --- ADDED: Check if incoming GitHub user email matches configured email --- | |
| 270 | - | configuredEmail := a.Config.ReverseProxy.Email | |
| 271 | - | if configuredEmail == "" { | |
| 272 | - | logger.Error("Configuration Error: ReverseProxy.Email is not set in config.yml. Cannot validate user email.") | |
| 273 | - | // Prevent login if the email isn't configured, as it's required for validation. | |
| 274 | - | return nil, "", nil, echo.NewHTTPError(http.StatusInternalServerError, "Server configuration error: Admin email not set.") | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | githubEmail := "" | |
| 278 | - | if len(userInfo.Emails) > 0 { | |
| 279 | - | githubEmail = userInfo.Emails[0] // Assuming the first email is primary | |
| 280 | - | } | |
| 281 | - | ||
| 282 | - | if githubEmail == "" { | |
| 283 | - | logger.Warn("Unauthorized GitHub login attempt: User has no public email address on GitHub.", "github_login", userInfo.Login) | |
| 284 | - | return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "GitHub account must have a public primary email address.") | |
| 285 | - | } | |
| 286 | - | ||
| 287 | - | if !strings.EqualFold(githubEmail, configuredEmail) { | |
| 288 | - | logger.Warn("Unauthorized GitHub login attempt: Email mismatch.", | |
| 289 | - | "github_login", userInfo.Login, | |
| 290 | - | "github_email", githubEmail, | |
| 291 | - | "configured_email", configuredEmail) | |
| 292 | - | return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, fmt.Sprintf("This GitHub account's email (%s) does not match the allowed email.", githubEmail)) | |
| 293 | - | } | |
| 294 | - | logger.Debug("GitHub user email matches configured admin email.", "email", githubEmail) | |
| 295 | - | // --- END: Email Check --- | |
| 296 | - | ||
| 297 | - | // --- Check if incoming GitHub user matches an existing provider --- | |
| 298 | - | existingUser, existingAccountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login) | |
| 299 | - | if err != nil { | |
| 300 | - | // Handle sql.ErrNoRows specifically: means this GitHub user is not linked yet. | |
| 301 | - | if err == sql.ErrNoRows { | |
| 302 | - | logger.Warn("Unauthorized GitHub user trying to log in (no matching provider found).", "github_login", userInfo.Login) | |
| 303 | - | // For now, deny access. Future: could allow multiple users if desired. | |
| 304 | - | return nil, "", nil, echo.NewHTTPError(http.StatusUnauthorized, "This GitHub account is not authorized.") | |
| 305 | - | } | |
| 306 | - | // Handle other potential database errors | |
| 307 | - | logger.Error("Error checking for existing provider by login", "error", err) | |
| 308 | - | return nil, "", nil, fmt.Errorf("could not check existing provider: %w", err) | |
| 309 | - | } | |
| 310 | - | logger.Debug("Incoming GitHub login matches existing user.", "github_login", userInfo.Login, "user_id", existingUser.ID, "account_id", existingAccountID) | |
| 311 | - | ||
| 312 | - | // --- Update Session for Existing User --- | |
| 313 | - | // The user is valid and matches the existing provider, just update their session. | |
| 314 | - | session, err := queries.CreateOrUpdateSession(a.DB, existingAccountID, accessToken, browserInfo) | |
| 315 | - | if err != nil { | |
| 316 | - | logger.Error("Could not create/update session for existing user", "error", err) | |
| 317 | - | return nil, "", nil, fmt.Errorf("could not update session: %w", err) | |
| 318 | - | } | |
| 319 | - | logger.Info("Successfully created/updated session for existing user", "user_id", existingUser.ID, "account_id", existingAccountID) | |
| 320 | - | return existingUser, existingAccountID, session, nil | |
| 321 | - | } | |
| 322 | - | ||
| 323 | - | // Should not be reached if logic is correct | |
| 324 | - | logger.Error("Could not handle user - reached end of function unexpectedly", "provider_count", providerCount) | |
| 325 | - | return nil, "", nil, fmt.Errorf("could not handle user") | |
| 326 | - | } | |
| 327 | - | ||
| 328 | - | // --- NEW FUNCTION: linkProviderToSeededAdmin --- | |
| 329 | - | // Finds the seeded admin, updates its details, creates the provider and session. | |
| 330 | - | func linkProviderToSeededAdmin(a *server.App, accessToken, browserInfo string, userInfo *db.GithubUserInfo) (*db.User, string, *db.Sessions, error) { | |
| 331 | - | logger.Debug("Starting linkProviderToSeededAdmin", "github_login", userInfo.Login) | |
| 332 | - | ||
| 333 | - | // 1. Get the seeded user and account ID (assuming it's the only one) | |
| 334 | - | seededUser, seededAccountID, err := queries.GetFirstUserAndAccount(a.DB) | |
| 335 | - | if err != nil { | |
| 336 | - | logger.Error("Failed to get seeded user/account", "error", err) | |
| 337 | - | return nil, "", nil, fmt.Errorf("critical: could not find seeded admin account: %w", err) | |
| 338 | - | } | |
| 339 | - | logger.Debug("Found seeded admin", "user_id", seededUser.ID, "account_id", seededAccountID, "original_email", seededUser.Email) | |
| 340 | - | ||
| 341 | - | // 2. Update the seeded user's details with GitHub info | |
| 342 | - | newEmail := seededUser.Email // Keep original unless GitHub provides one | |
| 343 | - | if len(userInfo.Emails) > 0 && userInfo.Emails[0] != "" { | |
| 344 | - | newEmail = userInfo.Emails[0] | |
| 345 | - | } | |
| 346 | - | newName := userInfo.Login | |
| 347 | - | ||
| 348 | - | err = queries.UpdateUserDetails(a.DB, seededUser.ID, newName, newEmail) | |
| 349 | - | if err != nil { | |
| 350 | - | logger.Error("Failed to update seeded user details", "user_id", seededUser.ID, "error", err) | |
| 351 | - | return nil, "", nil, fmt.Errorf("could not update seeded user details: %w", err) | |
| 352 | - | } | |
| 353 | - | logger.Info("Updated seeded user details", "user_id", seededUser.ID, "new_name", newName, "new_email", newEmail) | |
| 354 | - | seededUser.Name = newName | |
| 355 | - | seededUser.Email = newEmail | |
| 356 | - | ||
| 357 | - | // 3. Create the Provider record linked to the seeded account | |
| 358 | - | err = queries.InsertProviderForAccount(a.DB, seededAccountID, "github", userInfo) | |
| 359 | - | if err != nil { | |
| 360 | - | logger.Error("Failed to insert provider record for seeded account", "account_id", seededAccountID, "error", err) | |
| 361 | - | return nil, "", nil, fmt.Errorf("could not insert provider record: %w", err) | |
| 362 | - | } | |
| 363 | - | logger.Info("Inserted provider record", "account_id", seededAccountID, "provider", "github", "login", userInfo.Login) | |
| 364 | - | ||
| 365 | - | // 4. Create/Update the Session record linked to the seeded account | |
| 366 | - | session, err := queries.CreateOrUpdateSession(a.DB, seededAccountID, accessToken, browserInfo) | |
| 367 | - | if err != nil { | |
| 368 | - | logger.Error("Failed to create/update session for seeded account", "account_id", seededAccountID, "error", err) | |
| 369 | - | return nil, "", nil, fmt.Errorf("could not create/update session: %w", err) | |
| 370 | - | } | |
| 371 | - | logger.Info("Created/Updated session record", "account_id", seededAccountID, "session_id", session.ID) | |
| 372 | - | ||
| 373 | - | return seededUser, seededAccountID, session, nil | |
| 374 | - | } | |
| 375 | - | ||
| 376 | - | // setSessionValues sets the session values for the user | |
| 377 | - | func setSessionValues(c echo.Context, sess *sessions.Session, accountID string, session *db.Sessions) error { | |
| 378 | - | sess.Values["authenticated"] = true | |
| 379 | - | sess.Values["accountID"] = accountID | |
| 380 | - | sess.Values["expires"] = session.Expires | |
| 381 | - | sess.Values["sessionID"] = session.ID | |
| 382 | - | sess.Values["isActive"] = session.IsActive | |
| 383 | - | ||
| 384 | - | if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 385 | - | logger.Error("Failed to save session cookie", "error", err, "sessionID", session.ID) | |
| 386 | - | return fmt.Errorf("could not save session: %w", err) | |
| 387 | - | } | |
| 388 | - | return nil | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | // fetchGithubAPI fetches the Github API | |
| 392 | - | func fetchGithubAPI(url string, authHeader string, result interface{}) error { | |
| 393 | - | req, err := http.NewRequest("GET", url, nil) | |
| 394 | - | if err != nil { | |
| 395 | - | return err | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | req.Header.Set("Authorization", authHeader) | |
| 399 | - | ||
| 400 | - | resp, err := http.DefaultClient.Do(req) | |
| 401 | - | if err != nil { | |
| 402 | - | return err | |
| 403 | - | } | |
| 404 | - | defer resp.Body.Close() | |
| 405 | - | ||
| 406 | - | if resp.StatusCode != http.StatusOK { | |
| 407 | - | return fmt.Errorf("got status code %d", resp.StatusCode) | |
| 408 | - | } | |
| 409 | - | ||
| 410 | - | body, err := io.ReadAll(resp.Body) | |
| 411 | - | if err != nil { | |
| 412 | - | return err | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | err = json.Unmarshal(body, &result) | |
| 416 | - | if err != nil { | |
| 417 | - | return err | |
| 418 | - | } | |
| 419 | - | ||
| 420 | - | return nil | |
| 421 | - | } | |
| 422 | - | ||
| 423 | - | // githubGetUserDetails gets the user details from the Github API using the access token | |
| 424 | - | func githubGetUserDetails(c echo.Context) (userInfo *db.GithubUserInfo, err error) { | |
| 425 | - | accessToken := c.QueryParam("access_token") | |
| 426 | - | ||
| 427 | - | // Fetch user info | |
| 428 | - | err = fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo) | |
| 429 | - | if err != nil { | |
| 430 | - | return nil, err | |
| 431 | - | } | |
| 432 | - | // Fetch user emails | |
| 433 | - | var emailObjects []struct { | |
| 434 | - | Email string `json:"email"` | |
| 435 | - | } | |
| 436 | - | err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects) | |
| 437 | - | if err != nil { | |
| 438 | - | return nil, err | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | // Extract emails and populate the Emails field in userInfo | |
| 442 | - | for _, emailObj := range emailObjects { | |
| 443 | - | userInfo.Emails = append(userInfo.Emails, emailObj.Email) | |
| 444 | - | } | |
| 445 | - | ||
| 446 | - | return userInfo, nil | |
| 447 | - | } | |
| 448 | - | ||
| 449 | - | func DeviceCodeRequest(c echo.Context, a *server.App) error { | |
| 450 | - | proxyURL := a.Config.Build.ProxyURL | |
| 451 | - | resp, err := http.Post(proxyURL+"/github/device/code", "application/json", nil) | |
| 452 | - | if err != nil { | |
| 453 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 454 | - | } | |
| 455 | - | defer resp.Body.Close() | |
| 456 | - | ||
| 457 | - | if resp.StatusCode != http.StatusOK { | |
| 458 | - | body, _ := io.ReadAll(resp.Body) | |
| 459 | - | return c.JSON(resp.StatusCode, map[string]string{"error": string(body)}) | |
| 460 | - | } | |
| 461 | - | ||
| 462 | - | var deviceCode map[string]interface{} | |
| 463 | - | if err := json.NewDecoder(resp.Body).Decode(&deviceCode); err != nil { | |
| 464 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"}) | |
| 465 | - | } | |
| 466 | - | ||
| 467 | - | return c.JSON(http.StatusOK, deviceCode) | |
| 468 | - | } | |
| 469 | - | ||
| 470 | - | func DeviceTokenRequest(c echo.Context, a *server.App) error { | |
| 471 | - | proxyURL := a.Config.Build.ProxyURL | |
| 472 | - | deviceCode := c.FormValue("device_code") | |
| 473 | - | ||
| 474 | - | resp, err := http.PostForm(proxyURL+"/github/device/token", url.Values{ | |
| 475 | - | "device_code": {deviceCode}, | |
| 476 | - | }) | |
| 477 | - | if err != nil { | |
| 478 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | |
| 479 | - | } | |
| 480 | - | defer resp.Body.Close() | |
| 481 | - | ||
| 482 | - | if resp.StatusCode != http.StatusOK { | |
| 483 | - | body, _ := io.ReadAll(resp.Body) | |
| 484 | - | return c.JSON(resp.StatusCode, map[string]string{"error": string(body)}) | |
| 485 | - | } | |
| 486 | - | ||
| 487 | - | var tokenResp map[string]interface{} | |
| 488 | - | if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { | |
| 489 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to decode response"}) | |
| 490 | - | } | |
| 491 | - | ||
| 492 | - | // --- Start JWT Generation --- | |
| 493 | - | // Extract the access token from the response | |
| 494 | - | githubAccessToken, ok := tokenResp["access_token"].(string) | |
| 495 | - | if !ok || githubAccessToken == "" { | |
| 496 | - | logger.Error("GitHub access token not found or invalid in device token response", "response", tokenResp) | |
| 497 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to retrieve GitHub token"}) | |
| 498 | - | } | |
| 499 | - | logger.Debug("Retrieved GitHub access token via device flow") | |
| 500 | - | ||
| 501 | - | // Get GitHub user details using the access token | |
| 502 | - | userInfo, err := githubGetUserDetailsWithToken(githubAccessToken) | |
| 503 | - | if err != nil { | |
| 504 | - | logger.Error("Failed to get GitHub user details using device flow token", "error", err) | |
| 505 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch GitHub user details"}) | |
| 506 | - | } | |
| 507 | - | logger.Debug("Retrieved GitHub user details via device flow", "login", userInfo.Login) | |
| 508 | - | ||
| 509 | - | // Find the user in the database based on GitHub login | |
| 510 | - | _, accountID, err := queries.GetUserByProviderLogin(a.DB, "github", userInfo.Login) | |
| 511 | - | if err != nil { | |
| 512 | - | if err == sql.ErrNoRows { | |
| 513 | - | logger.Warn("Unauthorized GitHub user trying to get CLI token via device flow.", "github_login", userInfo.Login) | |
| 514 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "This GitHub account is not authorized."}) | |
| 515 | - | } | |
| 516 | - | logger.Error("Error checking for existing provider by login during device flow", "error", err) | |
| 517 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Database error during user lookup"}) | |
| 518 | - | } | |
| 519 | - | logger.Info("Found matching user for device flow", "account_id", accountID, "github_login", userInfo.Login) | |
| 520 | - | ||
| 521 | - | // Generate JWT | |
| 522 | - | jwtToken, err := generateJWT(accountID, a.Config.General.JwtToken) | |
| 523 | - | if err != nil { | |
| 524 | - | logger.Error("Failed to generate JWT for device flow", "error", err, "account_id", accountID) | |
| 525 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate authentication token"}) | |
| 526 | - | } | |
| 527 | - | logger.Info("Generated JWT for CLI device flow", "account_id", accountID) | |
| 528 | - | ||
| 529 | - | // Return the JWT | |
| 530 | - | return c.JSON(http.StatusOK, map[string]string{"jwt_token": jwtToken}) | |
| 531 | - | // --- End JWT Generation --- | |
| 532 | - | } | |
| 533 | - | ||
| 534 | - | // githubGetUserDetailsWithToken gets user details using a provided token | |
| 535 | - | // (Similar to githubGetUserDetails but takes token as argument) | |
| 536 | - | func githubGetUserDetailsWithToken(accessToken string) (*db.GithubUserInfo, error) { | |
| 537 | - | var userInfo *db.GithubUserInfo // Initialize userInfo | |
| 538 | - | ||
| 539 | - | // Fetch user info | |
| 540 | - | err := fetchGithubAPI("https://api.github.com/user", "Bearer "+accessToken, &userInfo) | |
| 541 | - | if err != nil { | |
| 542 | - | return nil, fmt.Errorf("failed to fetch user info: %w", err) | |
| 543 | - | } | |
| 544 | - | if userInfo == nil { | |
| 545 | - | return nil, fmt.Errorf("received nil user info from GitHub API") | |
| 546 | - | } | |
| 547 | - | ||
| 548 | - | // Fetch user emails | |
| 549 | - | var emailObjects []struct { | |
| 550 | - | Email string `json:"email"` | |
| 551 | - | } | |
| 552 | - | err = fetchGithubAPI("https://api.github.com/user/emails", "Bearer "+accessToken, &emailObjects) | |
| 553 | - | if err != nil { | |
| 554 | - | // Log email fetch error but continue, email might not be needed or primary | |
| 555 | - | logger.Warn("Failed to fetch user emails from GitHub API", "error", err) | |
| 556 | - | } else { | |
| 557 | - | // Extract emails and populate the Emails field in userInfo | |
| 558 | - | userInfo.Emails = []string{} // Ensure Emails slice is initialized | |
| 559 | - | for _, emailObj := range emailObjects { | |
| 560 | - | userInfo.Emails = append(userInfo.Emails, emailObj.Email) | |
| 561 | - | } | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | return userInfo, nil | |
| 565 | - | } | |
| 566 | - | ||
| 567 | - | // generateJWT creates a new JWT token for the given account ID | |
| 568 | - | func generateJWT(accountID string, secret string) (string, error) { | |
| 569 | - | if secret == "" { | |
| 570 | - | return "", fmt.Errorf("JWT secret is not configured") | |
| 571 | - | } | |
| 572 | - | // Set custom claims | |
| 573 | - | claims := &JwtCustomClaims{ | |
| 574 | - | AccountID: accountID, | |
| 575 | - | RegisteredClaims: jwt.RegisteredClaims{ | |
| 576 | - | // Set token expiration (e.g., 24 hours) | |
| 577 | - | ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour * 24 * 30)), // 30 days validity for CLI token | |
| 578 | - | IssuedAt: jwt.NewNumericDate(time.Now()), | |
| 579 | - | NotBefore: jwt.NewNumericDate(time.Now()), | |
| 580 | - | Issuer: "gordon", | |
| 581 | - | Subject: "cli_auth", | |
| 582 | - | }, | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | // Create token with claims | |
| 586 | - | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| 587 | - | ||
| 588 | - | // Generate encoded token and return it | |
| 589 | - | t, err := token.SignedString([]byte(secret)) | |
| 590 | - | if err != nil { | |
| 591 | - | return "", fmt.Errorf("failed to sign JWT token: %w", err) | |
| 592 | - | } | |
| 593 | - | ||
| 594 | - | return t, nil | |
| 595 | - | } | |
D internal/httpserve/handlers/logout.go +0 -52
| @@ -1,52 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/gorilla/sessions" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | func Logout(c echo.Context, a *server.App) error { | |
| 15 | - | // Retrieve the session | |
| 16 | - | sess, err := getSession(c) | |
| 17 | - | if err != nil || sess == nil { | |
| 18 | - | // if there is no session, redirect to the login page | |
| 19 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 20 | - | ||
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // Invalidate the session in the database | |
| 24 | - | sessionID, ok := sess.Values["sessionID"].(string) | |
| 25 | - | if ok { | |
| 26 | - | err := queries.MarkSessionInactive(a.DB, sessionID) | |
| 27 | - | if err != nil { | |
| 28 | - | return fmt.Errorf("failed to invalidate session: %w", err) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Delete the session | |
| 33 | - | sess.Options = &sessions.Options{ | |
| 34 | - | Path: "/", | |
| 35 | - | MaxAge: -1, | |
| 36 | - | HttpOnly: true, | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | err = sess.Save(c.Request(), c.Response()) | |
| 40 | - | if err != nil { | |
| 41 | - | return fmt.Errorf("failed to save session: %w", err) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Drop the cookie | |
| 45 | - | cookie := new(http.Cookie) | |
| 46 | - | cookie.Name = "session" | |
| 47 | - | cookie.Value = "" | |
| 48 | - | cookie.Expires = time.Now().Add(-1 * time.Hour) | |
| 49 | - | c.SetCookie(cookie) | |
| 50 | - | ||
| 51 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 52 | - | } | |
D internal/httpserve/handlers/send_error.go +0 -17
| @@ -1,17 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/pkg/sanitize" | |
| 7 | - | "github.com/labstack/echo/v4" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | func sendError(c echo.Context, err error) error { | |
| 11 | - | rawErrHTML := `<div>Error: ` + err.Error() + `</div>` | |
| 12 | - | sanitizedHTML, err := sanitize.SanitizeHTML(rawErrHTML) | |
| 13 | - | if err != nil { | |
| 14 | - | return c.String(http.StatusInternalServerError, "An error occurred during sanitization") | |
| 15 | - | } | |
| 16 | - | return c.HTML(http.StatusInternalServerError, sanitizedHTML) | |
| 17 | - | } | |
D internal/httpserve/handlers/session_db.go +0 -146
| @@ -1,146 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/bnema/gordon/pkg/logger" | |
| 11 | - | "github.com/labstack/echo-contrib/session" | |
| 12 | - | "github.com/labstack/echo/v4" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func ValidateSessionAndUser(c echo.Context, a *server.App) error { | |
| 16 | - | sessionExpiryTime := 30 * time.Minute | |
| 17 | - | ||
| 18 | - | sess, err := session.Get("session", c) | |
| 19 | - | if err != nil { | |
| 20 | - | logger.Warn("Failed to get session from cookie store", "error", err) | |
| 21 | - | return fmt.Errorf("failed to get session: %w", err) | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | accountID, ok := sess.Values["accountID"].(string) | |
| 25 | - | if !ok || accountID == "" { | |
| 26 | - | logger.Warn("Account ID not found in session or is empty") | |
| 27 | - | return fmt.Errorf("accountID not found or invalid") | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | sessionID, ok := sess.Values["sessionID"].(string) | |
| 31 | - | if !ok || sessionID == "" { | |
| 32 | - | logger.Warn("Session ID not found in session or is empty") | |
| 33 | - | return fmt.Errorf("sessionID not found or invalid") | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | authenticated, ok := sess.Values["authenticated"].(bool) | |
| 37 | - | if !ok || !authenticated { | |
| 38 | - | logger.Warn("User not authenticated according to session cookie", "ok", ok, "authenticated_val", sess.Values["authenticated"]) | |
| 39 | - | return fmt.Errorf("user not authenticated") | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | accountExists, err := IsAccountIDInDB(a, accountID) | |
| 43 | - | if err != nil { | |
| 44 | - | logger.Error("Failed to check account existence", "error", err, "accountID", accountID) | |
| 45 | - | return fmt.Errorf("failed checking account existence: %w", err) | |
| 46 | - | } | |
| 47 | - | if !accountExists { | |
| 48 | - | logger.Warn("Account associated with session not found in DB", "accountID", accountID) | |
| 49 | - | return fmt.Errorf("account does not exist") | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | isValidInDB, err := IsSessionActiveInDB(a, sessionID) | |
| 53 | - | if err != nil { | |
| 54 | - | logger.Error("Failed to check session validity in DB", "error", err, "sessionID", sessionID) | |
| 55 | - | return fmt.Errorf("failed checking session validity in DB: %w", err) | |
| 56 | - | } | |
| 57 | - | if !isValidInDB { | |
| 58 | - | logger.Warn("Session is invalid in DB (expired or not active)", "sessionID", sessionID) | |
| 59 | - | return fmt.Errorf("session invalid or expired") | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | err = queries.ExtendSessionExpiration(a.DB, accountID, sessionID, time.Now().Add(sessionExpiryTime)) | |
| 63 | - | if err != nil { | |
| 64 | - | logger.Error("Failed to extend session in DB", "error", err, "accountID", accountID, "sessionID", sessionID) | |
| 65 | - | return fmt.Errorf("failed to extend session: %w", err) | |
| 66 | - | } | |
| 67 | - | sess.Values["expires"] = time.Now().Add(sessionExpiryTime).Format(time.RFC3339) | |
| 68 | - | if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 69 | - | logger.Error("Failed to save updated expiry to cookie session", "error", err, "sessionID", sessionID) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | logger.Debug("Session validated and extended successfully", "accountID", accountID, "sessionID", sessionID) | |
| 73 | - | return nil | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | func IsSessionActiveInDB(a *server.App, sessionID string) (bool, error) { | |
| 77 | - | var dbExpiresStr string | |
| 78 | - | var isActive bool | |
| 79 | - | ||
| 80 | - | query := "SELECT expires, is_active FROM sessions WHERE id = ?" | |
| 81 | - | err := a.DB.QueryRow(query, sessionID).Scan(&dbExpiresStr, &isActive) | |
| 82 | - | ||
| 83 | - | if err != nil { | |
| 84 | - | if err == sql.ErrNoRows { | |
| 85 | - | logger.Warn("Session not found in DB", "sessionID", sessionID) | |
| 86 | - | return false, nil | |
| 87 | - | } | |
| 88 | - | logger.Error("Failed to query session validity from DB", "error", err, "sessionID", sessionID) | |
| 89 | - | return false, err | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | logger.Debug("Session validity check: Read DB values", "sessionID", sessionID, "isActive_read", isActive, "expires_read", dbExpiresStr) | |
| 93 | - | ||
| 94 | - | if !isActive { | |
| 95 | - | logger.Warn("Session found in DB but marked as not active", "sessionID", sessionID) | |
| 96 | - | return false, nil | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | expirationTime, err := time.Parse(time.RFC3339, dbExpiresStr) | |
| 100 | - | if err != nil { | |
| 101 | - | logger.Error("Failed to parse session expiration time from DB", "error", err, "sessionID", sessionID, "expires_str", dbExpiresStr) | |
| 102 | - | return false, fmt.Errorf("invalid expiry format in DB: %w", err) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | if time.Now().After(expirationTime) { | |
| 106 | - | logger.Warn("Session found in DB but expired", "sessionID", sessionID, "expires_at", expirationTime) | |
| 107 | - | return false, nil | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | return true, nil | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func IsAccountIDInDB(a *server.App, accountID string) (bool, error) { | |
| 114 | - | accountExists, err := queries.CheckDBAccountExists(a.DB, accountID) | |
| 115 | - | if err != nil { | |
| 116 | - | logger.Error("Failed to check account existence", "error", err, "accountID", accountID) | |
| 117 | - | return false, err | |
| 118 | - | } | |
| 119 | - | logger.Debug("Account existence check", "accountID", accountID, "exists", accountExists) | |
| 120 | - | return accountExists, nil | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // ResetSession resets the session cookie and session data | |
| 124 | - | // func ResetSession(c echo.Context) error { | |
| 125 | - | // // Manually expire the cookie | |
| 126 | - | // cookie := new(http.Cookie) | |
| 127 | - | // cookie.Name = "session" | |
| 128 | - | // cookie.Value = "" | |
| 129 | - | // cookie.Path = "/" | |
| 130 | - | // cookie.Expires = time.Unix(0, 0) | |
| 131 | - | // cookie.MaxAge = -1 // Clear immediately | |
| 132 | - | // c.SetCookie(cookie) | |
| 133 | - | // // Invalidate the existing session | |
| 134 | - | // sess, err := session.Get("session", c) | |
| 135 | - | // if err != nil { | |
| 136 | - | // log.Println(err) | |
| 137 | - | // return echo.NewHTTPError(http.StatusInternalServerError, "Could not get session") | |
| 138 | - | // } | |
| 139 | - | // sess.Options.MaxAge = -1 | |
| 140 | - | // if err := sess.Save(c.Request(), c.Response()); err != nil { | |
| 141 | - | // log.Println("Could not delete session:", err) | |
| 142 | - | // return echo.NewHTTPError(http.StatusInternalServerError, "Could not delete session") | |
| 143 | - | // } | |
| 144 | - | ||
| 145 | - | // return nil | |
| 146 | - | // } | |
D internal/httpserve/handlers/static.go +0 -37
| @@ -1,37 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "io/fs" | |
| 5 | - | "net/http" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // NoDirFileSys restricts directory listing | |
| 9 | - | type NoDirFileSys struct { | |
| 10 | - | fs http.FileSystem | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | // NewNoDirFileSys creates a new NoDirFileSys | |
| 14 | - | func NewNoDirFileSys(fs http.FileSystem) NoDirFileSys { | |
| 15 | - | return NoDirFileSys{fs: fs} | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func (nfs NoDirFileSys) Open(name string) (http.File, error) { | |
| 19 | - | f, err := nfs.fs.Open(name) | |
| 20 | - | if err != nil { | |
| 21 | - | return nil, err | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | return NoDirFile{f}, nil | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // NoDirFile restricts directory listing | |
| 28 | - | type NoDirFile struct { | |
| 29 | - | http.File | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | func (f NoDirFile) Readdir(count int) ([]fs.FileInfo, error) { | |
| 33 | - | // Disable directory listing | |
| 34 | - | return nil, nil | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // StaticRoute serves static files from the embedded filesystem - REMOVED | |
D internal/httpserve/handlers/templ_handlers.go +0 -212
| @@ -1,212 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/server" | |
| 5 | - | "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 6 | - | "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |
| 7 | - | "github.com/bnema/gordon/internal/templating/models/templ/pages/login" | |
| 8 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 9 | - | "github.com/bnema/gordon/pkg/docker" | |
| 10 | - | "github.com/docker/docker/api/types" | |
| 11 | - | "github.com/labstack/echo/v4" | |
| 12 | - | "strings" | |
| 13 | - | "time" | |
| 14 | - | "github.com/bnema/gordon/pkg/humanize" | |
| 15 | - | "net/http" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // RenderTemplLoginPage renders the login page using templ | |
| 19 | - | func RenderTemplLoginPage(c echo.Context, a *server.App) error { | |
| 20 | - | // Check for token in query param for backward compatibility | |
| 21 | - | urlToken := c.QueryParam("token") | |
| 22 | - | _ = urlToken // Not using this yet, but kept for compatibility | |
| 23 | - | ||
| 24 | - | // Check for error message | |
| 25 | - | errorMsg := c.QueryParam("error") | |
| 26 | - | ||
| 27 | - | // Create a templ renderer | |
| 28 | - | renderer := render.NewTemplRenderer(a) | |
| 29 | - | ||
| 30 | - | // Render the login page | |
| 31 | - | return renderer.RenderTempl(c, login.LoginPage("Login", a.Config.Build.BuildVersion, errorMsg)) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // RenderTemplDashboardPage renders the dashboard page using templ | |
| 35 | - | func RenderTemplDashboardPage(c echo.Context, a *server.App) error { | |
| 36 | - | // Create a templ renderer | |
| 37 | - | renderer := render.NewTemplRenderer(a) | |
| 38 | - | ||
| 39 | - | // Get user-specific URLs | |
| 40 | - | adminPath := a.Config.Admin.Path | |
| 41 | - | userSettingsURL := adminPath + "/settings" | |
| 42 | - | logoutURL := adminPath + "/logout" | |
| 43 | - | ||
| 44 | - | // Render the dashboard page | |
| 45 | - | return renderer.RenderTempl(c, admin.DashboardPage( | |
| 46 | - | "Dashboard", | |
| 47 | - | a.Config.Build.BuildVersion, | |
| 48 | - | adminPath, | |
| 49 | - | userSettingsURL, | |
| 50 | - | logoutURL, | |
| 51 | - | )) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // RenderTemplManagerPage renders the manager page using templ | |
| 55 | - | func RenderTemplManagerPage(c echo.Context, a *server.App) error { | |
| 56 | - | // Create a templ renderer | |
| 57 | - | renderer := render.NewTemplRenderer(a) | |
| 58 | - | ||
| 59 | - | // Get container and image counts | |
| 60 | - | containerCount, _ := GetContainerCount(a) | |
| 61 | - | imageCount, _ := GetImageCount(a) | |
| 62 | - | ||
| 63 | - | // Get user-specific URLs | |
| 64 | - | adminPath := a.Config.Admin.Path | |
| 65 | - | userSettingsURL := adminPath + "/settings" | |
| 66 | - | logoutURL := adminPath + "/logout" | |
| 67 | - | ||
| 68 | - | // Render the manager page | |
| 69 | - | return renderer.RenderTempl(c, admin.ManagerPage( | |
| 70 | - | "Container Manager", | |
| 71 | - | a.Config.Build.BuildVersion, | |
| 72 | - | adminPath, | |
| 73 | - | userSettingsURL, | |
| 74 | - | logoutURL, | |
| 75 | - | containerCount, | |
| 76 | - | imageCount, | |
| 77 | - | )) | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | // RenderTemplContainerList renders the container list component | |
| 81 | - | func RenderTemplContainerList(c echo.Context, a *server.App) error { | |
| 82 | - | // Create a templ renderer | |
| 83 | - | renderer := render.NewTemplRenderer(a) | |
| 84 | - | ||
| 85 | - | // Get the containers | |
| 86 | - | containers, noContainer, err := GetContainers(a) | |
| 87 | - | if err != nil { | |
| 88 | - | return err | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Render the container list component | |
| 92 | - | return renderer.RenderTempl(c, components.ContainerList(containers, noContainer)) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // RenderTemplImageList renders the image list component | |
| 96 | - | func RenderTemplImageList(c echo.Context, a *server.App) error { | |
| 97 | - | // Create a templ renderer | |
| 98 | - | renderer := render.NewTemplRenderer(a) | |
| 99 | - | ||
| 100 | - | // Get the images | |
| 101 | - | images, err := docker.ListContainerImages() | |
| 102 | - | if err != nil { | |
| 103 | - | return err | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // Convert to human-readable format | |
| 107 | - | var humanReadableImages []components.HumanReadableImage | |
| 108 | - | for _, img := range images { | |
| 109 | - | shortID := img.ID[7:19] | |
| 110 | - | ||
| 111 | - | for _, repoTag := range img.RepoTags { | |
| 112 | - | // Split the repo tag into repository and tag | |
| 113 | - | parts := strings.Split(repoTag, ":") | |
| 114 | - | repository := parts[0] | |
| 115 | - | tag := "latest" | |
| 116 | - | if len(parts) > 1 { | |
| 117 | - | tag = parts[1] | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | humanReadableImages = append(humanReadableImages, components.HumanReadableImage{ | |
| 121 | - | ID: img.ID, | |
| 122 | - | ShortID: shortID, | |
| 123 | - | Name: repoTag, | |
| 124 | - | Repository: repository, | |
| 125 | - | Tag: tag, | |
| 126 | - | Size: humanize.BytesToReadableSize(img.Size), | |
| 127 | - | Created: humanize.TimeAgo(time.Unix(img.Created, 0)), | |
| 128 | - | RepoDigests: img.RepoDigests, | |
| 129 | - | RepoTags: img.RepoTags, | |
| 130 | - | }) | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Render the image list component | |
| 135 | - | return renderer.RenderTempl(c, components.ImageList(humanReadableImages)) | |
| 136 | - | } | |
| 137 | - | ||
| 138 | - | // RenderTemplUploadImageForm renders the upload image form component | |
| 139 | - | func RenderTemplUploadImageForm(c echo.Context, a *server.App) error { | |
| 140 | - | // Create a templ renderer | |
| 141 | - | renderer := render.NewTemplRenderer(a) | |
| 142 | - | ||
| 143 | - | // Render the upload image form component | |
| 144 | - | return renderer.RenderTempl(c, components.UploadImageForm()) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | // RenderTemplCreateContainerForm renders the create container form component | |
| 148 | - | func RenderTemplCreateContainerForm(c echo.Context, a *server.App) error { | |
| 149 | - | // Create a templ renderer | |
| 150 | - | renderer := render.NewTemplRenderer(a) | |
| 151 | - | ||
| 152 | - | // Get the image ID from the URL parameter | |
| 153 | - | shortID := c.Param("ID") | |
| 154 | - | ||
| 155 | - | // Try to fetch the image info | |
| 156 | - | imageID, exists := safelyInteractWithIDMap(Fetch, shortID) | |
| 157 | - | if !exists { | |
| 158 | - | return c.String(http.StatusBadRequest, "Invalid image ID") | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | // Get image info | |
| 162 | - | image, err := docker.GetImageInfo(imageID) | |
| 163 | - | if err != nil { | |
| 164 | - | return err | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | // Prepare image name from repo tags | |
| 168 | - | imageName := "unknown" | |
| 169 | - | if len(image.RepoTags) > 0 { | |
| 170 | - | imageName = image.RepoTags[0] | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | // Prepare form data | |
| 174 | - | formData := components.ContainerFormData{ | |
| 175 | - | ImageName: imageName, | |
| 176 | - | ImageID: imageID, | |
| 177 | - | ShortID: shortID, | |
| 178 | - | // Default values can be set here if needed | |
| 179 | - | ProxyPort: "80", // Default proxy port | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | // Render the create container form component | |
| 183 | - | return renderer.RenderTempl(c, components.CreateContainerForm(formData)) | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | // GetContainerCount returns the count of containers | |
| 187 | - | func GetContainerCount(a *server.App) (int, error) { | |
| 188 | - | containers, err := docker.ListRunningContainers() | |
| 189 | - | if err != nil { | |
| 190 | - | return 0, err | |
| 191 | - | } | |
| 192 | - | return len(containers), nil | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | // GetImageCount returns the count of images | |
| 196 | - | func GetImageCount(a *server.App) (int, error) { | |
| 197 | - | images, err := docker.ListContainerImages() | |
| 198 | - | if err != nil { | |
| 199 | - | return 0, err | |
| 200 | - | } | |
| 201 | - | return len(images), nil | |
| 202 | - | } | |
| 203 | - | ||
| 204 | - | // GetContainers returns the list of containers | |
| 205 | - | func GetContainers(a *server.App) ([]types.Container, bool, error) { | |
| 206 | - | containers, err := docker.ListRunningContainers() | |
| 207 | - | if err != nil { | |
| 208 | - | return nil, false, err | |
| 209 | - | } | |
| 210 | - | noContainer := len(containers) == 0 | |
| 211 | - | return containers, noContainer, nil | |
| 212 | - | }⏎̸ | |
D internal/httpserve/handlers/upload_image.go +0 -92
| @@ -1,92 +0,0 @@ | |||
| 1 | - | package handlers | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "io" | |
| 6 | - | "net/http" | |
| 7 | - | "os" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 11 | - | "github.com/bnema/gordon/internal/templating/render" | |
| 12 | - | "github.com/bnema/gordon/pkg/docker" | |
| 13 | - | "github.com/bnema/gordon/pkg/filestore" | |
| 14 | - | "github.com/labstack/echo/v4" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // 10G | |
| 18 | - | const MaxUploadSize = 10 * 1024 * 1024 * 1024 // 10GB | |
| 19 | - | ||
| 20 | - | // UploadImageHandler handles the /upload-image route to show the form | |
| 21 | - | func UploadImageGETHandler(c echo.Context, a *server.App) error { | |
| 22 | - | // Use the Templ renderer | |
| 23 | - | renderer := render.NewTemplRenderer(a) | |
| 24 | - | component := components.UploadImageForm() | |
| 25 | - | ||
| 26 | - | return renderer.RenderTempl(c, component) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // UploadImageHandler handles the /upload-image | |
| 30 | - | func UploadImagePOSTHandler(c echo.Context, a *server.App) error { | |
| 31 | - | // Set upload size limit | |
| 32 | - | c.Request().Body = http.MaxBytesReader(c.Response(), c.Request().Body, MaxUploadSize) | |
| 33 | - | ||
| 34 | - | // Parse the multipart form | |
| 35 | - | form, err := c.MultipartForm() | |
| 36 | - | if err != nil { | |
| 37 | - | return sendError(c, err) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | // Get the file from the form | |
| 41 | - | files := form.File["file"] | |
| 42 | - | if len(files) == 0 { | |
| 43 | - | return c.String(http.StatusBadRequest, "No file uploaded") | |
| 44 | - | } | |
| 45 | - | file := files[0] | |
| 46 | - | ||
| 47 | - | // Open the file | |
| 48 | - | src, err := file.Open() | |
| 49 | - | if err != nil { | |
| 50 | - | return sendError(c, err) | |
| 51 | - | } | |
| 52 | - | defer src.Close() | |
| 53 | - | ||
| 54 | - | // Create a temporary file to store the uploaded chunks | |
| 55 | - | tempFile, err := os.CreateTemp("", "upload-*") | |
| 56 | - | if err != nil { | |
| 57 | - | return sendError(c, err) | |
| 58 | - | } | |
| 59 | - | defer tempFile.Close() | |
| 60 | - | ||
| 61 | - | // Copy the file chunks to the temporary file | |
| 62 | - | _, err = io.Copy(tempFile, src) | |
| 63 | - | if err != nil { | |
| 64 | - | return sendError(c, err) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | // Reset the temporary file pointer | |
| 68 | - | _, err = tempFile.Seek(0, io.SeekStart) | |
| 69 | - | if err != nil { | |
| 70 | - | return sendError(c, err) | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // Save the image to the storage directory | |
| 74 | - | saveInPath, err := filestore.SaveImageToStorage(&a.Config, file.Filename, tempFile) | |
| 75 | - | if err != nil { | |
| 76 | - | return sendError(c, err) | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | // Import the image into Docker | |
| 80 | - | _, err = docker.ImportImageToEngine(saveInPath) | |
| 81 | - | if err != nil { | |
| 82 | - | return fmt.Errorf("failed to import image to Docker: %v", err) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Remove the image from the storage directory | |
| 86 | - | err = filestore.RemoveFromStorage(saveInPath) | |
| 87 | - | if err != nil { | |
| 88 | - | return sendError(c, err) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | return c.HTML(http.StatusOK, ActionSuccess(a)) | |
| 92 | - | } | |
D internal/httpserve/middleware/colorschemedetect.go +0 -35
| @@ -1,35 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/labstack/echo-contrib/session" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // ColorSchemeDetection detects the current color scheme and sets it in the context based on the "Sec-CH-Prefers-Color-Scheme" header | |
| 9 | - | func ColorSchemeDetection(next echo.HandlerFunc) echo.HandlerFunc { | |
| 10 | - | ColorSchemeKey := "CurrentColorScheme" | |
| 11 | - | return func(c echo.Context) error { | |
| 12 | - | colorScheme := detectCurrentColorScheme(c) | |
| 13 | - | c.Set(ColorSchemeKey, colorScheme) | |
| 14 | - | return next(c) | |
| 15 | - | } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func detectCurrentColorScheme(c echo.Context) string { | |
| 19 | - | // Check if the color scheme key is set in the session storage | |
| 20 | - | sess, err := session.Get("session", c) | |
| 21 | - | if err != nil { | |
| 22 | - | return "light" | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | if colorSchemeValue, ok := sess.Values["colorScheme"]; ok && colorSchemeValue != nil { | |
| 26 | - | return colorSchemeValue.(string) // Return the color scheme value from the session storage | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // If color scheme key is not set, proceed with "Sec-CH-Prefers-Color-Scheme" header detection | |
| 30 | - | header := c.Request().Header.Get("Sec-CH-Prefers-Color-Scheme") | |
| 31 | - | if header == "dark" { | |
| 32 | - | return "dark" | |
| 33 | - | } | |
| 34 | - | return "light" // Default to light mode | |
| 35 | - | } | |
D internal/httpserve/middleware/commondata.go +0 -15
| @@ -1,15 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/server" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func SetCommonDataMiddleware(a *server.App) echo.MiddlewareFunc { | |
| 9 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 10 | - | return func(c echo.Context) error { | |
| 11 | - | // TODO: set common data | |
| 12 | - | return next(c) | |
| 13 | - | } | |
| 14 | - | } | |
| 15 | - | } | |
D internal/httpserve/middleware/error.go +0 -20
| @@ -1,20 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/pkg/logger" | |
| 5 | - | "github.com/labstack/echo/v4" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func ErrorHandler(next echo.HandlerFunc) echo.HandlerFunc { | |
| 9 | - | return func(c echo.Context) error { | |
| 10 | - | err := next(c) | |
| 11 | - | if err != nil { | |
| 12 | - | logger.Error("Caught http error", | |
| 13 | - | "path", c.Request().URL.Path, | |
| 14 | - | "method", c.Request().Method, | |
| 15 | - | "error", err, | |
| 16 | - | "status", c.Response().Status) | |
| 17 | - | } | |
| 18 | - | return err | |
| 19 | - | } | |
| 20 | - | } | |
D internal/httpserve/middleware/error_middleware.go +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 7 | - | "github.com/bnema/gordon/internal/server" | |
| 8 | - | "github.com/labstack/echo/v4" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // CustomHTTPErrorHandler is a custom error handler that uses templ to render error pages | |
| 12 | - | func CustomHTTPErrorHandler(a *server.App) echo.HTTPErrorHandler { | |
| 13 | - | return func(err error, c echo.Context) { | |
| 14 | - | code := http.StatusInternalServerError | |
| 15 | - | if he, ok := err.(*echo.HTTPError); ok { | |
| 16 | - | code = he.Code | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // Handle specific error codes with our templates | |
| 20 | - | switch code { | |
| 21 | - | case http.StatusNotFound: | |
| 22 | - | if err := handlers.RenderNotFoundPage(c, a); err != nil { | |
| 23 | - | c.Logger().Error(err) | |
| 24 | - | } | |
| 25 | - | return | |
| 26 | - | case http.StatusForbidden: | |
| 27 | - | if err := handlers.RenderForbiddenPage(c, a); err != nil { | |
| 28 | - | c.Logger().Error(err) | |
| 29 | - | } | |
| 30 | - | return | |
| 31 | - | default: | |
| 32 | - | // For other errors, use Echo's default error handler | |
| 33 | - | c.Echo().DefaultHTTPErrorHandler(err, c) | |
| 34 | - | } | |
| 35 | - | } | |
| 36 | - | } ⏎̸ | |
D internal/httpserve/middleware/langdetect.go +0 -25
| @@ -1,25 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | ||
| 6 | - | "github.com/labstack/echo/v4" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // LanguageDetectiondetects the current language and sets it in the context | |
| 10 | - | func LanguageDetection(next echo.HandlerFunc) echo.HandlerFunc { | |
| 11 | - | return func(c echo.Context) error { | |
| 12 | - | lang := detectCurrentLanguage(c) | |
| 13 | - | c.Set("LangKey", lang) | |
| 14 | - | return next(c) | |
| 15 | - | } | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func detectCurrentLanguage(c echo.Context) string { | |
| 19 | - | // If lang key is not set, proceed with "Accept-Language" header detection | |
| 20 | - | header := c.Request().Header.Get("Accept-Language") | |
| 21 | - | if strings.Contains(header, "fr") { | |
| 22 | - | return "fr" | |
| 23 | - | } | |
| 24 | - | return "en" // Default to English | |
| 25 | - | } | |
D internal/httpserve/middleware/requirelogin.go +0 -47
| @@ -1,47 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net/http" | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 8 | - | "github.com/bnema/gordon/internal/server" | |
| 9 | - | ||
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func redirectToLogin(c echo.Context, a *server.App) error { | |
| 14 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func RequireLogin(a *server.App) echo.MiddlewareFunc { | |
| 18 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 19 | - | return func(c echo.Context) error { | |
| 20 | - | const maxRetries = 3 | |
| 21 | - | var err error | |
| 22 | - | for i := 0; i < maxRetries; i++ { | |
| 23 | - | err = handlers.ValidateSessionAndUser(c, a) | |
| 24 | - | if err == nil { | |
| 25 | - | return next(c) | |
| 26 | - | } | |
| 27 | - | if i < maxRetries-1 { | |
| 28 | - | time.Sleep(time.Duration(i+1) * 500 * time.Millisecond) | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | // propose the redirect auth url as httpcode | |
| 32 | - | return c.Redirect(http.StatusSeeOther, a.Config.Admin.Path+"/login") | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | } | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | // func DeleteCookieAndRedirectToLogin(c echo.Context, a *server.App) error { | |
| 39 | - | // // Delete session cookie | |
| 40 | - | // cookie := new(http.Cookie) | |
| 41 | - | // cookie.Name = "session" | |
| 42 | - | // cookie.Value = "" | |
| 43 | - | // cookie.Expires = time.Now().Add(-1 * time.Hour) | |
| 44 | - | // c.SetCookie(cookie) | |
| 45 | - | ||
| 46 | - | // return redirectToLogin(c, a) | |
| 47 | - | // } | |
D internal/httpserve/middleware/requiretoken.go +0 -96
| @@ -1,96 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/http" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "errors" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 11 | - | "github.com/bnema/gordon/internal/server" | |
| 12 | - | "github.com/bnema/gordon/pkg/logger" | |
| 13 | - | "github.com/golang-jwt/jwt/v5" | |
| 14 | - | "github.com/labstack/echo/v4" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | // --- JWT Claims (copied from handlers/login.go for middleware use) --- | |
| 18 | - | // Consider moving this to a shared location if used in more places. | |
| 19 | - | type JwtCustomClaims struct { | |
| 20 | - | AccountID string `json:"account_id"` | |
| 21 | - | jwt.RegisteredClaims | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | // RequireToken validates JWT tokens for API requests | |
| 25 | - | func RequireToken(a *server.App) echo.MiddlewareFunc { | |
| 26 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 27 | - | return func(c echo.Context) error { | |
| 28 | - | authHeader := c.Request().Header.Get("Authorization") | |
| 29 | - | if authHeader == "" { | |
| 30 | - | logger.Warn("API request missing Authorization header") | |
| 31 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Missing authorization token"}) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | tokenString := strings.TrimPrefix(authHeader, "Bearer ") | |
| 35 | - | if tokenString == authHeader { // No "Bearer " prefix found | |
| 36 | - | logger.Warn("API request Authorization header malformed", "header", authHeader) | |
| 37 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed authorization token"}) | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | if a.Config.General.JwtToken == "" { | |
| 41 | - | logger.Error("JWT Secret is not configured, cannot validate token") | |
| 42 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Server configuration error"}) | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Parse and validate the token | |
| 46 | - | token, err := jwt.ParseWithClaims(tokenString, &JwtCustomClaims{}, func(token *jwt.Token) (interface{}, error) { | |
| 47 | - | // Validate the alg is what we expect: | |
| 48 | - | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
| 49 | - | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) | |
| 50 | - | } | |
| 51 | - | return []byte(a.Config.General.JwtToken), nil | |
| 52 | - | }) | |
| 53 | - | ||
| 54 | - | if err != nil { | |
| 55 | - | logger.Warn("Invalid JWT token received", "error", err) | |
| 56 | - | if errors.Is(err, jwt.ErrTokenMalformed) { | |
| 57 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Malformed token"}) | |
| 58 | - | } else if errors.Is(err, jwt.ErrTokenExpired) || errors.Is(err, jwt.ErrTokenNotValidYet) { | |
| 59 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Token expired or not yet valid"}) | |
| 60 | - | } else { | |
| 61 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) | |
| 62 | - | } | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | // Check if claims are valid and extract AccountID | |
| 66 | - | if claims, ok := token.Claims.(*JwtCustomClaims); ok && token.Valid { | |
| 67 | - | if claims.AccountID == "" { | |
| 68 | - | logger.Warn("JWT token is valid but missing AccountID claim") | |
| 69 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token claims"}) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // --- Optional: Check if AccountID exists and is active in DB --- | |
| 73 | - | accountExists, err := queries.CheckDBAccountExists(a.DB, claims.AccountID) // Corrected function name | |
| 74 | - | if err != nil { | |
| 75 | - | logger.Error("Database error checking account existence during token validation", "account_id", claims.AccountID, "error", err) | |
| 76 | - | return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Internal server error"}) | |
| 77 | - | } | |
| 78 | - | if !accountExists { | |
| 79 | - | logger.Warn("JWT token validated for non-existent account", "account_id", claims.AccountID) | |
| 80 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Account not found"}) | |
| 81 | - | } | |
| 82 | - | // --- End Optional DB Check --- | |
| 83 | - | ||
| 84 | - | logger.Debug("JWT token validated successfully", "account_id", claims.AccountID) | |
| 85 | - | // Set account ID in context for downstream handlers | |
| 86 | - | c.Set("accountID", claims.AccountID) | |
| 87 | - | return next(c) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | logger.Warn("Invalid JWT token claims or token invalid") | |
| 91 | - | return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid token"}) | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Removed unused functions: validateGitHubToken, fetchGitHubUserEmails, shouldUpdateSession, sessionUpdateCache | |
D internal/httpserve/middleware/secure.go +0 -81
| @@ -1,81 +0,0 @@ | |||
| 1 | - | package middleware | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "log" | |
| 6 | - | "net/http" | |
| 7 | - | "os" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/gorilla/sessions" | |
| 11 | - | _ "github.com/joho/godotenv/autoload" | |
| 12 | - | "github.com/labstack/echo-contrib/session" | |
| 13 | - | "github.com/labstack/echo/v4" | |
| 14 | - | "github.com/labstack/echo/v4/middleware" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | func SecureRoutes(a *server.App) echo.MiddlewareFunc { | |
| 18 | - | proxyURL := a.Config.Build.ProxyURL | |
| 19 | - | urlCheckVersion := a.Config.Build.ProxyURL + "/version" | |
| 20 | - | ||
| 21 | - | // Base directives | |
| 22 | - | defaultSrc := "'self'" | |
| 23 | - | styleSrc := "'self' 'unsafe-inline'" | |
| 24 | - | fontSrc := "'self' data:" | |
| 25 | - | imgSrc := "'self' data:" | |
| 26 | - | connectSrc := fmt.Sprintf("'self' %s %s", proxyURL, urlCheckVersion) | |
| 27 | - | ||
| 28 | - | // Start building script-src | |
| 29 | - | scriptSrc := "'self' 'unsafe-inline' 'unsafe-eval'" | |
| 30 | - | ||
| 31 | - | // Conditionally add Cloudflare Insights if SkipCertificates is true | |
| 32 | - | if a.Config.ReverseProxy.SkipCertificates { | |
| 33 | - | scriptSrc += " https://static.cloudflareinsights.com" | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // Construct the final CSP string | |
| 37 | - | csp := fmt.Sprintf( | |
| 38 | - | "default-src %s; style-src %s; font-src %s; img-src %s; script-src %s; connect-src %s", | |
| 39 | - | defaultSrc, styleSrc, fontSrc, imgSrc, scriptSrc, connectSrc, | |
| 40 | - | ) | |
| 41 | - | ||
| 42 | - | return middleware.SecureWithConfig(middleware.SecureConfig{ | |
| 43 | - | XSSProtection: "1; mode=block", | |
| 44 | - | ContentTypeNosniff: "nosniff", | |
| 45 | - | XFrameOptions: "SAMEORIGIN", | |
| 46 | - | HSTSMaxAge: 3600, | |
| 47 | - | HSTSExcludeSubdomains: false, | |
| 48 | - | ContentSecurityPolicy: csp, // Use the dynamically constructed CSP | |
| 49 | - | }) | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | // InitSessionMiddleware initializes the session middleware with secure options | |
| 53 | - | func InitSessionMiddleware(a *server.App) echo.MiddlewareFunc { | |
| 54 | - | isHttps := a.Config.Http.Https | |
| 55 | - | secret := os.Getenv("SESSION_SECRET") | |
| 56 | - | if secret == "" { | |
| 57 | - | log.Fatal("Environment variable SESSION_SECRET is not set or cannot be read") | |
| 58 | - | } | |
| 59 | - | store := sessions.NewCookieStore([]byte(secret)) | |
| 60 | - | store.Options = &sessions.Options{ | |
| 61 | - | Path: "/", | |
| 62 | - | HttpOnly: true, | |
| 63 | - | Secure: isHttps, | |
| 64 | - | MaxAge: 86400, | |
| 65 | - | SameSite: http.SameSiteLaxMode, | |
| 66 | - | } | |
| 67 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 68 | - | return func(c echo.Context) error { | |
| 69 | - | err := session.Middleware(store)(next)(c) | |
| 70 | - | if err != nil { | |
| 71 | - | log.Printf("Error in session middleware: %v", err) | |
| 72 | - | return err | |
| 73 | - | } | |
| 74 | - | _, err = session.Get("session", c) | |
| 75 | - | if err != nil { | |
| 76 | - | log.Printf("Could not retrieve session: %v", err) | |
| 77 | - | } | |
| 78 | - | return nil | |
| 79 | - | } | |
| 80 | - | } | |
| 81 | - | } | |
D internal/httpserve/proxy_init.go +0 -26
| @@ -1,26 +0,0 @@ | |||
| 1 | - | package httpserve | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/proxy" | |
| 5 | - | "github.com/bnema/gordon/internal/server" | |
| 6 | - | "github.com/charmbracelet/log" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // InitializeProxy initializes and starts the reverse proxy | |
| 10 | - | func InitializeProxy(a *server.App) (*proxy.Proxy, error) { | |
| 11 | - | log.Info("Initializing reverse proxy") | |
| 12 | - | ||
| 13 | - | // Create a new proxy | |
| 14 | - | p, err := proxy.NewProxy(a) | |
| 15 | - | if err != nil { | |
| 16 | - | return nil, err | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // Start the proxy | |
| 20 | - | if err := p.Start(); err != nil { | |
| 21 | - | return nil, err | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | log.Info("Reverse proxy initialized and started") | |
| 25 | - | return p, nil | |
| 26 | - | } | |
D internal/httpserve/router.go +0 -294
| @@ -1,294 +0,0 @@ | |||
| 1 | - | package httpserve | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "io/fs" | |
| 5 | - | "net/http" | |
| 6 | - | "os" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/httpserve/handlers" | |
| 9 | - | "github.com/bnema/gordon/internal/httpserve/middleware" | |
| 10 | - | "github.com/bnema/gordon/internal/server" | |
| 11 | - | "github.com/bnema/gordon/internal/webui" // Import the webui package | |
| 12 | - | log "github.com/bnema/gordon/pkg/logger" | |
| 13 | - | _ "github.com/joho/godotenv/autoload" | |
| 14 | - | "github.com/labstack/echo-contrib/session" | |
| 15 | - | "github.com/labstack/echo/v4" | |
| 16 | - | echomid "github.com/labstack/echo/v4/middleware" | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | // RegisterRoutes registers all routes and middlewares | |
| 20 | - | func RegisterRoutes(e *echo.Echo, a *server.App) *echo.Echo { | |
| 21 | - | e.Use(echomid.Recover()) | |
| 22 | - | ||
| 23 | - | // Determine if running in live UI mode | |
| 24 | - | useOS := os.Getenv("GORDON_LIVE_UI") == "true" | |
| 25 | - | staticFileSystem := getStaticFileSystem(useOS) | |
| 26 | - | assetHandler := http.FileServer(staticFileSystem) | |
| 27 | - | ||
| 28 | - | // Serve static files using the asset handler | |
| 29 | - | // We wrap the handler to ensure it works correctly with Echo | |
| 30 | - | // We also need to strip the prefix if accessing assets directly (e.g. /assets/css/...) | |
| 31 | - | // The root path "/" will serve index.html or equivalent by default if present. | |
| 32 | - | e.GET("/*", echo.WrapHandler(assetHandler)) | |
| 33 | - | ||
| 34 | - | // Log admin status | |
| 35 | - | log.Info("Admin WebUI status", | |
| 36 | - | "enabled", a.Config.Admin.IsAdminWebUIEnabled(), | |
| 37 | - | "path", a.Config.Admin.Path) | |
| 38 | - | ||
| 39 | - | // Initiate the session middleware | |
| 40 | - | sessionMiddleware := middleware.InitSessionMiddleware(a) | |
| 41 | - | // Apply session middleware *after* the static file handler setup | |
| 42 | - | // It will only run for routes not handled by the static file server | |
| 43 | - | e.Use(sessionMiddleware) | |
| 44 | - | ||
| 45 | - | // Language detection | |
| 46 | - | e.Use(middleware.LanguageDetection) | |
| 47 | - | ||
| 48 | - | AdminPath := a.Config.Admin.Path | |
| 49 | - | log.Debug("Binding login routes with AdminPath", "path", AdminPath) | |
| 50 | - | bindLoginRoute(e, a, AdminPath) | |
| 51 | - | ||
| 52 | - | log.Debug("Binding API endpoints") | |
| 53 | - | bindAPIEndpoints(e, a) | |
| 54 | - | ||
| 55 | - | // SetCommonDataMiddleware will pass data to the renderer | |
| 56 | - | e.Use(middleware.SetCommonDataMiddleware(a)) | |
| 57 | - | ||
| 58 | - | // Error handler middleware | |
| 59 | - | e.Use(middleware.ErrorHandler) | |
| 60 | - | ||
| 61 | - | // Use middlewares | |
| 62 | - | e.Use(middleware.SecureRoutes(a)) | |
| 63 | - | ||
| 64 | - | // Color scheme detection for dark/light mode | |
| 65 | - | e.Use(middleware.ColorSchemeDetection) | |
| 66 | - | ||
| 67 | - | // Set custom error handler | |
| 68 | - | e.HTTPErrorHandler = middleware.CustomHTTPErrorHandler(a) | |
| 69 | - | ||
| 70 | - | log.Debug("Binding admin routes with AdminPath", "path", AdminPath) | |
| 71 | - | bindAdminRoute(e, a, AdminPath) | |
| 72 | - | ||
| 73 | - | // Only bind HTMX endpoints if Admin WebUI is enabled | |
| 74 | - | if a.Config.Admin.IsAdminWebUIEnabled() { | |
| 75 | - | log.Info("Admin WebUI is enabled, binding HTMX endpoints") | |
| 76 | - | bindHTMXEndpoints(e, a) | |
| 77 | - | } else { | |
| 78 | - | log.Info("Admin WebUI is disabled, skipping HTMX endpoints") | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Protect the root path with a 403 | |
| 82 | - | e.GET("/", func(c echo.Context) error { | |
| 83 | - | return handlers.RenderForbiddenPage(c, a) | |
| 84 | - | }) | |
| 85 | - | ||
| 86 | - | log.Info("All routes registered successfully") | |
| 87 | - | return e | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // bindAPIEndpoints expose /api endpoints and speaks only JSON | |
| 91 | - | func bindAPIEndpoints(e *echo.Echo, a *server.App) { | |
| 92 | - | apiGroup := e.Group("/api") | |
| 93 | - | ||
| 94 | - | // Device flow endpoints (without RequireToken middleware) | |
| 95 | - | apiGroup.POST("/device/code", func(c echo.Context) error { | |
| 96 | - | return handlers.DeviceCodeRequest(c, a) | |
| 97 | - | }) | |
| 98 | - | apiGroup.POST("/device/token", func(c echo.Context) error { | |
| 99 | - | return handlers.DeviceTokenRequest(c, a) | |
| 100 | - | }) | |
| 101 | - | ||
| 102 | - | // Other API endpoints that require a token | |
| 103 | - | protectedApiGroup := apiGroup.Group("", middleware.RequireToken(a)) | |
| 104 | - | protectedApiGroup.GET("/ping", func(c echo.Context) error { | |
| 105 | - | return handlers.GetInfos(c, a) | |
| 106 | - | }) | |
| 107 | - | protectedApiGroup.POST("/deploy", func(c echo.Context) error { | |
| 108 | - | return handlers.PostDeploy(c, a) | |
| 109 | - | }) | |
| 110 | - | protectedApiGroup.POST("/deploy/chunked", func(c echo.Context) error { | |
| 111 | - | return handlers.PostDeployChunked(c, a) | |
| 112 | - | }) | |
| 113 | - | protectedApiGroup.GET("/deploy/check-conflict", func(c echo.Context) error { | |
| 114 | - | return handlers.CheckDeployConflict(c, a) | |
| 115 | - | }) | |
| 116 | - | // Regular push endpoint for small images | |
| 117 | - | protectedApiGroup.POST("/push", func(c echo.Context) error { | |
| 118 | - | return handlers.PostPush(c, a) | |
| 119 | - | }) | |
| 120 | - | ||
| 121 | - | // Chunked push endpoint for large images | |
| 122 | - | protectedApiGroup.POST("/push/chunked", func(c echo.Context) error { | |
| 123 | - | return handlers.PostPushChunked(c, a) | |
| 124 | - | }) | |
| 125 | - | protectedApiGroup.POST("/stop", func(c echo.Context) error { | |
| 126 | - | return handlers.PostContainerStop(c, a) | |
| 127 | - | }) | |
| 128 | - | protectedApiGroup.POST("/remove", func(c echo.Context) error { | |
| 129 | - | return handlers.PostContainerRemove(c, a) | |
| 130 | - | }) | |
| 131 | - | } | |
| 132 | - | ||
| 133 | - | // bindLoginRoute binds all login routes | |
| 134 | - | func bindLoginRoute(e *echo.Echo, a *server.App, adminPath string) { | |
| 135 | - | // Skip binding admin login routes if admin webUI is disabled | |
| 136 | - | if !a.Config.Admin.IsAdminWebUIEnabled() { | |
| 137 | - | log.Debug("Admin WebUI is disabled, skipping admin login routes") | |
| 138 | - | return | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | e.GET(adminPath+"/login", func(c echo.Context) error { | |
| 142 | - | // Check if already logged in based on cookie | |
| 143 | - | sess, _ := session.Get("session", c) // Ignore error for initial check | |
| 144 | - | if sess != nil && sess.Values != nil { | |
| 145 | - | if authenticated, ok := sess.Values["authenticated"].(bool); ok && authenticated { | |
| 146 | - | // Cookie claims authenticated. NOW, validate against the DB too. | |
| 147 | - | err := handlers.ValidateSessionAndUser(c, a) // Use the same validation function as the middleware | |
| 148 | - | if err == nil { | |
| 149 | - | // Session is valid in BOTH cookie AND DB. Proceed to admin panel. | |
| 150 | - | log.Debug("Login page check: Valid session found (cookie & DB), redirecting to admin panel") | |
| 151 | - | return c.Redirect(http.StatusFound, a.Config.Admin.Path) | |
| 152 | - | } | |
| 153 | - | // If DB validation failed, log it and fall through to render login page | |
| 154 | - | log.Warn("Login page check: Cookie session present but DB validation failed", "error", err) | |
| 155 | - | // NOTE: Consider invalidating the inconsistent cookie here? | |
| 156 | - | // handlers.InvalidateSessionCookie(c, sess) // Optional: Force cookie cleanup | |
| 157 | - | } | |
| 158 | - | } | |
| 159 | - | // If no session cookie, not authenticated in cookie, or DB validation failed: render login page. | |
| 160 | - | log.Debug("Login page check: No valid session found or DB validation failed, rendering login page") | |
| 161 | - | // Always use the Templ-based login page renderer | |
| 162 | - | return handlers.RenderTemplLoginPage(c, a) | |
| 163 | - | }) | |
| 164 | - | // Removed POST /login/submit-token route | |
| 165 | - | e.GET(adminPath+"/login/oauth/github", func(c echo.Context) error { | |
| 166 | - | return handlers.StartOAuthGithub(c, a) | |
| 167 | - | }) | |
| 168 | - | e.GET("/callback", func(c echo.Context) error { | |
| 169 | - | log.Debug("OAuth callback endpoint hit", "path", c.Path(), "query", c.QueryString()) | |
| 170 | - | return handlers.OAuthCallback(c, a) | |
| 171 | - | }) | |
| 172 | - | e.GET(adminPath+"/logout", func(c echo.Context) error { | |
| 173 | - | return handlers.Logout(c, a) | |
| 174 | - | }) | |
| 175 | - | } | |
| 176 | - | ||
| 177 | - | // bindAdminRoute binds all admin routes | |
| 178 | - | func bindAdminRoute(e *echo.Echo, a *server.App, adminPath string) { | |
| 179 | - | // Skip binding admin routes if admin webUI is disabled | |
| 180 | - | if !a.Config.Admin.IsAdminWebUIEnabled() { | |
| 181 | - | log.Debug("Admin WebUI is disabled, skipping admin routes") | |
| 182 | - | return | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | adminGroup := e.Group(adminPath) | |
| 186 | - | // Since login is behind the admin path, we cannot use group middleware | |
| 187 | - | adminGroup.GET("", func(c echo.Context) error { | |
| 188 | - | return c.Redirect(302, adminPath+"/manager") | |
| 189 | - | }, middleware.RequireLogin(a)) | |
| 190 | - | ||
| 191 | - | adminGroup.GET("/manager", func(c echo.Context) error { | |
| 192 | - | return handlers.RenderTemplManagerPage(c, a) | |
| 193 | - | }, middleware.RequireLogin(a)) | |
| 194 | - | adminGroup.GET("/cc/:ID", func(c echo.Context) error { | |
| 195 | - | return handlers.CreateContainerFullGET(c, a) | |
| 196 | - | }, middleware.RequireLogin(a)) | |
| 197 | - | ||
| 198 | - | // Add a ping endpoint for health checks and connection testing | |
| 199 | - | // This endpoint doesn't require authentication since it's used by the proxy for connection testing | |
| 200 | - | adminGroup.GET("/ping", func(c echo.Context) error { | |
| 201 | - | log.Debug("Ping endpoint hit from:", "ip", c.RealIP()) | |
| 202 | - | return c.JSON(200, map[string]string{ | |
| 203 | - | "status": "ok", | |
| 204 | - | "message": "Gordon admin server is running", | |
| 205 | - | }) | |
| 206 | - | }) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // bindHTMXEndpoints binds all HTMX endpoints | |
| 210 | - | func bindHTMXEndpoints(e *echo.Echo, a *server.App) { | |
| 211 | - | // Skip if AdminWebUI is disabled | |
| 212 | - | if !a.Config.Admin.IsAdminWebUIEnabled() { | |
| 213 | - | log.Debug("Admin WebUI is disabled, skipping HTMX endpoints") | |
| 214 | - | return | |
| 215 | - | } | |
| 216 | - | ||
| 217 | - | // Create a group for /htmx endpoints | |
| 218 | - | htmxGroup := e.Group("/htmx") | |
| 219 | - | ||
| 220 | - | // Apply middleware to the group, so all endpoints are protected | |
| 221 | - | htmxGroup.Use(middleware.RequireLogin(a)) | |
| 222 | - | ||
| 223 | - | // List all images component | |
| 224 | - | htmxGroup.GET("/image-manager", func(c echo.Context) error { | |
| 225 | - | return handlers.ImageManagerComponent(c, a) | |
| 226 | - | }) | |
| 227 | - | // Delete an image | |
| 228 | - | htmxGroup.DELETE("/image-manager/delete/:ID", func(c echo.Context) error { | |
| 229 | - | return handlers.ImageManagerDelete(c, a) | |
| 230 | - | }) | |
| 231 | - | // Purge unused images | |
| 232 | - | htmxGroup.POST("/image-manager/prune", func(c echo.Context) error { | |
| 233 | - | return handlers.ImageManagerPrune(c, a) | |
| 234 | - | }) | |
| 235 | - | ||
| 236 | - | // List all containers | |
| 237 | - | htmxGroup.GET("/container-manager", func(c echo.Context) error { | |
| 238 | - | return handlers.RenderTemplContainerList(c, a) | |
| 239 | - | }) | |
| 240 | - | // Stop a container | |
| 241 | - | htmxGroup.POST("/container-manager/stop/:ID", func(c echo.Context) error { | |
| 242 | - | return handlers.ContainerManagerStop(c, a) | |
| 243 | - | }) | |
| 244 | - | // Delete a container | |
| 245 | - | htmxGroup.DELETE("/container-manager/delete/:ID", func(c echo.Context) error { | |
| 246 | - | return handlers.ContainerManagerDelete(c, a) | |
| 247 | - | }) | |
| 248 | - | // Start a container | |
| 249 | - | htmxGroup.POST("/container-manager/start/:ID", func(c echo.Context) error { | |
| 250 | - | return handlers.ContainerManagerStart(c, a) | |
| 251 | - | }) | |
| 252 | - | // Edit a container view | |
| 253 | - | htmxGroup.GET("/container-manager/edit/:ID", func(c echo.Context) error { | |
| 254 | - | return handlers.ContainerManagerEditGET(c, a) | |
| 255 | - | }) | |
| 256 | - | // Edit a container action | |
| 257 | - | htmxGroup.POST("/container-manager/edit/:ID", func(c echo.Context) error { | |
| 258 | - | return handlers.ContainerManagerEditPOST(c, a) | |
| 259 | - | }) | |
| 260 | - | ||
| 261 | - | // Display upload-image component | |
| 262 | - | htmxGroup.GET("/upload-image", func(c echo.Context) error { | |
| 263 | - | return handlers.UploadImageGETHandler(c, a) | |
| 264 | - | }) | |
| 265 | - | // Upload image | |
| 266 | - | htmxGroup.POST("/upload-image", func(c echo.Context) error { | |
| 267 | - | return handlers.UploadImagePOSTHandler(c, a) | |
| 268 | - | }) | |
| 269 | - | // Display create-container component | |
| 270 | - | htmxGroup.GET("/create-container/:ID", func(c echo.Context) error { | |
| 271 | - | return handlers.CreateContainerGET(c, a) | |
| 272 | - | }) | |
| 273 | - | // Handle create-container form submission | |
| 274 | - | htmxGroup.POST("/create-container/:ID", func(c echo.Context) error { | |
| 275 | - | return handlers.CreateContainerPOST(c, a) | |
| 276 | - | }) | |
| 277 | - | } | |
| 278 | - | ||
| 279 | - | // getStaticFileSystem returns the appropriate http.FileSystem based on the GORDON_LIVE_UI env var. | |
| 280 | - | func getStaticFileSystem(useOS bool) http.FileSystem { | |
| 281 | - | if useOS { | |
| 282 | - | log.Info("Serving static files from OS filesystem (live mode)") | |
| 283 | - | return http.FS(os.DirFS("internal/webui/public")) | |
| 284 | - | } | |
| 285 | - | ||
| 286 | - | log.Info("Serving static files from embedded filesystem") | |
| 287 | - | // Use the PublicFS from the webui package | |
| 288 | - | fsys, err := fs.Sub(webui.PublicFS, "public") | |
| 289 | - | if err != nil { | |
| 290 | - | panic(err) // Should not happen with embedded files | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | return http.FS(fsys) | |
| 294 | - | } | |
D internal/interfaces/app_interface.go +0 -21
| @@ -1,21 +0,0 @@ | |||
| 1 | - | package interfaces | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/common" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // AppInterface defines the interface that the proxy package needs from the App struct | |
| 10 | - | type AppInterface interface { | |
| 11 | - | // Configuration access | |
| 12 | - | GetConfig() *common.Config | |
| 13 | - | ||
| 14 | - | // Database access | |
| 15 | - | GetDB() *sql.DB | |
| 16 | - | ||
| 17 | - | // Environment and status | |
| 18 | - | IsDevEnvironment() bool | |
| 19 | - | GetUptime() string | |
| 20 | - | GetVersionstring() string | |
| 21 | - | } | |
A internal/middleware/auth.go +246 -0
| @@ -0,0 +1,246 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "crypto/subtle" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "fmt" | |
| 8 | + | "net/http" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/golang-jwt/jwt/v5" | |
| 13 | + | "github.com/rs/zerolog/log" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // BasicAuth middleware provides HTTP Basic Authentication | |
| 17 | + | func BasicAuth(username, password string) func(http.Handler) http.Handler { | |
| 18 | + | return func(next http.Handler) http.Handler { | |
| 19 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 20 | + | if !isAuthenticated(r, username, password) { | |
| 21 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Management API"`) | |
| 22 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 23 | + | ||
| 24 | + | log.Warn(). | |
| 25 | + | Str("method", r.Method). | |
| 26 | + | Str("path", r.URL.Path). | |
| 27 | + | Str("remote_addr", r.RemoteAddr). | |
| 28 | + | Msg("Unauthorized API access attempt") | |
| 29 | + | return | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | next.ServeHTTP(w, r) | |
| 33 | + | }) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // APIKeyAuth middleware provides API key authentication | |
| 38 | + | func APIKeyAuth(validAPIKey string) func(http.Handler) http.Handler { | |
| 39 | + | return func(next http.Handler) http.Handler { | |
| 40 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 41 | + | apiKey := r.Header.Get("X-API-Key") | |
| 42 | + | if apiKey == "" { | |
| 43 | + | apiKey = r.URL.Query().Get("api_key") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | if !isValidAPIKey(apiKey, validAPIKey) { | |
| 47 | + | http.Error(w, "Forbidden - Invalid API Key", http.StatusForbidden) | |
| 48 | + | ||
| 49 | + | log.Warn(). | |
| 50 | + | Str("method", r.Method). | |
| 51 | + | Str("path", r.URL.Path). | |
| 52 | + | Str("remote_addr", r.RemoteAddr). | |
| 53 | + | Msg("Invalid API key access attempt") | |
| 54 | + | return | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | next.ServeHTTP(w, r) | |
| 58 | + | }) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // RegistryAuth middleware provides Docker Registry authentication | |
| 63 | + | func RegistryAuth(username, password string) func(http.Handler) http.Handler { | |
| 64 | + | return func(next http.Handler) http.Handler { | |
| 65 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 66 | + | // Skip auth for registry info endpoint | |
| 67 | + | if r.URL.Path == "/v2/" && r.Method == "GET" { | |
| 68 | + | next.ServeHTTP(w, r) | |
| 69 | + | return | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // Require auth for all other registry operations | |
| 73 | + | if !isAuthenticated(r, username, password) { | |
| 74 | + | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 75 | + | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 76 | + | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 77 | + | ||
| 78 | + | log.Warn(). | |
| 79 | + | Str("method", r.Method). | |
| 80 | + | Str("path", r.URL.Path). | |
| 81 | + | Str("remote_addr", r.RemoteAddr). | |
| 82 | + | Msg("Unauthorized registry access attempt") | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | next.ServeHTTP(w, r) | |
| 87 | + | }) | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // IPWhitelist middleware restricts access to specific IP addresses | |
| 92 | + | func IPWhitelist(allowedIPs []string) func(http.Handler) http.Handler { | |
| 93 | + | return func(next http.Handler) http.Handler { | |
| 94 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 95 | + | clientIP := getClientIP(r) | |
| 96 | + | ||
| 97 | + | // Remove port from IP if present | |
| 98 | + | if colonIndex := strings.LastIndex(clientIP, ":"); colonIndex != -1 { | |
| 99 | + | clientIP = clientIP[:colonIndex] | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | allowed := false | |
| 103 | + | for _, allowedIP := range allowedIPs { | |
| 104 | + | if clientIP == allowedIP || allowedIP == "127.0.0.1" && (clientIP == "::1" || clientIP == "localhost") { | |
| 105 | + | allowed = true | |
| 106 | + | break | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | if !allowed { | |
| 111 | + | http.Error(w, "Forbidden - IP not allowed", http.StatusForbidden) | |
| 112 | + | ||
| 113 | + | log.Warn(). | |
| 114 | + | Str("method", r.Method). | |
| 115 | + | Str("path", r.URL.Path). | |
| 116 | + | Str("client_ip", clientIP). | |
| 117 | + | Msg("IP not in whitelist") | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | next.ServeHTTP(w, r) | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Helper functions | |
| 127 | + | ||
| 128 | + | func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) bool { | |
| 129 | + | username, password, ok := r.BasicAuth() | |
| 130 | + | if !ok { | |
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // Use constant-time comparison to prevent timing attacks | |
| 135 | + | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |
| 136 | + | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |
| 137 | + | ||
| 138 | + | return usernameMatch && passwordMatch | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func isValidAPIKey(provided, expected string) bool { | |
| 142 | + | if provided == "" || expected == "" { | |
| 143 | + | return false | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | // Use constant-time comparison to prevent timing attacks | |
| 147 | + | return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1 | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // GordonClaims represents the JWT claims for Gordon tokens | |
| 151 | + | type GordonClaims struct { | |
| 152 | + | Username string `json:"username"` | |
| 153 | + | Roles []string `json:"roles"` | |
| 154 | + | jwt.RegisteredClaims | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // JWTAuth middleware provides JWT token authentication | |
| 158 | + | func JWTAuth(jwtSecret string) func(http.Handler) http.Handler { | |
| 159 | + | return func(next http.Handler) http.Handler { | |
| 160 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 161 | + | // Extract token from Authorization header | |
| 162 | + | authHeader := r.Header.Get("Authorization") | |
| 163 | + | if authHeader == "" { | |
| 164 | + | http.Error(w, "Authorization header required", http.StatusUnauthorized) | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Check for Bearer token | |
| 169 | + | tokenString := "" | |
| 170 | + | if strings.HasPrefix(authHeader, "Bearer ") { | |
| 171 | + | tokenString = strings.TrimPrefix(authHeader, "Bearer ") | |
| 172 | + | } else { | |
| 173 | + | http.Error(w, "Bearer token required", http.StatusUnauthorized) | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Parse and validate token | |
| 178 | + | token, err := jwt.ParseWithClaims(tokenString, &GordonClaims{}, func(token *jwt.Token) (interface{}, error) { | |
| 179 | + | // Validate signing method | |
| 180 | + | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
| 181 | + | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) | |
| 182 | + | } | |
| 183 | + | return []byte(jwtSecret), nil | |
| 184 | + | }) | |
| 185 | + | ||
| 186 | + | if err != nil { | |
| 187 | + | log.Warn().Err(err). | |
| 188 | + | Str("method", r.Method). | |
| 189 | + | Str("path", r.URL.Path). | |
| 190 | + | Str("remote_addr", r.RemoteAddr). | |
| 191 | + | Msg("Invalid JWT token") | |
| 192 | + | http.Error(w, "Invalid token", http.StatusUnauthorized) | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | if !token.Valid { | |
| 197 | + | http.Error(w, "Invalid token", http.StatusUnauthorized) | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // Token is valid, continue | |
| 202 | + | next.ServeHTTP(w, r) | |
| 203 | + | }) | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // GenerateJWTSecret generates a secure random secret for JWT signing | |
| 208 | + | func GenerateJWTSecret() (string, error) { | |
| 209 | + | bytes := make([]byte, 32) // 256 bits | |
| 210 | + | if _, err := rand.Read(bytes); err != nil { | |
| 211 | + | return "", fmt.Errorf("failed to generate JWT secret: %w", err) | |
| 212 | + | } | |
| 213 | + | return base64.URLEncoding.EncodeToString(bytes), nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | // GenerateJWTToken generates a JWT token for a user | |
| 217 | + | func GenerateJWTToken(username string, roles []string, jwtSecret string, validFor time.Duration) (string, error) { | |
| 218 | + | claims := GordonClaims{ | |
| 219 | + | Username: username, | |
| 220 | + | Roles: roles, | |
| 221 | + | RegisteredClaims: jwt.RegisteredClaims{ | |
| 222 | + | Issuer: "gordon", | |
| 223 | + | Subject: username, | |
| 224 | + | ExpiresAt: jwt.NewNumericDate(time.Now().Add(validFor)), | |
| 225 | + | IssuedAt: jwt.NewNumericDate(time.Now()), | |
| 226 | + | NotBefore: jwt.NewNumericDate(time.Now()), | |
| 227 | + | }, | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) | |
| 231 | + | tokenString, err := token.SignedString([]byte(jwtSecret)) | |
| 232 | + | if err != nil { | |
| 233 | + | return "", fmt.Errorf("failed to sign JWT token: %w", err) | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | return tokenString, nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // GenerateSecureAPIKey generates a secure random API key | |
| 240 | + | func GenerateSecureAPIKey() (string, error) { | |
| 241 | + | bytes := make([]byte, 24) // 192 bits, base64 encoded will be ~32 chars | |
| 242 | + | if _, err := rand.Read(bytes); err != nil { | |
| 243 | + | return "", fmt.Errorf("failed to generate API key: %w", err) | |
| 244 | + | } | |
| 245 | + | return base64.URLEncoding.EncodeToString(bytes), nil | |
| 246 | + | }⏎̸ | |
A internal/middleware/logging.go +187 -0
| @@ -0,0 +1,187 @@ | |||
| 1 | + | package middleware | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/rs/zerolog/log" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // ResponseWriter wraps http.ResponseWriter to capture status code and bytes written | |
| 11 | + | type ResponseWriter struct { | |
| 12 | + | http.ResponseWriter | |
| 13 | + | statusCode int | |
| 14 | + | bytes int | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func NewResponseWriter(w http.ResponseWriter) *ResponseWriter { | |
| 18 | + | return &ResponseWriter{ | |
| 19 | + | ResponseWriter: w, | |
| 20 | + | statusCode: http.StatusOK, // Default status code | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (rw *ResponseWriter) WriteHeader(code int) { | |
| 25 | + | rw.statusCode = code | |
| 26 | + | rw.ResponseWriter.WriteHeader(code) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (rw *ResponseWriter) Write(b []byte) (int, error) { | |
| 30 | + | n, err := rw.ResponseWriter.Write(b) | |
| 31 | + | rw.bytes += n | |
| 32 | + | return n, err | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (rw *ResponseWriter) StatusCode() int { | |
| 36 | + | return rw.statusCode | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (rw *ResponseWriter) BytesWritten() int { | |
| 40 | + | return rw.bytes | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // RequestLogger is a middleware that logs HTTP requests | |
| 44 | + | func RequestLogger(next http.Handler) http.Handler { | |
| 45 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 46 | + | start := time.Now() | |
| 47 | + | ||
| 48 | + | // Wrap the response writer to capture status and bytes | |
| 49 | + | rw := NewResponseWriter(w) | |
| 50 | + | ||
| 51 | + | // Get client IP (handle X-Forwarded-For and X-Real-IP headers) | |
| 52 | + | clientIP := getClientIP(r) | |
| 53 | + | ||
| 54 | + | // Call the next handler | |
| 55 | + | next.ServeHTTP(rw, r) | |
| 56 | + | ||
| 57 | + | // Calculate duration | |
| 58 | + | duration := time.Since(start) | |
| 59 | + | ||
| 60 | + | // Log the request | |
| 61 | + | log.Info(). | |
| 62 | + | Str("method", r.Method). | |
| 63 | + | Str("path", r.URL.Path). | |
| 64 | + | Str("query", r.URL.RawQuery). | |
| 65 | + | Str("host", r.Host). | |
| 66 | + | Str("user_agent", r.UserAgent()). | |
| 67 | + | Str("referer", r.Referer()). | |
| 68 | + | Str("client_ip", clientIP). | |
| 69 | + | Int("status", rw.StatusCode()). | |
| 70 | + | Int("bytes", rw.BytesWritten()). | |
| 71 | + | Dur("duration", duration). | |
| 72 | + | Str("proto", r.Proto). | |
| 73 | + | Msg("HTTP Request") | |
| 74 | + | }) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // getClientIP extracts the client IP from various headers | |
| 78 | + | func getClientIP(r *http.Request) string { | |
| 79 | + | // Check X-Forwarded-For header (comma-separated list, first is original client) | |
| 80 | + | if xff := r.Header.Get("X-Forwarded-For"); xff != "" { | |
| 81 | + | // Take the first IP (original client) | |
| 82 | + | for idx := 0; idx < len(xff); idx++ { | |
| 83 | + | if xff[idx] == ',' { | |
| 84 | + | return xff[:idx] | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | return xff | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Check X-Real-IP header | |
| 91 | + | if xri := r.Header.Get("X-Real-IP"); xri != "" { | |
| 92 | + | return xri | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Check X-Forwarded header (less common) | |
| 96 | + | if xf := r.Header.Get("X-Forwarded"); xf != "" { | |
| 97 | + | return xf | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Fallback to RemoteAddr | |
| 101 | + | return r.RemoteAddr | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // ContainerRequestLogger logs requests with container-specific information | |
| 105 | + | func ContainerRequestLogger(containerID string, domain string) func(http.Handler) http.Handler { | |
| 106 | + | return func(next http.Handler) http.Handler { | |
| 107 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 108 | + | start := time.Now() | |
| 109 | + | ||
| 110 | + | // Wrap the response writer to capture status and bytes | |
| 111 | + | rw := NewResponseWriter(w) | |
| 112 | + | ||
| 113 | + | // Get client IP | |
| 114 | + | clientIP := getClientIP(r) | |
| 115 | + | ||
| 116 | + | // Call the next handler | |
| 117 | + | next.ServeHTTP(rw, r) | |
| 118 | + | ||
| 119 | + | // Calculate duration | |
| 120 | + | duration := time.Since(start) | |
| 121 | + | ||
| 122 | + | // Log the request with container information | |
| 123 | + | log.Info(). | |
| 124 | + | Str("method", r.Method). | |
| 125 | + | Str("path", r.URL.Path). | |
| 126 | + | Str("query", r.URL.RawQuery). | |
| 127 | + | Str("host", r.Host). | |
| 128 | + | Str("domain", domain). | |
| 129 | + | Str("container_id", containerID). | |
| 130 | + | Str("user_agent", r.UserAgent()). | |
| 131 | + | Str("referer", r.Referer()). | |
| 132 | + | Str("client_ip", clientIP). | |
| 133 | + | Int("status", rw.StatusCode()). | |
| 134 | + | Int("bytes", rw.BytesWritten()). | |
| 135 | + | Dur("duration", duration). | |
| 136 | + | Str("proto", r.Proto). | |
| 137 | + | Msg("Container Request") | |
| 138 | + | }) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | // PanicRecovery middleware recovers from panics and logs them | |
| 143 | + | func PanicRecovery(next http.Handler) http.Handler { | |
| 144 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 145 | + | defer func() { | |
| 146 | + | if err := recover(); err != nil { | |
| 147 | + | log.Error(). | |
| 148 | + | Interface("panic", err). | |
| 149 | + | Str("method", r.Method). | |
| 150 | + | Str("path", r.URL.Path). | |
| 151 | + | Str("host", r.Host). | |
| 152 | + | Msg("Panic recovered") | |
| 153 | + | ||
| 154 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 155 | + | } | |
| 156 | + | }() | |
| 157 | + | ||
| 158 | + | next.ServeHTTP(w, r) | |
| 159 | + | }) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // CORS middleware adds CORS headers | |
| 163 | + | func CORS(next http.Handler) http.Handler { | |
| 164 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 165 | + | w.Header().Set("Access-Control-Allow-Origin", "*") | |
| 166 | + | w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") | |
| 167 | + | w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") | |
| 168 | + | ||
| 169 | + | // Handle preflight OPTIONS request | |
| 170 | + | if r.Method == "OPTIONS" { | |
| 171 | + | w.WriteHeader(http.StatusOK) | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | next.ServeHTTP(w, r) | |
| 176 | + | }) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | // Chain combines multiple middleware functions | |
| 180 | + | func Chain(middlewares ...func(http.Handler) http.Handler) func(http.Handler) http.Handler { | |
| 181 | + | return func(final http.Handler) http.Handler { | |
| 182 | + | for i := len(middlewares) - 1; i >= 0; i-- { | |
| 183 | + | final = middlewares[i](final) | |
| 184 | + | } | |
| 185 | + | return final | |
| 186 | + | } | |
| 187 | + | }⏎̸ | |
D internal/proxy/acme.go +0 -606
| @@ -1,606 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "crypto" | |
| 5 | - | "crypto/tls" | |
| 6 | - | "database/sql" | |
| 7 | - | "encoding/json" | |
| 8 | - | "fmt" | |
| 9 | - | "os" | |
| 10 | - | "strings" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 14 | - | "github.com/bnema/gordon/internal/interfaces" | |
| 15 | - | "github.com/bnema/gordon/pkg/logger" | |
| 16 | - | "github.com/go-acme/lego/v4/certcrypto" | |
| 17 | - | "github.com/go-acme/lego/v4/certificate" | |
| 18 | - | "github.com/go-acme/lego/v4/challenge/http01" | |
| 19 | - | "github.com/go-acme/lego/v4/lego" | |
| 20 | - | "github.com/go-acme/lego/v4/providers/dns" | |
| 21 | - | "github.com/go-acme/lego/v4/registration" | |
| 22 | - | ) | |
| 23 | - | ||
| 24 | - | // CertManagerConfig holds configuration for the certificate manager | |
| 25 | - | type CertManagerConfig struct { | |
| 26 | - | CertDir string | |
| 27 | - | Email string | |
| 28 | - | Mode string | |
| 29 | - | SkipCertificates bool | |
| 30 | - | AdminDomain string | |
| 31 | - | RootDomain string | |
| 32 | - | HttpPort string | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // CertificateManager handles ACME certificate operations | |
| 36 | - | type CertificateManager struct { | |
| 37 | - | config CertManagerConfig | |
| 38 | - | client *lego.Client | |
| 39 | - | user *AcmeUser | |
| 40 | - | db *sql.DB | |
| 41 | - | queries *queries.ProxyQueries | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // AcmeUser implements the registration.User interface | |
| 45 | - | type AcmeUser struct { | |
| 46 | - | Email string `json:"email"` | |
| 47 | - | Registration *registration.Resource `json:"registration"` | |
| 48 | - | key crypto.PrivateKey `json:"-"` // Exclude key from JSON marshal | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // GetEmail returns the user's email | |
| 52 | - | func (u *AcmeUser) GetEmail() string { | |
| 53 | - | return u.Email | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | // GetRegistration returns the user's registration | |
| 57 | - | func (u *AcmeUser) GetRegistration() *registration.Resource { | |
| 58 | - | return u.Registration | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // GetPrivateKey returns the user's private key | |
| 62 | - | func (u *AcmeUser) GetPrivateKey() crypto.PrivateKey { | |
| 63 | - | return u.key | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // NewCertificateManager creates a new certificate manager | |
| 67 | - | func NewCertificateManager(config CertManagerConfig, app interfaces.AppInterface, queries *queries.ProxyQueries) (*CertificateManager, error) { | |
| 68 | - | logger.Debug("Initializing certificate manager", "config", config) | |
| 69 | - | ||
| 70 | - | db := app.GetDB() // Get DB connection | |
| 71 | - | ||
| 72 | - | // --- User Loading/Creation --- | |
| 73 | - | user, err := loadAcmeUserFromDB(config.Email, db, queries) | |
| 74 | - | ||
| 75 | - | if err != nil { | |
| 76 | - | // Check if the error is "user not found" | |
| 77 | - | if err == sql.ErrNoRows { | |
| 78 | - | logger.Info("No existing ACME user found in DB, creating new one.", "email", config.Email) | |
| 79 | - | ||
| 80 | - | // Create a new key | |
| 81 | - | privateKey, keyErr := certcrypto.GeneratePrivateKey(certcrypto.RSA2048) | |
| 82 | - | if keyErr != nil { | |
| 83 | - | return nil, fmt.Errorf("failed to generate private key: %w", keyErr) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | user = &AcmeUser{ | |
| 87 | - | Email: config.Email, | |
| 88 | - | key: privateKey, | |
| 89 | - | // Registration will be nil initially | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // Save the new user (key only for now, registration later) | |
| 93 | - | if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil { | |
| 94 | - | // Log the error but proceed, registration might fix it or fail later | |
| 95 | - | logger.Error("Failed to save newly generated ACME user key to DB", "error", saveErr) | |
| 96 | - | // Depending on policy, might want to return error here: | |
| 97 | - | // return nil, fmt.Errorf("failed to save initial ACME user: %w", saveErr) | |
| 98 | - | } else { | |
| 99 | - | logger.Debug("Saved ACME user details to DB", "email", user.Email) | |
| 100 | - | } | |
| 101 | - | } else { | |
| 102 | - | // Handle other DB errors during load | |
| 103 | - | return nil, fmt.Errorf("failed to load ACME user from DB: %w", err) | |
| 104 | - | } | |
| 105 | - | } else { | |
| 106 | - | logger.Debug("Loaded existing ACME user from DB", "email", user.Email) | |
| 107 | - | } | |
| 108 | - | // --- End User Loading/Creation --- | |
| 109 | - | ||
| 110 | - | // Create the lego config using the loaded/created user | |
| 111 | - | legoConfig := lego.NewConfig(user) | |
| 112 | - | legoConfig.Certificate.KeyType = certcrypto.RSA2048 // Match key type used | |
| 113 | - | ||
| 114 | - | // Set the server based on mode | |
| 115 | - | if config.Mode == "staging" { | |
| 116 | - | logger.Info("Using Let's Encrypt staging environment") | |
| 117 | - | legoConfig.CADirURL = lego.LEDirectoryStaging | |
| 118 | - | } else { | |
| 119 | - | logger.Info("Using Let's Encrypt production environment") | |
| 120 | - | legoConfig.CADirURL = lego.LEDirectoryProduction | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | // Create the lego client | |
| 124 | - | client, err := lego.NewClient(legoConfig) | |
| 125 | - | if err != nil { | |
| 126 | - | return nil, fmt.Errorf("failed to create lego client: %w", err) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // --- ACME User Registration --- | |
| 130 | - | if user.Registration == nil { | |
| 131 | - | logger.Info("ACME user registration not found, attempting to register", "email", user.Email) | |
| 132 | - | reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) | |
| 133 | - | if err != nil { | |
| 134 | - | // Check if it's an "already registered" error, which is fine | |
| 135 | - | // Note: Lego might handle this implicitly, but explicit check is safer. | |
| 136 | - | // The exact error type/message might vary depending on lego version and ACME server. | |
| 137 | - | // We might need to refine this error check based on observed behavior. | |
| 138 | - | if strings.Contains(err.Error(), "urn:ietf:params:acme:error:accountDoesNotExist") { | |
| 139 | - | // This might indicate a mismatch between loaded key and LE's record. | |
| 140 | - | // We might need to re-create the key and re-register, or query the account by key. | |
| 141 | - | // For now, just log and return error. | |
| 142 | - | logger.Error("ACME account registration failed: Account does not exist (key mismatch?)", "error", err) | |
| 143 | - | return nil, fmt.Errorf("failed to register ACME user (account not found): %w", err) | |
| 144 | - | } else if strings.Contains(err.Error(), "user already exists") { // Example, adjust as needed | |
| 145 | - | logger.Info("ACME user is already registered with the server.") | |
| 146 | - | // Try to resolve the account details if possible | |
| 147 | - | // reg, err = client.Registration.ResolveAccountByKey() // Requires lego client supporting this | |
| 148 | - | // if err != nil { | |
| 149 | - | // logger.Error("Failed to resolve existing ACME account by key", "error", err) | |
| 150 | - | // return nil, fmt.Errorf("failed to resolve existing ACME account: %w", err) | |
| 151 | - | // } | |
| 152 | - | // For now, let's assume if registration failed with "exists", we might need manual intervention | |
| 153 | - | // or a more sophisticated recovery mechanism. Returning error for safety. | |
| 154 | - | // If Obtain works despite this, we can reconsider. | |
| 155 | - | logger.Error("ACME user registration failed, but server indicates user exists. Manual check might be needed.", "error", err) | |
| 156 | - | return nil, fmt.Errorf("failed to register ACME user (already exists conflict): %w", err) | |
| 157 | - | } else { | |
| 158 | - | // Handle other registration errors | |
| 159 | - | logger.Error("ACME user registration failed", "error", err) | |
| 160 | - | return nil, fmt.Errorf("failed to register ACME user: %w", err) | |
| 161 | - | } | |
| 162 | - | } else { | |
| 163 | - | logger.Info("ACME user registered successfully", "uri", reg.URI) | |
| 164 | - | user.Registration = reg // Store the registration resource | |
| 165 | - | ||
| 166 | - | // Save the user again, now with registration info | |
| 167 | - | if saveErr := saveAcmeUserToDB(user, db, queries); saveErr != nil { | |
| 168 | - | logger.Error("Failed to save ACME user registration info to DB", "error", saveErr) | |
| 169 | - | // Continue, as registration itself succeeded, but log the failure | |
| 170 | - | } else { | |
| 171 | - | logger.Debug("Saved ACME user registration info to DB") | |
| 172 | - | } | |
| 173 | - | } | |
| 174 | - | } else { | |
| 175 | - | logger.Debug("Existing ACME user registration loaded from DB", "uri", user.Registration.URI) | |
| 176 | - | // Optional: Could add a check here to ensure the registration is still valid | |
| 177 | - | } | |
| 178 | - | // --- End ACME User Registration --- | |
| 179 | - | ||
| 180 | - | // Create the certificate manager struct | |
| 181 | - | cm := &CertificateManager{ | |
| 182 | - | config: config, | |
| 183 | - | user: user, | |
| 184 | - | client: client, | |
| 185 | - | db: db, | |
| 186 | - | queries: queries, | |
| 187 | - | } | |
| 188 | - | ||
| 189 | - | return cm, nil | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // loadAcmeUserFromDB loads the user's private key and registration info from the DB | |
| 193 | - | func loadAcmeUserFromDB(email string, db *sql.DB, queries *queries.ProxyQueries) (*AcmeUser, error) { | |
| 194 | - | var privateKeyPEM string | |
| 195 | - | var registrationJSON sql.NullString | |
| 196 | - | ||
| 197 | - | err := db.QueryRow(queries.GetAcmeAccountByEmail, email).Scan(&privateKeyPEM, ®istrationJSON) | |
| 198 | - | if err != nil { | |
| 199 | - | if err == sql.ErrNoRows { | |
| 200 | - | logger.Debug("ACME user not found in DB", "email", email) | |
| 201 | - | return nil, sql.ErrNoRows // Return specific error | |
| 202 | - | } | |
| 203 | - | return nil, fmt.Errorf("failed to query ACME user: %w", err) | |
| 204 | - | } | |
| 205 | - | ||
| 206 | - | // Add detailed logging for the loaded registration info | |
| 207 | - | logger.Debug("Raw registration_info from DB", "email", email, "Valid", registrationJSON.Valid, "String", registrationJSON.String) | |
| 208 | - | ||
| 209 | - | // Parse the private key | |
| 210 | - | privateKey, err := certcrypto.ParsePEMPrivateKey([]byte(privateKeyPEM)) | |
| 211 | - | if err != nil { | |
| 212 | - | return nil, fmt.Errorf("failed to parse private key from DB: %w", err) | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | user := &AcmeUser{ | |
| 216 | - | Email: email, | |
| 217 | - | key: privateKey, | |
| 218 | - | } | |
| 219 | - | ||
| 220 | - | // Load the registration info if it exists | |
| 221 | - | if registrationJSON.Valid && registrationJSON.String != "" { | |
| 222 | - | // Need to unmarshal into a registration.Resource pointer | |
| 223 | - | reg := ®istration.Resource{} | |
| 224 | - | err = json.Unmarshal([]byte(registrationJSON.String), reg) | |
| 225 | - | if err != nil { | |
| 226 | - | // Log the error but potentially continue without registration? Or return error? | |
| 227 | - | // Returning error is safer. | |
| 228 | - | logger.Error("Failed to unmarshal ACME registration info from DB", "email", email, "error", err) | |
| 229 | - | return nil, fmt.Errorf("failed to unmarshal registration info: %w", err) | |
| 230 | - | } | |
| 231 | - | user.Registration = reg // Assign the pointer | |
| 232 | - | logger.Debug("Loaded ACME user registration info from DB", "email", email, "uri", user.Registration.URI) | |
| 233 | - | } else { | |
| 234 | - | logger.Debug("ACME user registration info not found or null in DB", "email", email) | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | return user, nil | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // saveAcmeUserToDB saves the user's private key and registration info to the DB | |
| 241 | - | func saveAcmeUserToDB(user *AcmeUser, db *sql.DB, queries *queries.ProxyQueries) error { | |
| 242 | - | if user.key == nil { | |
| 243 | - | return fmt.Errorf("cannot save user without a private key") | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // Encode private key to PEM | |
| 247 | - | privateKeyPEM := certcrypto.PEMEncode(user.key) | |
| 248 | - | ||
| 249 | - | // Marshal registration info to JSON (handle nil) | |
| 250 | - | var registrationJSON sql.NullString | |
| 251 | - | if user.Registration != nil { | |
| 252 | - | regBytes, err := json.Marshal(user.Registration) | |
| 253 | - | if err != nil { | |
| 254 | - | return fmt.Errorf("failed to marshal registration info: %w", err) | |
| 255 | - | } | |
| 256 | - | registrationJSON = sql.NullString{String: string(regBytes), Valid: true} | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | now := time.Now().Format(time.RFC3339) | |
| 260 | - | ||
| 261 | - | // Use InsertOrReplace query | |
| 262 | - | _, err := db.Exec(queries.InsertOrReplaceAcmeAccount, | |
| 263 | - | user.Email, | |
| 264 | - | string(privateKeyPEM), | |
| 265 | - | registrationJSON, // Pass the NullString directly | |
| 266 | - | now, // created_at (will be updated on replace) | |
| 267 | - | now, // updated_at | |
| 268 | - | ) | |
| 269 | - | ||
| 270 | - | if err != nil { | |
| 271 | - | return fmt.Errorf("failed to save ACME user to DB: %w", err) | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | logger.Debug("Saved ACME user details to DB", "email", user.Email) | |
| 275 | - | return nil | |
| 276 | - | } | |
| 277 | - | ||
| 278 | - | // SetupChallengeProvider sets up the appropriate challenge provider for a domain | |
| 279 | - | func (cm *CertificateManager) SetupChallengeProvider(domainName string, proxy *Proxy) error { | |
| 280 | - | // Get domain configuration from database | |
| 281 | - | var domain struct { | |
| 282 | - | AcmeEnabled bool | |
| 283 | - | AcmeChallengeType sql.NullString | |
| 284 | - | AcmeDnsProvider sql.NullString | |
| 285 | - | AcmeDnsCredentialsRef sql.NullString | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | err := proxy.app.GetDB().QueryRow(` | |
| 289 | - | SELECT acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref | |
| 290 | - | FROM domain WHERE name = ? | |
| 291 | - | `, domainName).Scan(&domain.AcmeEnabled, &domain.AcmeChallengeType, &domain.AcmeDnsProvider, &domain.AcmeDnsCredentialsRef) | |
| 292 | - | ||
| 293 | - | if err != nil { | |
| 294 | - | return fmt.Errorf("failed to get domain configuration: %w", err) | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Determine challenge type | |
| 298 | - | challengeType := domain.AcmeChallengeType.String | |
| 299 | - | if !domain.AcmeChallengeType.Valid { | |
| 300 | - | challengeType = proxy.config.DefaultChallengeType | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | // Set up the appropriate challenge provider | |
| 304 | - | switch challengeType { | |
| 305 | - | case "http-01": | |
| 306 | - | // Use the configured HTTP challenge port | |
| 307 | - | port := proxy.config.DefaultHttpChallengePort | |
| 308 | - | if port == "" { | |
| 309 | - | port = "80" | |
| 310 | - | } | |
| 311 | - | provider := http01.NewProviderServer("", port) | |
| 312 | - | if err := cm.client.Challenge.SetHTTP01Provider(provider); err != nil { | |
| 313 | - | return fmt.Errorf("failed to set HTTP-01 provider: %w", err) | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | case "dns-01": | |
| 317 | - | if domain.AcmeDnsProvider.String == "" { | |
| 318 | - | return fmt.Errorf("DNS provider not configured for domain %s", domainName) | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | // Get DNS provider credentials from environment | |
| 322 | - | creds := cm.getDNSProviderCredentials(domain.AcmeDnsCredentialsRef.String) | |
| 323 | - | if creds == nil { | |
| 324 | - | return fmt.Errorf("DNS provider credentials not found for domain %s", domainName) | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | // Create and configure the DNS provider | |
| 328 | - | provider, err := dns.NewDNSChallengeProviderByName(domain.AcmeDnsProvider.String) | |
| 329 | - | if err != nil { | |
| 330 | - | return fmt.Errorf("failed to create DNS provider: %w", err) | |
| 331 | - | } | |
| 332 | - | ||
| 333 | - | // Configure the provider with credentials | |
| 334 | - | if err := provider.Present(domainName, "", ""); err != nil { | |
| 335 | - | return fmt.Errorf("failed to configure DNS provider: %w", err) | |
| 336 | - | } | |
| 337 | - | ||
| 338 | - | // Set DNS timeouts | |
| 339 | - | if dnsProvider, ok := provider.(interface { | |
| 340 | - | SetPropagationTimeout(time.Duration) | |
| 341 | - | SetPollingInterval(time.Duration) | |
| 342 | - | }); ok { | |
| 343 | - | dnsProvider.SetPropagationTimeout( | |
| 344 | - | time.Duration(proxy.config.DefaultDnsChallengePropagationTimeout) * time.Second, | |
| 345 | - | ) | |
| 346 | - | dnsProvider.SetPollingInterval( | |
| 347 | - | time.Duration(proxy.config.DefaultDnsChallengePollingInterval) * time.Second, | |
| 348 | - | ) | |
| 349 | - | } | |
| 350 | - | ||
| 351 | - | if err := cm.client.Challenge.SetDNS01Provider(provider); err != nil { | |
| 352 | - | return fmt.Errorf("failed to set DNS-01 provider: %w", err) | |
| 353 | - | } | |
| 354 | - | ||
| 355 | - | default: | |
| 356 | - | return fmt.Errorf("unsupported challenge type: %s", challengeType) | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | return nil | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | // GetKeyAuthorization returns the key authorization for a token | |
| 363 | - | func (cm *CertificateManager) GetKeyAuthorization(token string) (string, error) { | |
| 364 | - | // Create a new HTTP-01 provider server | |
| 365 | - | provider := http01.NewProviderServer("", cm.config.HttpPort) | |
| 366 | - | ||
| 367 | - | // Present the challenge | |
| 368 | - | err := provider.Present("", token, "") | |
| 369 | - | if err != nil { | |
| 370 | - | return "", fmt.Errorf("failed to present challenge: %w", err) | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | // Get the key authorization from the provider | |
| 374 | - | path := http01.ChallengePath(token) | |
| 375 | - | return path, nil | |
| 376 | - | } | |
| 377 | - | ||
| 378 | - | // ObtainCertificate obtains a new certificate for a domain | |
| 379 | - | func (cm *CertificateManager) ObtainCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) { | |
| 380 | - | logger.Debug("Obtaining certificate for domain", "domain", domainName) | |
| 381 | - | ||
| 382 | - | // Set up the challenge provider | |
| 383 | - | if err := cm.SetupChallengeProvider(domainName, proxy); err != nil { | |
| 384 | - | return nil, fmt.Errorf("failed to set up challenge provider: %w", err) | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | // Request the certificate | |
| 388 | - | request := certificate.ObtainRequest{ | |
| 389 | - | Domains: []string{domainName}, | |
| 390 | - | Bundle: true, | |
| 391 | - | } | |
| 392 | - | ||
| 393 | - | cert, err := cm.client.Certificate.Obtain(request) | |
| 394 | - | if err != nil { | |
| 395 | - | return nil, fmt.Errorf("failed to obtain certificate: %w", err) | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | // Update the certificate in the database | |
| 399 | - | if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil { | |
| 400 | - | logger.Error("Failed to update certificate in database", "error", err) | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | return cert, nil | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | // updateCertificateInDB updates the certificate information in the database | |
| 407 | - | func (cm *CertificateManager) updateCertificateInDB(domainName string, cert *certificate.Resource, proxy *Proxy) error { | |
| 408 | - | // Get the domain ID | |
| 409 | - | var domainID string | |
| 410 | - | err := proxy.app.GetDB().QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |
| 411 | - | if err != nil { | |
| 412 | - | return fmt.Errorf("failed to get domain ID: %w", err) | |
| 413 | - | } | |
| 414 | - | ||
| 415 | - | // Parse the certificate to get expiry date | |
| 416 | - | x509Cert, err := certcrypto.ParsePEMCertificate(cert.Certificate) | |
| 417 | - | if err != nil { | |
| 418 | - | return fmt.Errorf("failed to parse certificate: %w", err) | |
| 419 | - | } | |
| 420 | - | ||
| 421 | - | // Update or insert the certificate | |
| 422 | - | _, err = proxy.app.GetDB().Exec(` | |
| 423 | - | INSERT OR REPLACE INTO certificate ( | |
| 424 | - | id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status | |
| 425 | - | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) | |
| 426 | - | `, | |
| 427 | - | cert.Domain, | |
| 428 | - | domainID, | |
| 429 | - | string(cert.Certificate), | |
| 430 | - | string(cert.PrivateKey), | |
| 431 | - | time.Now().Format(time.RFC3339), | |
| 432 | - | x509Cert.NotAfter.Format(time.RFC3339), | |
| 433 | - | "Let's Encrypt", | |
| 434 | - | "valid", | |
| 435 | - | ) | |
| 436 | - | ||
| 437 | - | if err != nil { | |
| 438 | - | return fmt.Errorf("failed to update certificate in database: %w", err) | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | return nil | |
| 442 | - | } | |
| 443 | - | ||
| 444 | - | // RenewCertificate renews an existing certificate | |
| 445 | - | func (cm *CertificateManager) RenewCertificate(domainName string, proxy *Proxy) (*certificate.Resource, error) { | |
| 446 | - | logger.Debug("Renewing certificate for domain", "domain", domainName) | |
| 447 | - | ||
| 448 | - | // Set up the challenge provider | |
| 449 | - | if err := cm.SetupChallengeProvider(domainName, proxy); err != nil { | |
| 450 | - | return nil, fmt.Errorf("failed to set up challenge provider: %w", err) | |
| 451 | - | } | |
| 452 | - | ||
| 453 | - | // Get the existing certificate from the database | |
| 454 | - | var certFile, keyFile string | |
| 455 | - | err := proxy.app.GetDB().QueryRow(` | |
| 456 | - | SELECT cert_file, key_file FROM certificate c | |
| 457 | - | JOIN domain d ON c.domain_id = d.id | |
| 458 | - | WHERE d.name = ? | |
| 459 | - | `, domainName).Scan(&certFile, &keyFile) | |
| 460 | - | if err != nil { | |
| 461 | - | return nil, fmt.Errorf("failed to get existing certificate: %w", err) | |
| 462 | - | } | |
| 463 | - | ||
| 464 | - | // Create certificate resource for renewal | |
| 465 | - | cert := &certificate.Resource{ | |
| 466 | - | Domain: domainName, | |
| 467 | - | Certificate: []byte(certFile), | |
| 468 | - | PrivateKey: []byte(keyFile), | |
| 469 | - | } | |
| 470 | - | ||
| 471 | - | // Renew the certificate | |
| 472 | - | _, err = cm.client.Certificate.Renew(*cert, true, false, "") | |
| 473 | - | if err != nil { | |
| 474 | - | return nil, fmt.Errorf("failed to renew certificate: %w", err) | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // Update the certificate in the database | |
| 478 | - | if err := cm.updateCertificateInDB(domainName, cert, proxy); err != nil { | |
| 479 | - | logger.Error("Failed to update certificate in database", "error", err) | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | return cert, nil | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | // GetCertificateForTLS returns a certificate for TLS handshake | |
| 486 | - | func (cm *CertificateManager) GetCertificateForTLS(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 487 | - | logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName) | |
| 488 | - | ||
| 489 | - | // Get the certificate resource from the database | |
| 490 | - | certResource, err := cm.GetCertificate(hello.ServerName) | |
| 491 | - | if err != nil { | |
| 492 | - | // If the certificate is specifically not found in the DB, | |
| 493 | - | // return nil, nil to indicate no certificate is available for this ServerName. | |
| 494 | - | // This allows the TLS handshake to potentially use other certificates or fail gracefully. | |
| 495 | - | if err == sql.ErrNoRows { | |
| 496 | - | logger.Debug("Certificate not found in DB during TLS handshake", "server_name", hello.ServerName) | |
| 497 | - | return nil, nil // Indicate no certificate available for this name | |
| 498 | - | } | |
| 499 | - | // For other errors (DB connection issues, etc.), return the error. | |
| 500 | - | logger.Error("Error getting certificate during TLS handshake", "server_name", hello.ServerName, "error", err) | |
| 501 | - | return nil, fmt.Errorf("failed to get certificate for %s: %w", hello.ServerName, err) | |
| 502 | - | } | |
| 503 | - | ||
| 504 | - | // Convert the retrieved certificate resource to tls.Certificate | |
| 505 | - | tlsCert, err := tls.X509KeyPair(certResource.Certificate, certResource.PrivateKey) | |
| 506 | - | if err != nil { | |
| 507 | - | logger.Error("Failed to create key pair from retrieved certificate", "server_name", hello.ServerName, "error", err) | |
| 508 | - | return nil, fmt.Errorf("failed to parse retrieved certificate for %s: %w", hello.ServerName, err) | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | return &tlsCert, nil | |
| 512 | - | } | |
| 513 | - | ||
| 514 | - | // GetCertificate retrieves a certificate for a domain from the database | |
| 515 | - | func (cm *CertificateManager) GetCertificate(domainName string) (*certificate.Resource, error) { | |
| 516 | - | logger.Debug("Attempting to retrieve certificate from DB", "domain", domainName) | |
| 517 | - | var certPEM, keyPEM string | |
| 518 | - | // Add dummy variables to scan the extra columns we don't need in this function | |
| 519 | - | var issuedAt, expiresAt, issuer, status sql.NullString // Use sql.NullString or appropriate types | |
| 520 | - | ||
| 521 | - | // Query the database using the predefined query (which now returns 6 columns) | |
| 522 | - | err := cm.db.QueryRow(cm.queries.GetCertificateByDomain, domainName).Scan( | |
| 523 | - | &certPEM, &keyPEM, // The columns we need | |
| 524 | - | &issuedAt, &expiresAt, &issuer, &status, // Dummy scans for the rest | |
| 525 | - | ) | |
| 526 | - | ||
| 527 | - | if err != nil { | |
| 528 | - | // Check for ErrNoRows *after* attempting the scan | |
| 529 | - | if err == sql.ErrNoRows { | |
| 530 | - | logger.Warn("Certificate not found in DB for domain, attempting to obtain.", "domain", domainName) | |
| 531 | - | // Certificate not found, try obtaining a new one. | |
| 532 | - | // Note: Need the 'proxy' instance here. This function signature might need adjustment, | |
| 533 | - | // or the obtaining logic should be handled higher up (e.g., in GetCertificateForTLS or the proxy core). | |
| 534 | - | // For now, returning an error to highlight this dependency. | |
| 535 | - | // Alternatively, we could pass the proxy instance or necessary config down. | |
| 536 | - | // Let's return a specific error indicating it needs obtaining. | |
| 537 | - | return nil, fmt.Errorf("certificate not found in DB for domain %s, obtain needed", domainName) | |
| 538 | - | ||
| 539 | - | // --- Alternative: Obtain directly (Requires Proxy instance or refactor) --- | |
| 540 | - | // proxyInstance := ??? // How to get the proxy instance here? | |
| 541 | - | // if proxyInstance == nil { | |
| 542 | - | // return nil, fmt.Errorf("cannot obtain certificate without proxy instance in GetCertificate") | |
| 543 | - | // } | |
| 544 | - | // obtainedCert, obtainErr := cm.ObtainCertificate(domainName, proxyInstance) | |
| 545 | - | // if obtainErr != nil { | |
| 546 | - | // logger.Error("Failed to obtain certificate after not finding in DB", "domain", domainName, "error", obtainErr) | |
| 547 | - | // return nil, fmt.Errorf("certificate not found and obtain failed for %s: %w", domainName, obtainErr) | |
| 548 | - | // } | |
| 549 | - | // logger.Info("Successfully obtained certificate after not finding in DB", "domain", domainName) | |
| 550 | - | // return obtainedCert, nil | |
| 551 | - | // --- End Alternative --- | |
| 552 | - | ||
| 553 | - | } | |
| 554 | - | logger.Error("Failed to query certificate from DB", "domain", domainName, "error", err) | |
| 555 | - | return nil, fmt.Errorf("database error retrieving certificate for %s: %w", domainName, err) | |
| 556 | - | } | |
| 557 | - | ||
| 558 | - | logger.Debug("Successfully retrieved certificate from DB", "domain", domainName) | |
| 559 | - | ||
| 560 | - | // Construct the certificate resource | |
| 561 | - | cert := &certificate.Resource{ | |
| 562 | - | Domain: domainName, | |
| 563 | - | CertURL: "", // Not stored/retrieved from DB in this example | |
| 564 | - | CertStableURL: "", // Not stored/retrieved from DB | |
| 565 | - | PrivateKey: []byte(keyPEM), | |
| 566 | - | Certificate: []byte(certPEM), | |
| 567 | - | IssuerCertificate: nil, // Not typically stored/needed for serving | |
| 568 | - | CSR: nil, // Not stored | |
| 569 | - | } | |
| 570 | - | ||
| 571 | - | return cert, nil | |
| 572 | - | } | |
| 573 | - | ||
| 574 | - | // GetAutocertManager returns the autocert manager | |
| 575 | - | func (cm *CertificateManager) GetAutocertManager() *tls.Config { | |
| 576 | - | return &tls.Config{ | |
| 577 | - | GetCertificate: cm.GetCertificateForTLS, | |
| 578 | - | MinVersion: tls.VersionTLS12, | |
| 579 | - | } | |
| 580 | - | } | |
| 581 | - | ||
| 582 | - | // getDNSProviderCredentials retrieves DNS provider credentials from environment variables | |
| 583 | - | func (cm *CertificateManager) getDNSProviderCredentials(ref string) map[string]string { | |
| 584 | - | if ref == "" { | |
| 585 | - | return nil | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | creds := make(map[string]string) | |
| 589 | - | prefix := fmt.Sprintf("GORDON_DNS_CRED_%s_", ref) | |
| 590 | - | ||
| 591 | - | for _, env := range os.Environ() { | |
| 592 | - | if strings.HasPrefix(env, prefix) { | |
| 593 | - | parts := strings.SplitN(env, "=", 2) | |
| 594 | - | if len(parts) == 2 { | |
| 595 | - | key := strings.TrimPrefix(parts[0], prefix) | |
| 596 | - | creds[key] = parts[1] | |
| 597 | - | } | |
| 598 | - | } | |
| 599 | - | } | |
| 600 | - | ||
| 601 | - | if len(creds) == 0 { | |
| 602 | - | return nil | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | return creds | |
| 606 | - | } | |
D internal/proxy/containers.go +0 -158
| @@ -1,158 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/pkg/docker" | |
| 8 | - | "github.com/bnema/gordon/pkg/logger" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // scanContainersAndCheckCertificates scans all containers from the Gordon network | |
| 12 | - | // and checks if we have certificates for their domains and subdomains | |
| 13 | - | func (p *Proxy) scanContainersAndCheckCertificates() error { | |
| 14 | - | networkName := p.app.GetConfig().ContainerEngine.Network | |
| 15 | - | if networkName == "" { | |
| 16 | - | // If no network is defined, skip container scanning | |
| 17 | - | logger.Info("No container network defined, skipping certificate checks") | |
| 18 | - | return nil | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | logger.Info("Scanning containers in network for certificate checks", | |
| 22 | - | "network", networkName) | |
| 23 | - | ||
| 24 | - | // Try to get network info using the docker client | |
| 25 | - | networkInfo, err := docker.GetNetworkInfo(networkName) | |
| 26 | - | if err != nil { | |
| 27 | - | logger.Warn("Failed to get network info, skipping certificate checks", | |
| 28 | - | "error", err, | |
| 29 | - | "network", networkName) | |
| 30 | - | return nil // Return nil instead of error to continue execution | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // Skip admin cert check if environment variable is set | |
| 34 | - | skipAdminCertCheck := os.Getenv("GORDON_SKIP_ADMIN_CERT_CHECK") == "true" | |
| 35 | - | if skipAdminCertCheck { | |
| 36 | - | logger.Info("Skipping admin certificate check as GORDON_SKIP_ADMIN_CERT_CHECK=true") | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Get all containers in the network | |
| 40 | - | for containerID, containerEndpoint := range networkInfo.Containers { | |
| 41 | - | containerName := strings.TrimPrefix(containerEndpoint.Name, "/") | |
| 42 | - | ||
| 43 | - | // Get container info to check for relevant domains | |
| 44 | - | containerInfo, err := docker.GetContainerInfo(containerID) | |
| 45 | - | if err != nil { | |
| 46 | - | logger.Warn("Failed to get container info", | |
| 47 | - | "containerID", containerID, | |
| 48 | - | "containerName", containerName, | |
| 49 | - | "error", err) | |
| 50 | - | continue | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // Extract domains from container | |
| 54 | - | // First check if there are labels with domain information | |
| 55 | - | domains := []string{} | |
| 56 | - | ||
| 57 | - | // Check common domain-related labels | |
| 58 | - | if domainLabel, exists := containerInfo.Config.Labels["gordon.domain"]; exists && domainLabel != "" { | |
| 59 | - | // Remove any leading dot from domain label | |
| 60 | - | domainLabel = strings.TrimPrefix(domainLabel, ".") | |
| 61 | - | ||
| 62 | - | // Always add the base domain | |
| 63 | - | domains = append(domains, domainLabel) | |
| 64 | - | ||
| 65 | - | // Also check if there's a service label to use as a subdomain | |
| 66 | - | if serviceLabel, serviceExists := containerInfo.Config.Labels["gordon.service"]; serviceExists && serviceLabel != "" { | |
| 67 | - | // Combine service name with domain to create service.domain format | |
| 68 | - | serviceSubdomain := serviceLabel + "." + domainLabel | |
| 69 | - | domains = append(domains, serviceSubdomain) | |
| 70 | - | logger.Debug("Created additional domain from service label", | |
| 71 | - | "domain", serviceSubdomain, | |
| 72 | - | "service", serviceLabel, | |
| 73 | - | "base_domain", domainLabel) | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // Check environment variables for domain information | |
| 78 | - | for _, env := range containerInfo.Config.Env { | |
| 79 | - | if strings.HasPrefix(env, "DOMAIN=") || strings.HasPrefix(env, "VIRTUAL_HOST=") { | |
| 80 | - | parts := strings.SplitN(env, "=", 2) | |
| 81 | - | if len(parts) == 2 && parts[1] != "" { | |
| 82 | - | // Multiple domains might be comma-separated | |
| 83 | - | for _, domain := range strings.Split(parts[1], ",") { | |
| 84 | - | // Remove any leading dot before adding the domain | |
| 85 | - | cleanDomain := strings.TrimPrefix(strings.TrimSpace(domain), ".") | |
| 86 | - | domains = append(domains, cleanDomain) | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | // If no domains were found, use the container name | |
| 93 | - | if len(domains) == 0 { | |
| 94 | - | // Check if there is a proxy route for this container | |
| 95 | - | var found bool | |
| 96 | - | for domain, routeInfo := range p.routes { | |
| 97 | - | if routeInfo.ContainerID == containerID { | |
| 98 | - | domains = append(domains, domain) | |
| 99 | - | found = true | |
| 100 | - | } | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // If no route was found, use container name as fallback | |
| 104 | - | if !found { | |
| 105 | - | // Use container name as subdomain of the default domain | |
| 106 | - | if defaultDomain := p.app.GetConfig().Http.Domain; defaultDomain != "" { | |
| 107 | - | domains = append(domains, containerName+"."+defaultDomain) | |
| 108 | - | } | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Check certificates for each domain | |
| 113 | - | for _, domain := range domains { | |
| 114 | - | // With the refactored CertificateManager, we don't need to manually check | |
| 115 | - | // the cache here. We can just attempt to ensure a certificate exists | |
| 116 | - | // by calling requestDomainCertificate, which delegates to GetCertificate. | |
| 117 | - | // GetCertificate will use autocert's cache and request a new cert only if needed. | |
| 118 | - | ||
| 119 | - | logger.Debug("Ensuring certificate exists for domain", | |
| 120 | - | "domain", domain, | |
| 121 | - | "container", containerName) | |
| 122 | - | ||
| 123 | - | // Request certificate (non-blocking). | |
| 124 | - | // GetCertificate handles checking cache/validity and requesting if necessary. | |
| 125 | - | go p.requestDomainCertificate(domain) // Simplified call | |
| 126 | - | ||
| 127 | - | // If this is a subdomain, ensure the parent domain cert exists too | |
| 128 | - | parts := strings.Split(domain, ".") | |
| 129 | - | if len(parts) >= 3 { | |
| 130 | - | parentDomain := strings.Join(parts[1:], ".") | |
| 131 | - | ||
| 132 | - | // Only check parent domain if it's not an ordinary TLD | |
| 133 | - | // and not the same as the root domain (to avoid redundant checks) | |
| 134 | - | if len(parts) > 2 && !isCommonTLD(parts[len(parts)-1]) && parentDomain != p.app.GetConfig().Http.Domain { | |
| 135 | - | logger.Debug("Ensuring certificate exists for parent domain", | |
| 136 | - | "parent_domain", parentDomain, | |
| 137 | - | "original_domain", domain, | |
| 138 | - | "container", containerName) | |
| 139 | - | ||
| 140 | - | // Request certificate for parent domain also | |
| 141 | - | go p.requestDomainCertificate(parentDomain) // Simplified call | |
| 142 | - | } | |
| 143 | - | } | |
| 144 | - | } | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | return nil | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // isCommonTLD checks if a string is a common top-level domain | |
| 151 | - | func isCommonTLD(tld string) bool { | |
| 152 | - | commonTLDs := map[string]bool{ | |
| 153 | - | "com": true, "org": true, "net": true, "io": true, "co": true, | |
| 154 | - | "ai": true, "app": true, "dev": true, "edu": true, "gov": true, | |
| 155 | - | "info": true, "name": true, "biz": true, "me": true, | |
| 156 | - | } | |
| 157 | - | return commonTLDs[tld] | |
| 158 | - | } | |
D internal/proxy/core.go +0 -357
| @@ -1,357 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "net" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "crypto/tls" | |
| 11 | - | "database/sql" | |
| 12 | - | "net/http" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/gordon/internal/common" | |
| 15 | - | "github.com/bnema/gordon/internal/db/queries" | |
| 16 | - | "github.com/bnema/gordon/internal/interfaces" | |
| 17 | - | "github.com/bnema/gordon/pkg/docker" | |
| 18 | - | "github.com/bnema/gordon/pkg/logger" | |
| 19 | - | "github.com/labstack/echo/v4" | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | - | // - core.go - Core proxy structure and constructor | |
| 23 | - | // - certificates.go - Certificate management functions | |
| 24 | - | // - routes.go - Route management functions | |
| 25 | - | // - middleware.go - HTTP middleware functions | |
| 26 | - | // - server.go - Server startup and shutdown | |
| 27 | - | // - utils.go - Utility functions | |
| 28 | - | ||
| 29 | - | // Proxy represents the reverse proxy server | |
| 30 | - | type Proxy struct { | |
| 31 | - | config common.ReverseProxyConfig | |
| 32 | - | app interfaces.AppInterface | |
| 33 | - | httpsServer *echo.Echo | |
| 34 | - | routes map[string]*ProxyRouteInfo | |
| 35 | - | mu sync.RWMutex | |
| 36 | - | serverStarted bool | |
| 37 | - | ctx context.Context // Context for storing shared data | |
| 38 | - | ||
| 39 | - | // Flag to track specific domain certificate operations (May need review if CertManager handles this) | |
| 40 | - | processingSpecificDomain bool | |
| 41 | - | ||
| 42 | - | // Store Gordon's own container ID at startup for reliable identification | |
| 43 | - | gordonContainerID string | |
| 44 | - | ||
| 45 | - | reverseProxyClient *http.Client | |
| 46 | - | dbMaxRetries int | |
| 47 | - | dbRetryBaseDelay time.Duration | |
| 48 | - | shutdown chan struct{} // Channel for signaling shutdown to background goroutines | |
| 49 | - | ||
| 50 | - | // Track recently created containers to prevent recreation logic | |
| 51 | - | recentContainers map[string]time.Time | |
| 52 | - | recentContainersMu sync.RWMutex | |
| 53 | - | containerCooldownPeriod time.Duration | |
| 54 | - | ||
| 55 | - | // Certificate Management | |
| 56 | - | certificateManager *CertificateManager // New refactored manager | |
| 57 | - | ||
| 58 | - | // SQL Queries | |
| 59 | - | Queries *queries.ProxyQueries | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // NewProxy creates a new instance of the reverse proxy | |
| 63 | - | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |
| 64 | - | logger.Debug("Initializing reverse proxy") | |
| 65 | - | ||
| 66 | - | // Set up the config | |
| 67 | - | config := app.GetConfig().ReverseProxy | |
| 68 | - | ||
| 69 | - | // Check if proxy is disabled through configuration | |
| 70 | - | if !config.Enabled { | |
| 71 | - | logger.Info("Reverse proxy is disabled via configuration") | |
| 72 | - | // Return a non-active proxy instance | |
| 73 | - | return &Proxy{ | |
| 74 | - | config: config, | |
| 75 | - | app: app, | |
| 76 | - | serverStarted: false, | |
| 77 | - | shutdown: make(chan struct{}), | |
| 78 | - | ctx: context.Background(), | |
| 79 | - | }, nil | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Check if running inside a container | |
| 83 | - | isContainer := docker.IsRunningInContainer() | |
| 84 | - | logger.Debug("Container environment check", "is_running_in_container", isContainer) | |
| 85 | - | ||
| 86 | - | if !isContainer { | |
| 87 | - | logger.Info("Gordon is not running in a container, proxy will be disabled automatically") | |
| 88 | - | // Override the enabled setting to false | |
| 89 | - | config.Enabled = false | |
| 90 | - | return &Proxy{ | |
| 91 | - | config: config, | |
| 92 | - | app: app, | |
| 93 | - | serverStarted: false, | |
| 94 | - | shutdown: make(chan struct{}), | |
| 95 | - | ctx: context.Background(), | |
| 96 | - | }, nil | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Set up the echo server for HTTPS traffic | |
| 100 | - | httpsServer := echo.New() | |
| 101 | - | httpsServer.HideBanner = true | |
| 102 | - | ||
| 103 | - | // Initialize container storage and cooldown settings | |
| 104 | - | recentContainers := make(map[string]time.Time) | |
| 105 | - | containerCooldownPeriod := time.Duration(10) * time.Second | |
| 106 | - | // Use a fixed cooldown period of 10 seconds (currently no config option for this) | |
| 107 | - | ||
| 108 | - | // Set up DB retry configuration | |
| 109 | - | dbMaxRetries := 3 | |
| 110 | - | dbRetryBaseDelay := time.Duration(500) * time.Millisecond | |
| 111 | - | ||
| 112 | - | // Initialize the reverseProxyClient with reasonable timeouts | |
| 113 | - | reverseProxyClient := &http.Client{ | |
| 114 | - | Timeout: 30 * time.Second, | |
| 115 | - | Transport: &http.Transport{ | |
| 116 | - | TLSClientConfig: &tls.Config{ | |
| 117 | - | InsecureSkipVerify: true, | |
| 118 | - | }, | |
| 119 | - | // Add more detailed timeout settings | |
| 120 | - | TLSHandshakeTimeout: 10 * time.Second, | |
| 121 | - | ResponseHeaderTimeout: 10 * time.Second, | |
| 122 | - | ExpectContinueTimeout: 5 * time.Second, | |
| 123 | - | // Add a custom dialer with explicit timeouts | |
| 124 | - | DialContext: (&net.Dialer{ | |
| 125 | - | Timeout: 10 * time.Second, | |
| 126 | - | KeepAlive: 30 * time.Second, | |
| 127 | - | DualStack: true, | |
| 128 | - | }).DialContext, | |
| 129 | - | MaxIdleConns: 100, | |
| 130 | - | MaxIdleConnsPerHost: 10, | |
| 131 | - | IdleConnTimeout: 90 * time.Second, | |
| 132 | - | ForceAttemptHTTP2: true, | |
| 133 | - | }, | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | // Initialize the proxy queries | |
| 137 | - | proxyQueries := queries.NewProxyQueries() | |
| 138 | - | ||
| 139 | - | // Create the proxy instance | |
| 140 | - | p := &Proxy{ | |
| 141 | - | config: config, | |
| 142 | - | app: app, | |
| 143 | - | httpsServer: httpsServer, | |
| 144 | - | // httpServer: httpServer, // Removed assignment | |
| 145 | - | routes: make(map[string]*ProxyRouteInfo), | |
| 146 | - | reverseProxyClient: reverseProxyClient, | |
| 147 | - | dbMaxRetries: dbMaxRetries, | |
| 148 | - | dbRetryBaseDelay: dbRetryBaseDelay, | |
| 149 | - | recentContainers: recentContainers, | |
| 150 | - | containerCooldownPeriod: containerCooldownPeriod, | |
| 151 | - | shutdown: make(chan struct{}), | |
| 152 | - | Queries: proxyQueries, | |
| 153 | - | ctx: context.Background(), | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // --- Certificate Manager Setup --- | |
| 157 | - | // Determine cert directory | |
| 158 | - | certDir := config.CertDir | |
| 159 | - | if certDir == "" { | |
| 160 | - | certDir = app.GetConfig().General.StorageDir + "/certs" | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | // Create Certificate Manager Config | |
| 164 | - | certManagerConfig := CertManagerConfig{ | |
| 165 | - | CertDir: certDir, | |
| 166 | - | Email: config.Email, | |
| 167 | - | Mode: config.LetsEncryptMode, // staging or production | |
| 168 | - | SkipCertificates: config.SkipCertificates, | |
| 169 | - | AdminDomain: app.GetConfig().Http.FullDomain(), | |
| 170 | - | RootDomain: app.GetConfig().Http.Domain, | |
| 171 | - | HttpPort: config.DefaultHttpChallengePort, | |
| 172 | - | // RouteValidator: p.isHostInRoutes, // TODO: Pass a function if needed by hostPolicy | |
| 173 | - | } | |
| 174 | - | ||
| 175 | - | // Only initialize Certificate Manager if SkipCertificates is false | |
| 176 | - | if !config.SkipCertificates { | |
| 177 | - | // Create the Certificate Manager | |
| 178 | - | certMgr, err := NewCertificateManager(certManagerConfig, app, p.Queries) | |
| 179 | - | if err != nil { | |
| 180 | - | logger.Error("Failed to initialize Certificate Manager", "error", err) | |
| 181 | - | // Return an error to prevent starting a broken proxy | |
| 182 | - | return nil, fmt.Errorf("failed to initialize certificate manager: %w", err) | |
| 183 | - | } else { | |
| 184 | - | logger.Debug("Certificate manager successfully initialized") | |
| 185 | - | p.certificateManager = certMgr // Store the new manager | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // Ensure admin domain has proper ACME configuration only if manager was created | |
| 189 | - | if err := p.ensureAdminDomainConfig(); err != nil { | |
| 190 | - | logger.Error("Failed to ensure admin domain configuration", "error", err) | |
| 191 | - | return nil, fmt.Errorf("failed to ensure admin domain configuration: %w", err) | |
| 192 | - | } | |
| 193 | - | } else { | |
| 194 | - | logger.Info("Skipping certificate manager initialization because SkipCertificates is true") | |
| 195 | - | } | |
| 196 | - | ||
| 197 | - | // --- End Certificate Manager Setup --- | |
| 198 | - | ||
| 199 | - | // Detect our container ID for later use | |
| 200 | - | p.detectGordonContainer() | |
| 201 | - | ||
| 202 | - | return p, nil | |
| 203 | - | } | |
| 204 | - | ||
| 205 | - | // Close cleans up resources used by the proxy | |
| 206 | - | func (p *Proxy) Close() { | |
| 207 | - | // Signal all background goroutines to stop | |
| 208 | - | close(p.shutdown) | |
| 209 | - | ||
| 210 | - | if p.httpsServer != nil { | |
| 211 | - | if err := p.httpsServer.Close(); err != nil { | |
| 212 | - | logger.Error("Error closing HTTPS server", "error", err) | |
| 213 | - | } | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | logger.Info("Proxy resources cleaned up") | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // requestDomainCertificate requests a certificate for a domain | |
| 220 | - | func (p *Proxy) requestDomainCertificate(domainName string) error { | |
| 221 | - | // If certificate acquisition is globally skipped, do nothing. | |
| 222 | - | if p.config.SkipCertificates { | |
| 223 | - | logger.Debug("Skipping certificate request because SkipCertificates is true", "domain", domainName) | |
| 224 | - | return nil | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | logger.Debug("Requesting certificate for domain", "domain", domainName) | |
| 228 | - | ||
| 229 | - | // Check if we're already processing this domain | |
| 230 | - | if p.processingSpecificDomain { | |
| 231 | - | logger.Debug("Already processing a specific domain, skipping", "domain", domainName) | |
| 232 | - | return nil | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | // Get domain configuration | |
| 236 | - | var acmeEnabled bool | |
| 237 | - | // Use sql.NullString for potentially nullable string columns | |
| 238 | - | var acmeChallengeType, acmeDnsProvider, acmeDnsCredentialsRef sql.NullString | |
| 239 | - | err := p.app.GetDB().QueryRow(p.Queries.GetDomainAcmeConfig, domainName).Scan( | |
| 240 | - | &acmeEnabled, &acmeChallengeType, &acmeDnsProvider, &acmeDnsCredentialsRef, | |
| 241 | - | ) | |
| 242 | - | if err != nil { | |
| 243 | - | // Handle case where the domain might not exist in the acme_configs table yet | |
| 244 | - | if err == sql.ErrNoRows { | |
| 245 | - | logger.Debug("No ACME configuration found for domain, assuming ACME is disabled", "domain", domainName) | |
| 246 | - | return nil // Treat as ACME disabled if no config exists | |
| 247 | - | } | |
| 248 | - | logger.Error("Failed to get domain ACME configuration", "domain", domainName, "error", err) | |
| 249 | - | return err | |
| 250 | - | } | |
| 251 | - | ||
| 252 | - | if !acmeEnabled { | |
| 253 | - | logger.Debug("ACME not enabled for domain", "domain", domainName) | |
| 254 | - | return nil | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // Check if certificate exists and is valid | |
| 258 | - | var certFile, keyFile string | |
| 259 | - | var issuedAtStr, expiresAtStr string // Read timestamps as strings first | |
| 260 | - | var issuer, status string | |
| 261 | - | var issuedAt, expiresAt time.Time // Keep these for use after parsing | |
| 262 | - | ||
| 263 | - | // Scan from DB, reading timestamps as strings | |
| 264 | - | err = p.app.GetDB().QueryRow(p.Queries.GetCertificateByDomain, domainName).Scan( | |
| 265 | - | &certFile, &keyFile, &issuedAtStr, &expiresAtStr, &issuer, &status, | |
| 266 | - | ) | |
| 267 | - | ||
| 268 | - | // Handle potential sql.ErrNoRows if the certificate doesn't exist yet | |
| 269 | - | if err != nil && err != sql.ErrNoRows { | |
| 270 | - | logger.Error("Failed to check existing certificate", "domain", domainName, "error", err) | |
| 271 | - | return err | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | // If a certificate was found (no error or ErrNoRows), parse the timestamps | |
| 275 | - | if err == nil { | |
| 276 | - | issuedAt, err = time.Parse(time.RFC3339, issuedAtStr) | |
| 277 | - | if err != nil { | |
| 278 | - | logger.Error("Failed to parse issued_at timestamp from DB", "domain", domainName, "value", issuedAtStr, "error", err) | |
| 279 | - | // Return the parse error as it indicates data corruption or an unexpected format | |
| 280 | - | return fmt.Errorf("failed to parse issued_at for domain %s: %w", domainName, err) | |
| 281 | - | } | |
| 282 | - | expiresAt, err = time.Parse(time.RFC3339, expiresAtStr) | |
| 283 | - | if err != nil { | |
| 284 | - | logger.Error("Failed to parse expires_at timestamp from DB", "domain", domainName, "value", expiresAtStr, "error", err) | |
| 285 | - | // Return the parse error | |
| 286 | - | return fmt.Errorf("failed to parse expires_at for domain %s: %w", domainName, err) | |
| 287 | - | } | |
| 288 | - | logger.Debug("Successfully scanned and parsed certificate timestamps", "domain", domainName, "issued", issuedAt, "expires", expiresAt) | |
| 289 | - | } | |
| 290 | - | // Note: If err was sql.ErrNoRows, issuedAt/expiresAt will remain zero time.Time, which is handled correctly later. | |
| 291 | - | ||
| 292 | - | // If certificate exists and is valid | |
| 293 | - | if err == nil && status == "valid" { | |
| 294 | - | // Check if certificate needs renewal | |
| 295 | - | renewBefore := time.Duration(p.config.RenewBefore) * 24 * time.Hour | |
| 296 | - | if time.Now().Add(renewBefore).Before(expiresAt) { | |
| 297 | - | logger.Debug("Certificate is still valid and does not need renewal", | |
| 298 | - | "domain", domainName, | |
| 299 | - | "expires_at", expiresAt, | |
| 300 | - | "renew_before_days", p.config.RenewBefore) | |
| 301 | - | return nil // Certificate is valid, no action needed | |
| 302 | - | } | |
| 303 | - | logger.Debug("Certificate requires renewal", "domain", domainName, "expires_at", expiresAt) | |
| 304 | - | } else if err == sql.ErrNoRows { | |
| 305 | - | logger.Debug("No existing certificate found for domain", "domain", domainName) | |
| 306 | - | } else { | |
| 307 | - | logger.Debug("Existing certificate is not valid", "domain", domainName, "status", status, "scan_error", err) | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | // --- Proceed with obtaining/renewing certificate --- | |
| 311 | - | ||
| 312 | - | // Check if required ACME config fields are present now that we know ACME is enabled | |
| 313 | - | if !acmeChallengeType.Valid || acmeChallengeType.String == "" { | |
| 314 | - | err := fmt.Errorf("ACME challenge type is missing for domain %s", domainName) | |
| 315 | - | logger.Error(err.Error()) | |
| 316 | - | return err | |
| 317 | - | } | |
| 318 | - | // If DNS challenge, provider and credentials ref are required | |
| 319 | - | if acmeChallengeType.String == "dns-01" { | |
| 320 | - | if !acmeDnsProvider.Valid || acmeDnsProvider.String == "" { | |
| 321 | - | err := fmt.Errorf("ACME DNS provider is missing for domain %s using dns-01 challenge", domainName) | |
| 322 | - | logger.Error(err.Error()) | |
| 323 | - | return err | |
| 324 | - | } | |
| 325 | - | if !acmeDnsCredentialsRef.Valid || acmeDnsCredentialsRef.String == "" { | |
| 326 | - | err := fmt.Errorf("ACME DNS credentials reference is missing for domain %s using dns-01 challenge", domainName) | |
| 327 | - | logger.Error(err.Error()) | |
| 328 | - | return err | |
| 329 | - | } | |
| 330 | - | // Log the DNS provider being used (optional, but helpful for debugging) | |
| 331 | - | logger.Debug("Using DNS provider for ACME challenge", "domain", domainName, "provider", acmeDnsProvider.String) | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | // Set processing flag | |
| 335 | - | p.processingSpecificDomain = true | |
| 336 | - | defer func() { | |
| 337 | - | p.processingSpecificDomain = false | |
| 338 | - | }() | |
| 339 | - | ||
| 340 | - | // Request new certificate using the CertificateManager | |
| 341 | - | cert, err := p.certificateManager.ObtainCertificate(domainName, p) // Pass 'p' (Proxy instance) | |
| 342 | - | if err != nil { | |
| 343 | - | logger.Error("Failed to obtain/renew certificate", "domain", domainName, "error", err) | |
| 344 | - | // Update certificate status to 'failed' in DB? | |
| 345 | - | // _, dbErr := p.app.GetDB().Exec(p.Queries.UpdateCertificateStatus, "failed", domainName) | |
| 346 | - | // if dbErr != nil { | |
| 347 | - | // logger.Error("Failed to update certificate status after obtain failure", "domain", domainName, "db_error", dbErr) | |
| 348 | - | // } | |
| 349 | - | return err // Return the obtain error | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | logger.Info("Successfully obtained certificate", | |
| 353 | - | "domain", domainName, | |
| 354 | - | "cert_url", cert.CertURL) | |
| 355 | - | ||
| 356 | - | return nil | |
| 357 | - | } | |
D internal/proxy/db_helpers.go +0 -87
| @@ -1,87 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "database/sql" | |
| 5 | - | "fmt" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/db" | |
| 9 | - | "github.com/bnema/gordon/pkg/logger" | |
| 10 | - | ) | |
| 11 | - | ||
| 12 | - | // dbExecWithRetry performs a database execution with retry logic for handling locked database | |
| 13 | - | func (p *Proxy) dbExecWithRetry(query string, args ...interface{}) (sql.Result, error) { | |
| 14 | - | return db.ExecWithRetry(p.app.GetDB(), query, args...) | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | // dbQueryWithRetry performs a database query with retry logic for handling locked database | |
| 18 | - | func (p *Proxy) dbQueryWithRetry(query string, args ...interface{}) (*sql.Rows, error) { | |
| 19 | - | return db.QueryWithRetry(p.app.GetDB(), query, args...) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // dbBeginWithRetry starts a transaction with retry logic for handling locked database | |
| 23 | - | func (p *Proxy) dbBeginWithRetry() (*sql.Tx, error) { | |
| 24 | - | return db.BeginWithRetry(p.app.GetDB()) | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // txExecWithRetry executes a query within a transaction with retry logic | |
| 28 | - | func txExecWithRetry(tx *sql.Tx, query string, args ...interface{}) (sql.Result, error) { | |
| 29 | - | return db.TxExecWithRetry(tx, query, args...) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // ensureAdminDomainConfig ensures the admin domain has proper ACME configuration in the database | |
| 33 | - | func (p *Proxy) ensureAdminDomainConfig() error { | |
| 34 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 35 | - | adminAccountID := "" // Variable to store the admin account ID | |
| 36 | - | ||
| 37 | - | // Check if domain exists | |
| 38 | - | var exists bool | |
| 39 | - | err := p.app.GetDB().QueryRow(p.Queries.CheckDomainExists, adminDomain).Scan(&exists) | |
| 40 | - | if err != nil { | |
| 41 | - | return fmt.Errorf("failed to check admin domain existence: %w", err) | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Get the first account ID (assumed to be the admin) | |
| 45 | - | err = p.app.GetDB().QueryRow(p.Queries.GetFirstAccount).Scan(&adminAccountID) | |
| 46 | - | if err != nil { | |
| 47 | - | if err == sql.ErrNoRows { | |
| 48 | - | // This case should ideally not happen if account setup is done first | |
| 49 | - | logger.Error("No accounts found in the database. Cannot associate admin domain.") | |
| 50 | - | return fmt.Errorf("no accounts found in database") | |
| 51 | - | } | |
| 52 | - | return fmt.Errorf("failed to get admin account ID: %w", err) | |
| 53 | - | } | |
| 54 | - | if adminAccountID == "" { | |
| 55 | - | // Sanity check | |
| 56 | - | logger.Error("Admin account ID is empty. Cannot associate admin domain.") | |
| 57 | - | return fmt.Errorf("admin account ID is empty") | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | if !exists { | |
| 61 | - | // Create the admin domain with proper ACME config and account ID | |
| 62 | - | now := time.Now().Format(time.RFC3339) | |
| 63 | - | _, err = p.app.GetDB().Exec(p.Queries.CreateAdminDomain, | |
| 64 | - | adminDomain, // ID (assuming domain name is unique ID here?) | |
| 65 | - | adminDomain, // Name | |
| 66 | - | adminAccountID, // Account ID (NEW) | |
| 67 | - | now, // Created At | |
| 68 | - | now, // Updated At | |
| 69 | - | true, // ACME Enabled | |
| 70 | - | "http-01", // ACME Challenge Type | |
| 71 | - | ) | |
| 72 | - | if err != nil { | |
| 73 | - | return fmt.Errorf("failed to create admin domain: %w", err) | |
| 74 | - | } | |
| 75 | - | logger.Info("Created admin domain with ACME configuration", "domain", adminDomain, "account_id", adminAccountID) | |
| 76 | - | } else { | |
| 77 | - | // Update existing domain to ensure ACME is enabled (no need to update account_id here, assume it's set correctly) | |
| 78 | - | now := time.Now().Format(time.RFC3339) | |
| 79 | - | _, err = p.app.GetDB().Exec(p.Queries.UpdateAdminDomainAcme, now, adminDomain) | |
| 80 | - | if err != nil { | |
| 81 | - | return fmt.Errorf("failed to update admin domain ACME config: %w", err) | |
| 82 | - | } | |
| 83 | - | logger.Info("Updated admin domain ACME configuration", "domain", adminDomain) | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | return nil | |
| 87 | - | } | |
D internal/proxy/middleware.go +0 -265
| @@ -1,265 +0,0 @@ | |||
| 1 | - | package proxy | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "net" | |
| 5 | - | "net/http" | |
| 6 | - | "strings" | |
| 7 | - | "sync" | |
| 8 | - | "time" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/pkg/logger" | |
| 11 | - | "github.com/google/uuid" | |
| 12 | - | "github.com/labstack/echo/v4" | |
| 13 | - | "github.com/labstack/echo/v4/middleware" | |
| 14 | - | "golang.org/x/time/rate" | |
| 15 | - | ||
| 16 | - | "github.com/bnema/gordon/pkg/kv" // Correct import path based on the module name | |
| 17 | - | ) | |
| 18 | - | ||
| 19 | - | // rateLimiterCleanup holds the cleanup functions for rate limiters | |
| 20 | - | var rateLimiterCleanup []func() | |
| 21 | - | ||
| 22 | - | // RequestIDKey is the key used to store the request ID in the context | |
| 23 | - | const RequestIDKey = "request_id" | |
| 24 | - | ||
| 25 | - | // Cooldown period for logging direct IP access warnings | |
| 26 | - | const logCooldown = 1 * time.Minute | |
| 27 | - | ||
| 28 | - | // Map to store the last log time for each IP address attempting direct access | |
| 29 | - | var ipLogTimes = make(map[string]time.Time) | |
| 30 | - | var ipLogMutex sync.Mutex // Mutex to protect access to ipLogTimes | |
| 31 | - | ||
| 32 | - | // isHostIPAddress checks if the host part of a string (like "1.2.3.4:80" or "1.2.3.4") is a valid IP address. | |
| 33 | - | func isHostIPAddress(host string) bool { | |
| 34 | - | // Split host and port | |
| 35 | - | h, _, err := net.SplitHostPort(host) | |
| 36 | - | if err != nil { | |
| 37 | - | // If splitting fails, assume it might be just an IP without a port | |
| 38 | - | h = host | |
| 39 | - | } | |
| 40 | - | // Try parsing the host part as an IP address | |
| 41 | - | ip := net.ParseIP(h) | |
| 42 | - | return ip != nil | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // blockDirectIPMiddleware rejects requests where the Host header is an IP address, | |
| 46 | - | // unless it's an ACME challenge request. It includes a log cooldown mechanism. | |
| 47 | - | func blockDirectIPMiddleware() echo.MiddlewareFunc { | |
| 48 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 49 | - | return func(c echo.Context) error { | |
| 50 | - | host := c.Request().Host | |
| 51 | - | path := c.Request().URL.Path | |
| 52 | - | realIP := c.RealIP() // Get the real IP once | |
| 53 | - | ||
| 54 | - | if isHostIPAddress(host) { | |
| 55 | - | // Allow ACME HTTP-01 challenges to proceed even via IP | |
| 56 | - | if strings.HasPrefix(path, "/.well-known/acme-challenge/") { | |
| 57 | - | logger.Debug("Allowing direct IP access for ACME challenge", "host", host, "path", path, "ip", realIP) | |
| 58 | - | return next(c) | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | // --- Log Cooldown Logic --- | |
| 62 | - | now := time.Now() | |
| 63 | - | logAllowed := false | |
| 64 | - | ||
| 65 | - | ipLogMutex.Lock() | |
| 66 | - | lastLogTime, exists := ipLogTimes[realIP] | |
| 67 | - | if !exists || now.Sub(lastLogTime) > logCooldown { | |
| 68 | - | // It's a new IP or the cooldown has passed | |
| 69 | - | ipLogTimes[realIP] = now // Update the last log time | |
| 70 | - | logAllowed = true | |
| 71 | - | } | |
| 72 | - | ipLogMutex.Unlock() | |
| 73 | - | ||
| 74 | - | // Log only if allowed by the cooldown | |
| 75 | - | if logAllowed { | |
| 76 | - | logger.Warn("Blocking direct IP access attempt (log cooldown active)", | |
| 77 | - | "host", host, | |
| 78 | - | "path", path, | |
| 79 | - | "ip", realIP, | |
| 80 | - | "user_agent", c.Request().UserAgent()) | |
| 81 | - | } else { | |
| 82 | - | // Optionally log at DEBUG level that we suppressed a log, useful for verifying | |
| 83 | - | // logger.Debug("Suppressed duplicate direct IP access log due to cooldown", "ip", realIP, "host", host) | |
| 84 | - | } | |
| 85 | - | // --- End Log Cooldown Logic --- | |
| 86 | - | ||
| 87 | - | // Block other direct IP access attempts regardless of logging | |
| 88 | - | return echo.NewHTTPError(http.StatusForbidden, "Direct IP access is not permitted.") | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // If host is not an IP, proceed normally | |
| 92 | - | return next(c) | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | // setupMiddleware configures middleware for both HTTP and HTTPS servers | |
| 98 | - | func (p *Proxy) setupMiddleware() { | |
| 99 | - | // Configure Echo IP extraction. Since the proxy is edge-facing (directly exposed), | |
| 100 | - | // ignore X-Forwarded-For and use the direct remote address. | |
| 101 | - | p.httpsServer.IPExtractor = echo.ExtractIPDirect() | |
| 102 | - | ||
| 103 | - | // Add UUID generator middleware | |
| 104 | - | p.httpsServer.Use(p.createRequestIDMiddleware()) | |
| 105 | - | ||
| 106 | - | // --- Block Direct IP Access Middleware (Conditional) --- | |
| 107 | - | if p.config.BlockDirectIP { | |
| 108 | - | // This should run early, before logging or rate limiting potentially. | |
| 109 | - | p.httpsServer.Use(blockDirectIPMiddleware()) | |
| 110 | - | logger.Info("Direct IP blocking middleware is ENABLED (allows ACME)") | |
| 111 | - | } else { | |
| 112 | - | logger.Info("Direct IP blocking middleware is DISABLED") | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // Add middleware to log incoming headers (for debugging Cloudflare detection) | |
| 116 | - | // p.httpsServer.Use(logHeadersMiddleware()) | |
| 117 | - | ||
| 118 | - | // --- Rate Limiting Setup (Domain Only Now) --- | |
| 119 | - | if p.config.EnableRateLimit { | |
| 120 | - | logger.Debug("Rate limiting enabled. Setting up limiter for domain requests only.") | |
| 121 | - | ||
| 122 | - | // --- REMOVED Rate Limiter for Direct IP Access --- | |
| 123 | - | ||
| 124 | - | // --- Rate Limiter for Domain Access (Existing Logic) --- | |
| 125 | - | // Use Starskey or fallback for requests coming through a domain name. | |
| 126 | - | domainRateLimiterStore, err := kv.NewStarskeyRateLimiterStore( | |
| 127 | - | "./data/ratelimiter/domain", // Path for domain limiter data | |
| 128 | - | 10, // Rate: 10 requests per second (default) | |
| 129 | - | 30, // Burst: 30 requests (default) | |
| 130 | - | 3*time.Minute, // Expires in 3 minutes | |
| 131 | - | ) | |
| 132 | - | ||
| 133 | - | if err != nil { | |
| 134 | - | logger.Error("Failed to create Domain rate limiter store (Starskey), falling back to memory store", "error", err) | |
| 135 | - | // Fallback to memory store | |
| 136 | - | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 137 | - | Store: middleware.NewRateLimiterMemoryStoreWithConfig( | |
| 138 | - | middleware.RateLimiterMemoryStoreConfig{ | |
| 139 | - | Rate: rate.Limit(10), | |
| 140 | - | Burst: 30, | |
| 141 | - | ExpiresIn: 3 * time.Minute, | |
| 142 | - | }, | |
| 143 | - | ), | |
| 144 | - | // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe) | |
| 145 | - | Skipper: func(c echo.Context) bool { | |
| 146 | - | host := c.Request().Host | |
| 147 | - | isIP := isHostIPAddress(host) | |
| 148 | - | if !isIP { | |
| 149 | - | // Log only if applying the limiter | |
| 150 | - | logger.Debug("Applying domain rate limiter (Memory Fallback)", "host", host, "ip", c.RealIP()) | |
| 151 | - | } | |
| 152 | - | return isIP // Skip if it IS an IP | |
| 153 | - | }, | |
| 154 | - | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 155 | - | return ctx.RealIP(), nil | |
| 156 | - | }, | |
| 157 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 158 | - | logger.Warn("Domain rate limit exceeded (Memory Fallback)", | |
| 159 | - | "ip", identifier, | |
| 160 | - | "host", context.Request().Host, | |
| 161 | - | "path", context.Request().URL.Path, | |
| 162 | - | "method", context.Request().Method, | |
| 163 | - | "error", err.Error()) | |
| 164 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 165 | - | }, | |
| 166 | - | })) | |
| 167 | - | } else { | |
| 168 | - | logger.Debug("Using Starskey rate limiter for domain requests") | |
| 169 | - | // Use the Starskey store | |
| 170 | - | p.httpsServer.Use(middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ | |
| 171 | - | Store: domainRateLimiterStore, | |
| 172 | - | // Apply this limiter ONLY if the host is NOT an IP address (redundant now but safe) | |
| 173 | - | Skipper: func(c echo.Context) bool { | |
| 174 | - | host := c.Request().Host | |
| 175 | - | isIP := isHostIPAddress(host) | |
| 176 | - | if !isIP { | |
| 177 | - | // Log only if applying the limiter | |
| 178 | - | logger.Debug("Applying domain rate limiter (Starskey)", "host", host, "ip", c.RealIP()) | |
| 179 | - | } | |
| 180 | - | return isIP // Skip if it IS an IP | |
| 181 | - | }, | |
| 182 | - | IdentifierExtractor: func(ctx echo.Context) (string, error) { | |
| 183 | - | id := ctx.RealIP() | |
| 184 | - | ctx.Response().Header().Set("X-Rate-Limit-Domain-IP", id) // Keep debug header | |
| 185 | - | return id, nil | |
| 186 | - | }, | |
| 187 | - | DenyHandler: func(context echo.Context, identifier string, err error) error { | |
| 188 | - | logger.Warn("Domain rate limit exceeded (Starskey)", | |
| 189 | - | "ip", identifier, | |
| 190 | - | "host", context.Request().Host, | |
| 191 | - | "path", context.Request().URL.Path, | |
| 192 | - | "method", context.Request().Method, | |
| 193 | - | "error", err.Error()) | |
| 194 | - | return context.String(http.StatusTooManyRequests, "Too many requests") | |
| 195 | - | }, | |
| 196 | - | })) | |
| 197 | - | ||
| 198 | - | // Add cleanup function for the Starskey domain limiter | |
| 199 | - | rateLimiterCleanup = append(rateLimiterCleanup, func() { | |
| 200 | - | if err := domainRateLimiterStore.Close(); err != nil { | |
| 201 | - | logger.Error("Failed to close Domain rate limiter store (Starskey)", "error", err) | |
| 202 | - | } | |
| 203 | - | }) | |
| 204 | - | } | |
| 205 | - | logger.Debug("Configured rate limiter for domain requests") | |
| 206 | - | ||
| 207 | - | } else { | |
| 208 | - | logger.Info("Rate limiting is disabled globally via configuration") | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | // --- Logging Middleware (Existing) --- | |
| 212 | - | if p.config.EnableHttpLogs { | |
| 213 | - | p.httpsServer.Use(middleware.LoggerWithConfig(middleware.LoggerConfig{ | |
| 214 | - | // Use a standard string with escaped quotes for the JSON format | |
| 215 | - | Format: "{\"time\":\"${time_rfc3339_nano}\",\"id\":\"${id}\",\"remote_ip\":\"${remote_ip}\",\"host\":\"${host}\",\"method\":\"${method}\",\"uri\":\"${uri}\",\"user_agent\":\"${user_agent}\",\"status\":${status},\"error\":\"${error}\",\"latency\":${latency},\"latency_human\":\"${latency_human}\",\"bytes_in\":${bytes_in},\"bytes_out\":${bytes_out}}\n", | |
| 216 | - | CustomTimeFormat: "2006-01-02T15:04:05.00000Z07:00", | |
| 217 | - | Skipper: func(c echo.Context) bool { | |
| 218 | - | // Skip logging if disabled in config OR if it's a successful ACME challenge | |
| 219 | - | // (to avoid polluting logs during certificate issuance) | |
| 220 | - | isAcme := strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/") | |
| 221 | - | return !p.config.EnableHttpLogs || (isAcme && c.Response().Status == http.StatusOK) | |
| 222 | - | }, | |
| 223 | - | })) | |
| 224 | - | logger.Debug("HTTP request logging enabled for proxy server") | |
| 225 | - | } else { | |
| 226 | - | logger.Debug("HTTP request logging disabled for proxy server") | |
| 227 | - | } | |
| 228 | - | ||
| 229 | - | // REMOVED the duplicated rate limiter and logger blocks that seemed intended for a separate HTTP server | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | // createRequestIDMiddleware returns a middleware function that generates a UUID for each request | |
| 233 | - | func (p *Proxy) createRequestIDMiddleware() echo.MiddlewareFunc { | |
| 234 | - | return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 235 | - | return func(c echo.Context) error { | |
| 236 | - | // Generate a UUID for this request | |
| 237 | - | requestID := uuid.New().String() | |
| 238 | - | ||
| 239 | - | // Store it in the context | |
| 240 | - | c.Set(RequestIDKey, requestID) | |
| 241 | - | ||
| 242 | - | // Add it as a response header | |
| 243 | - | c.Response().Header().Set(echo.HeaderXRequestID, requestID) | |
| 244 | - | ||
| 245 | - | // Continue processing | |
| 246 | - | return next(c) | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // logHeadersMiddleware creates a middleware function to log request headers | |
| 252 | - | // func logHeadersMiddleware() echo.MiddlewareFunc { | |
| 253 | - | // return func(next echo.HandlerFunc) echo.HandlerFunc { | |
| 254 | - | // return func(c echo.Context) error { | |
| 255 | - | // // Log all headers at DEBUG level | |
| 256 | - | // headerStrings := []string{} | |
| 257 | - | // for k, v := range c.Request().Header { | |
| 258 | - | // headerStrings = append(headerStrings, fmt.Sprintf("%s: %s", k, strings.Join(v, ","))) | |
| 259 | - | // } | |
| 260 | - | // logger.Debug("Incoming Request Headers", "request_id", c.Get(RequestIDKey), "headers", strings.Join(headerStrings, " | ")) | |
| 261 | - | ||
| 262 | - | // return next(c) | |
| 263 | - | // } | |
| 264 | - | // } | |
| 265 | - | // } | |
D internal/proxy/routes.go +0 -2094 large diff (2094 lines changed): collapsed
M internal/proxy/server.go +239 -555
| @@ -2,601 +2,285 @@ package proxy | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | - | "crypto/tls" | |
| 6 | - | "errors" | |
| 7 | 5 | "fmt" | |
| 8 | 6 | "net/http" | |
| 9 | - | "strings" | |
| 10 | - | "time" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/gordon/pkg/docker" | |
| 13 | - | "github.com/bnema/gordon/pkg/logger" | |
| 7 | + | "net/http/httputil" | |
| 8 | + | "net/url" | |
| 9 | + | "strconv" | |
| 10 | + | ||
| 11 | + | "github.com/rs/zerolog/log" | |
| 12 | + | "gordon/internal/config" | |
| 13 | + | "gordon/internal/container" | |
| 14 | + | "gordon/internal/middleware" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| 16 | - | // This file contains server operations functions for the proxy. | |
| 17 | - | // These methods may need to be deleted from proxy.go after validation. | |
| 18 | - | ||
| 19 | - | // Start loads the routes from the database and starts the proxy server | |
| 20 | - | func (p *Proxy) Start() error { | |
| 21 | - | // First, load routes from the database | |
| 22 | - | if err := p.loadRoutes(); err != nil { | |
| 23 | - | return fmt.Errorf("failed to load routes: %w", err) | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Now log Gordon container identity for debugging (after routes are loaded) | |
| 27 | - | p.LogGordonIdentity() | |
| 28 | - | ||
| 29 | - | // Scan Gordon network containers and check for certificates | |
| 30 | - | if err := p.scanContainersAndCheckCertificates(); err != nil { | |
| 31 | - | logger.Warn("Failed to scan containers for certificate checks", "error", err) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Run an immediate route verification to ensure all routes are valid on startup | |
| 35 | - | logger.Info("Performing initial route verification on startup") | |
| 36 | - | ||
| 37 | - | // Discover any containers that should have routes but don't | |
| 38 | - | p.DiscoverMissingRoutes() | |
| 39 | - | ||
| 40 | - | // Log all active containers and their routes for visibility | |
| 41 | - | p.LogActiveContainersAndRoutes() | |
| 42 | - | ||
| 43 | - | // Start the container event listener for real-time updates | |
| 44 | - | if err := p.StartContainerEventListener(); err != nil { | |
| 45 | - | logger.Warn("Failed to start container event listener", "error", err) | |
| 46 | - | // Continue anyway, this is non-critical | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // Check if there might be port conflicts with the main server | |
| 50 | - | mainServerPort := p.app.GetConfig().Http.Port | |
| 51 | - | ||
| 52 | - | // Check HTTP port conflict | |
| 53 | - | if p.config.HttpPort == mainServerPort { | |
| 54 | - | logger.Warn("HTTP port for reverse proxy conflicts with main server port", | |
| 55 | - | "port", p.config.HttpPort, | |
| 56 | - | "solution", "reverse proxy HTTP server will be disabled") | |
| 57 | - | return nil | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Check HTTPS port conflict (less common, but still possible) | |
| 61 | - | if p.config.Port == mainServerPort { | |
| 62 | - | logger.Warn("HTTPS port for reverse proxy conflicts with main server port", | |
| 63 | - | "port", p.config.Port, | |
| 64 | - | "solution", "reverse proxy HTTPS server will be disabled") | |
| 65 | - | return nil | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | // Add a special route for the admin domain (Gordon itself) | |
| 69 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 70 | - | ||
| 71 | - | // Auto-detect the Gordon container name and ID | |
| 72 | - | containerName := p.detectGordonContainer() | |
| 73 | - | ||
| 74 | - | // Get the network name from config | |
| 75 | - | networkName := p.app.GetConfig().ContainerEngine.Network | |
| 76 | - | ||
| 77 | - | // Initialize container IP | |
| 78 | - | var containerIP string | |
| 79 | - | ||
| 80 | - | // Try to get the actual container IP from the network | |
| 81 | - | if p.gordonContainerID == "" { | |
| 82 | - | return fmt.Errorf("failed to detect Gordon container ID, cannot add admin route") | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // Get the IP address from the container's network | |
| 86 | - | ip, err := docker.GetContainerIPFromNetwork(p.gordonContainerID, networkName) | |
| 87 | - | if err != nil || ip == "" { | |
| 88 | - | logger.Error("Failed to get Gordon container IP from network", | |
| 89 | - | "container_id", p.gordonContainerID, | |
| 90 | - | "network", networkName, | |
| 91 | - | "error", err) | |
| 92 | - | return fmt.Errorf("failed to get Gordon container IP from network %s: %w", networkName, err) | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | containerIP = ip | |
| 96 | - | logger.Info("Using Gordon container IP from network", | |
| 97 | - | "container_id", p.gordonContainerID, | |
| 98 | - | "network", networkName, | |
| 99 | - | "ip", containerIP) | |
| 100 | - | ||
| 101 | - | // Check if admin route exists in database and create/update it | |
| 102 | - | err = p.AddRoute( | |
| 103 | - | adminDomain, | |
| 104 | - | containerName, | |
| 105 | - | containerIP, | |
| 106 | - | p.app.GetConfig().Http.Port, | |
| 107 | - | "http", // Gordon server uses HTTP internally | |
| 108 | - | "/", | |
| 109 | - | ) | |
| 17 | + | type Server struct { | |
| 18 | + | config *config.Config | |
| 19 | + | mux *http.ServeMux | |
| 20 | + | routes []config.Route | |
| 21 | + | manager *container.Manager | |
| 22 | + | } | |
| 110 | 23 | ||
| 111 | - | if err != nil { | |
| 112 | - | logger.Error("Failed to add admin route to database", | |
| 113 | - | "error", err, | |
| 114 | - | "domain", adminDomain) | |
| 115 | - | } else { | |
| 116 | - | logger.Info("Ensured admin domain route is in database", | |
| 117 | - | "domain", adminDomain, | |
| 118 | - | "container", containerName, | |
| 119 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |
| 24 | + | func NewServer(cfg *config.Config, manager *container.Manager) *Server { | |
| 25 | + | s := &Server{ | |
| 26 | + | config: cfg, | |
| 27 | + | mux: http.NewServeMux(), | |
| 28 | + | routes: cfg.GetRoutes(), | |
| 29 | + | manager: manager, | |
| 120 | 30 | } | |
| 31 | + | s.setupRoutes() | |
| 32 | + | return s | |
| 33 | + | } | |
| 121 | 34 | ||
| 122 | - | p.mu.Lock() | |
| 123 | - | // Only add if it doesn't already exist in memory | |
| 124 | - | if _, exists := p.routes[adminDomain]; !exists { | |
| 125 | - | p.routes[adminDomain] = &ProxyRouteInfo{ | |
| 126 | - | Domain: adminDomain, | |
| 127 | - | ContainerIP: containerIP, | |
| 128 | - | ContainerPort: p.app.GetConfig().Http.Port, | |
| 129 | - | ContainerID: containerName, // Use auto-detected container name | |
| 130 | - | Protocol: "http", // Gordon server uses HTTP internally | |
| 131 | - | Path: "/", | |
| 132 | - | Active: true, | |
| 35 | + | func (s *Server) setupRoutes() { | |
| 36 | + | // Protected Management API endpoints | |
| 37 | + | if s.config.Auth.Enabled { | |
| 38 | + | // Create protected subrouter for API endpoints | |
| 39 | + | apiMux := http.NewServeMux() | |
| 40 | + | apiMux.HandleFunc("GET /api/status", s.handleStatus) | |
| 41 | + | apiMux.HandleFunc("POST /api/reload", s.handleReload) | |
| 42 | + | apiMux.HandleFunc("GET /api/containers", s.handleListContainers) | |
| 43 | + | apiMux.HandleFunc("POST /api/deploy/{route}", s.handleDeploy) | |
| 44 | + | ||
| 45 | + | // Apply authentication middleware to API routes | |
| 46 | + | var authHandler http.Handler = apiMux | |
| 47 | + | ||
| 48 | + | switch s.config.Auth.Method { | |
| 49 | + | case "jwt": | |
| 50 | + | authHandler = middleware.JWTAuth(s.config.Auth.JWTSecret)(authHandler) | |
| 51 | + | case "api_key": | |
| 52 | + | authHandler = middleware.APIKeyAuth(s.config.Auth.APIKey)(authHandler) | |
| 53 | + | case "basic": | |
| 54 | + | authHandler = middleware.BasicAuth(s.config.Auth.Username, s.config.Auth.Password)(authHandler) | |
| 133 | 55 | } | |
| 134 | - | logger.Info("Added special route for admin domain", | |
| 135 | - | "domain", adminDomain, | |
| 136 | - | "container", containerName, | |
| 137 | - | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |
| 56 | + | ||
| 57 | + | // Add IP whitelist if configured | |
| 58 | + | if len(s.config.Auth.AllowedIPs) > 0 { | |
| 59 | + | authHandler = middleware.IPWhitelist(s.config.Auth.AllowedIPs)(authHandler) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | s.mux.Handle("/api/", authHandler) | |
| 138 | 63 | } else { | |
| 139 | - | logger.Debug("Admin domain route already exists", | |
| 140 | - | "domain", adminDomain, | |
| 141 | - | "route", fmt.Sprintf("%s://%s:%s", | |
| 142 | - | p.routes[adminDomain].Protocol, | |
| 143 | - | p.routes[adminDomain].ContainerIP, | |
| 144 | - | p.routes[adminDomain].ContainerPort)) | |
| 145 | - | } | |
| 146 | - | p.mu.Unlock() | |
| 147 | - | ||
| 148 | - | // Explicitly request certificate for admin domain after ensuring route exists | |
| 149 | - | if err := p.requestDomainCertificate(adminDomain); err != nil { | |
| 150 | - | logger.Error("Failed to request initial certificate for admin domain", "domain", adminDomain, "error", err) | |
| 151 | - | // Don't return an error here, let the server start and retry later | |
| 152 | - | } | |
| 153 | - | ||
| 154 | - | // Set up middleware | |
| 155 | - | p.setupMiddleware() | |
| 156 | - | ||
| 157 | - | // Configure routes | |
| 158 | - | p.configureRoutes() | |
| 159 | - | ||
| 160 | - | // Configure HTTPS and HTTP servers using our refactored method | |
| 161 | - | if err := p.StartHTTPSServer(); err != nil { | |
| 162 | - | logger.Error("Failed to start HTTPS server", "error", err) | |
| 163 | - | return err | |
| 64 | + | // Unprotected API endpoints (not recommended for production) | |
| 65 | + | s.mux.HandleFunc("GET /api/status", s.handleStatus) | |
| 66 | + | s.mux.HandleFunc("POST /api/reload", s.handleReload) | |
| 67 | + | s.mux.HandleFunc("GET /api/containers", s.handleListContainers) | |
| 68 | + | s.mux.HandleFunc("POST /api/deploy/{route}", s.handleDeploy) | |
| 164 | 69 | } | |
| 165 | - | ||
| 166 | - | return nil | |
| 70 | + | ||
| 71 | + | // Catch-all handler for domain-based routing | |
| 72 | + | // Since Go's ServeMux doesn't support Host matching, we'll handle it in the handler | |
| 73 | + | s.mux.HandleFunc("/", s.handleDomainRouting) | |
| 167 | 74 | } | |
| 168 | 75 | ||
| 169 | - | // Stop gracefully shuts down the proxy server | |
| 170 | - | func (p *Proxy) Stop() error { | |
| 171 | - | logger.Info("Stopping reverse proxy") | |
| 172 | - | ||
| 173 | - | // Run any rate limiter cleanup functions | |
| 174 | - | for _, cleanup := range rateLimiterCleanup { | |
| 175 | - | cleanup() | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // Stop the container event listener | |
| 179 | - | docker.StopContainerEventListener() | |
| 180 | - | ||
| 181 | - | // Skip the rest if the server was never started | |
| 182 | - | if !p.serverStarted { | |
| 183 | - | logger.Debug("Proxy server was never started, nothing to stop") | |
| 184 | - | return nil | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // Create a context with timeout for shutdown | |
| 188 | - | timeout := p.config.GracePeriod | |
| 189 | - | if timeout <= 0 { | |
| 190 | - | timeout = 10 // Default to 10 seconds if not set | |
| 191 | - | } | |
| 192 | - | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second) | |
| 193 | - | defer cancel() | |
| 194 | - | ||
| 195 | - | // Removed HTTP server shutdown logic | |
| 196 | - | // if p.httpServer != nil { | |
| 197 | - | // if err := p.httpServer.Shutdown(ctx); err != nil { | |
| 198 | - | // logger.Error("Failed to gracefully shutdown HTTP server", "error", err) | |
| 199 | - | // } | |
| 200 | - | // } | |
| 201 | - | ||
| 202 | - | // Shutdown HTTPS server if it's running | |
| 203 | - | if p.httpsServer != nil { | |
| 204 | - | if err := p.httpsServer.Shutdown(ctx); err != nil { | |
| 205 | - | logger.Error("Failed to gracefully shutdown HTTPS server", "error", err) | |
| 76 | + | func (s *Server) handleDomainRouting(w http.ResponseWriter, r *http.Request) { | |
| 77 | + | host := r.Host | |
| 78 | + | ||
| 79 | + | // Find matching route for this domain | |
| 80 | + | for _, route := range s.routes { | |
| 81 | + | if route.Domain == host { | |
| 82 | + | log.Info().Str("domain", host).Str("image", route.Image).Msg("Routing request") | |
| 83 | + | s.proxyToContainer(w, r, route) | |
| 84 | + | return | |
| 206 | 85 | } | |
| 207 | 86 | } | |
| 208 | - | ||
| 209 | - | logger.Info("Reverse proxy stopped") | |
| 210 | - | return nil | |
| 87 | + | ||
| 88 | + | // No route found | |
| 89 | + | log.Warn().Str("domain", host).Msg("No route found for domain") | |
| 90 | + | http.NotFound(w, r) | |
| 211 | 91 | } | |
| 212 | 92 | ||
| 213 | - | // testAdminConnection attempts to find a working connection to the Gordon admin ping endpoint | |
| 214 | - | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |
| 215 | - | adminPath := p.app.GetConfig().Admin.Path // Get the base admin path | |
| 216 | - | pingPath := adminPath + "/ping" // Construct the full ping path | |
| 217 | - | url := fmt.Sprintf("http://%s:%s%s", defaultHost, port, pingPath) | |
| 218 | - | ||
| 219 | - | logger.Debug("Testing admin ping connection", "url", url) // Update log message | |
| 220 | - | ||
| 221 | - | // Try connecting using the provided default host | |
| 222 | - | client := &http.Client{ | |
| 223 | - | // Use a simple TLS config instead of getting it from the app | |
| 224 | - | Transport: &http.Transport{ | |
| 225 | - | TLSClientConfig: &tls.Config{ | |
| 226 | - | InsecureSkipVerify: true, | |
| 227 | - | }, | |
| 228 | - | }, | |
| 229 | - | Timeout: 5 * time.Second, | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | resp, err := client.Get(url) | |
| 233 | - | if err == nil { | |
| 234 | - | resp.Body.Close() | |
| 235 | - | // Check if the response status is OK | |
| 236 | - | if resp.StatusCode == http.StatusOK { | |
| 237 | - | logger.Info("Admin ping connection successful", "host", defaultHost) // Update log message | |
| 238 | - | return defaultHost | |
| 239 | - | } else { | |
| 240 | - | logger.Warn("Admin ping connection returned non-OK status", "host", defaultHost, "status_code", resp.StatusCode) | |
| 93 | + | func (s *Server) proxyToContainer(w http.ResponseWriter, r *http.Request, route config.Route) { | |
| 94 | + | ctx := r.Context() | |
| 95 | + | ||
| 96 | + | // Check if container exists and is running | |
| 97 | + | container, exists := s.manager.GetContainer(route.Domain) | |
| 98 | + | if !exists { | |
| 99 | + | log.Info().Str("domain", route.Domain).Msg("Container not found, deploying") | |
| 100 | + | ||
| 101 | + | // Deploy the container | |
| 102 | + | var err error | |
| 103 | + | container, err = s.manager.DeployContainer(ctx, route) | |
| 104 | + | if err != nil { | |
| 105 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("Failed to deploy container") | |
| 106 | + | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 107 | + | return | |
| 241 | 108 | } | |
| 242 | - | } else { | |
| 243 | - | logger.Warn("Admin ping connection failed", "host", defaultHost, "error", err) | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | // If connection failed or status not OK, try other common hostnames | |
| 247 | - | hosts := []string{ | |
| 248 | - | "localhost", | |
| 249 | - | "127.0.0.1", | |
| 250 | - | "host.docker.internal", | |
| 251 | - | "host.containers.internal", | |
| 252 | 109 | } | |
| 253 | 110 | ||
| 254 | - | for _, host := range hosts { | |
| 255 | - | if host == defaultHost { | |
| 256 | - | continue // Already tried this one | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | url = fmt.Sprintf("http://%s:%s%s", host, port, pingPath) // Use full pingPath here too | |
| 260 | - | logger.Debug("Trying alternative host for admin ping", "url", url) // Update log message | |
| 261 | - | ||
| 262 | - | resp, err := client.Get(url) | |
| 111 | + | // Try to get container port (try common web ports) | |
| 112 | + | var targetPort int | |
| 113 | + | var err error | |
| 114 | + | ||
| 115 | + | // Try common web ports in order: 80, 8080, 3000 | |
| 116 | + | for _, port := range []int{80, 8080, 3000} { | |
| 117 | + | targetPort, err = s.manager.GetContainerPort(ctx, route.Domain, port) | |
| 263 | 118 | if err == nil { | |
| 264 | - | resp.Body.Close() | |
| 265 | - | if resp.StatusCode == http.StatusOK { | |
| 266 | - | logger.Info("Found working admin ping connection", "host", host) // Update log message | |
| 267 | - | return host | |
| 268 | - | } else { | |
| 269 | - | logger.Warn("Admin ping connection on alternative host returned non-OK status", "host", host, "status_code", resp.StatusCode) | |
| 270 | - | } | |
| 271 | - | } else { | |
| 272 | - | logger.Warn("Admin ping connection failed on alternative host", "host", host, "error", err) | |
| 119 | + | break | |
| 273 | 120 | } | |
| 274 | 121 | } | |
| 275 | - | ||
| 276 | - | logger.Warn("Could not establish admin ping connection on any tested host", "fallback", defaultHost) // Update log message | |
| 277 | - | return defaultHost | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | // TestAdminConnectionLater tests the admin connection after a short delay | |
| 281 | - | func (p *Proxy) TestAdminConnectionLater() { | |
| 282 | - | // Wait for servers to start | |
| 283 | - | time.Sleep(2 * time.Second) | |
| 284 | - | ||
| 285 | - | logger.Debug("Testing admin connections after server startup") | |
| 286 | - | ||
| 287 | - | // Get domain and current route | |
| 288 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 289 | - | ||
| 290 | - | p.mu.RLock() | |
| 291 | - | route, exists := p.routes[adminDomain] | |
| 292 | - | p.mu.RUnlock() | |
| 293 | - | ||
| 294 | - | if !exists { | |
| 295 | - | logger.Warn("Admin route not found") | |
| 122 | + | ||
| 123 | + | if err != nil { | |
| 124 | + | log.Error().Err(err).Str("domain", route.Domain).Msg("No accessible port found for container") | |
| 125 | + | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 296 | 126 | return | |
| 297 | 127 | } | |
| 298 | - | ||
| 299 | - | // Test connection and update if needed | |
| 300 | - | testedIP := p.testAdminConnection(route.ContainerIP, p.app.GetConfig().Http.Port) | |
| 301 | - | if testedIP != route.ContainerIP { | |
| 302 | - | p.mu.Lock() | |
| 303 | - | if r, ok := p.routes[adminDomain]; ok { | |
| 304 | - | r.ContainerIP = testedIP | |
| 305 | - | logger.Info("Updated admin route with working connection", | |
| 306 | - | "domain", adminDomain, | |
| 307 | - | "ip", testedIP) | |
| 308 | - | } | |
| 309 | - | p.mu.Unlock() | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // Check external port access | |
| 313 | - | p.checkExternalPortAccess() | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | // checkExternalPortAccess verifies if ports 80 and 443 are accessible | |
| 317 | - | func (p *Proxy) checkExternalPortAccess() { | |
| 318 | - | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 319 | - | if adminDomain == "" { | |
| 128 | + | ||
| 129 | + | target, err := url.Parse(fmt.Sprintf("http://localhost:%d", targetPort)) | |
| 130 | + | if err != nil { | |
| 131 | + | log.Error().Err(err).Msg("Failed to parse target URL") | |
| 132 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 320 | 133 | return | |
| 321 | 134 | } | |
| 322 | - | ||
| 323 | - | // Test HTTP port 80 | |
| 324 | - | /* // Commenting out HTTP check due to potential hairpin NAT issues | |
| 325 | - | client := &http.Client{ | |
| 326 | - | Timeout: 5 * time.Second, | |
| 327 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 328 | - | return http.ErrUseLastResponse // Don't follow redirects | |
| 329 | - | }, | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | httpURL := fmt.Sprintf("http://%s/.well-known/acme-challenge/test", adminDomain) | |
| 333 | - | _, httpErr := client.Get(httpURL) | |
| 334 | - | if httpErr != nil { | |
| 335 | - | logger.Warn("External HTTP port 80 might not be accessible", | |
| 336 | - | "error", httpErr.Error()) | |
| 337 | - | } else { | |
| 338 | - | logger.Info("External HTTP port 80 is accessible") | |
| 135 | + | ||
| 136 | + | proxy := httputil.NewSingleHostReverseProxy(target) | |
| 137 | + | ||
| 138 | + | // Customize the proxy to handle errors | |
| 139 | + | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |
| 140 | + | log.Error().Err(err).Str("domain", route.Domain).Int("port", targetPort).Msg("Proxy error") | |
| 141 | + | http.Error(w, "Service Unavailable", http.StatusServiceUnavailable) | |
| 339 | 142 | } | |
| 340 | - | */ | |
| 341 | - | ||
| 342 | - | // Test HTTPS port 443 | |
| 343 | - | /* // Commenting out httpsClient as it's no longer used | |
| 344 | - | httpsClient := &http.Client{ | |
| 345 | - | Timeout: 10 * time.Second, // Increased timeout for more reliable testing | |
| 346 | - | Transport: &http.Transport{ | |
| 347 | - | TLSClientConfig: &tls.Config{ | |
| 348 | - | InsecureSkipVerify: true, | |
| 349 | - | }, | |
| 350 | - | // Add more detailed timeout settings | |
| 351 | - | TLSHandshakeTimeout: 5 * time.Second, | |
| 352 | - | ResponseHeaderTimeout: 5 * time.Second, | |
| 353 | - | ExpectContinueTimeout: 1 * time.Second, | |
| 354 | - | // Add a custom dialer with explicit timeouts | |
| 355 | - | DialContext: (&net.Dialer{ | |
| 356 | - | Timeout: 5 * time.Second, | |
| 357 | - | KeepAlive: 30 * time.Second, | |
| 358 | - | DualStack: true, | |
| 359 | - | }).DialContext, | |
| 360 | - | }, | |
| 361 | - | CheckRedirect: func(req *http.Request, via []*http.Request) error { | |
| 362 | - | return http.ErrUseLastResponse | |
| 363 | - | }, | |
| 364 | - | } | |
| 365 | - | */ | |
| 366 | - | ||
| 367 | - | // Verify TLS settings are consistent | |
| 368 | - | if p.certificateManager == nil { // Updated check | |
| 369 | - | logger.Error("Certificate manager is nil - TLS will not work correctly", | |
| 370 | - | "solution", "Check NewProxy implementation and logs") | |
| 143 | + | ||
| 144 | + | // Add custom headers | |
| 145 | + | proxy.ModifyResponse = func(resp *http.Response) error { | |
| 146 | + | resp.Header.Set("X-Proxied-By", "Gordon") | |
| 147 | + | resp.Header.Set("X-Container-ID", container.ID) | |
| 148 | + | return nil | |
| 371 | 149 | } | |
| 150 | + | ||
| 151 | + | log.Debug(). | |
| 152 | + | Str("method", r.Method). | |
| 153 | + | Str("path", r.URL.Path). | |
| 154 | + | Str("domain", route.Domain). | |
| 155 | + | Str("target", target.String()). | |
| 156 | + | Str("container", container.ID). | |
| 157 | + | Msg("Proxying request") | |
| 158 | + | proxy.ServeHTTP(w, r) | |
| 159 | + | } | |
| 372 | 160 | ||
| 373 | - | /* // Commenting out port 443 checks due to potential hairpin NAT issues | |
| 374 | - | // First try a basic TCP connection to port 443 to test if it's open | |
| 375 | - | logger.Info("Testing TCP connectivity to HTTPS port 443...") | |
| 376 | - | conn, tcpErr := net.DialTimeout("tcp", adminDomain+":443", 5*time.Second) | |
| 377 | - | if tcpErr != nil { | |
| 378 | - | logger.Warn("TCP connection to port 443 failed - HTTPS will not work", | |
| 379 | - | "domain", adminDomain, | |
| 380 | - | "error", tcpErr.Error()) | |
| 381 | - | } else { | |
| 382 | - | conn.Close() | |
| 383 | - | logger.Info("TCP connection to port 443 successful") | |
| 161 | + | func (s *Server) Start(ctx context.Context) error { | |
| 162 | + | addr := ":" + strconv.Itoa(s.config.Server.Port) | |
| 163 | + | ||
| 164 | + | // Wrap the mux with middleware | |
| 165 | + | handler := middleware.Chain( | |
| 166 | + | middleware.PanicRecovery, | |
| 167 | + | middleware.RequestLogger, | |
| 168 | + | middleware.CORS, | |
| 169 | + | )(s.mux) | |
| 170 | + | ||
| 171 | + | // TODO: Implement HTTPS support with Let's Encrypt | |
| 172 | + | server := &http.Server{ | |
| 173 | + | Addr: addr, | |
| 174 | + | Handler: handler, | |
| 384 | 175 | } | |
| 385 | 176 | ||
| 386 | - | // Then try the full HTTPS request | |
| 387 | - | httpsURL := fmt.Sprintf("https://%s/", adminDomain) | |
| 388 | - | logger.Info("Testing HTTPS connection with SNI...", "url", httpsURL) | |
| 389 | - | _, httpsErr := httpsClient.Get(httpsURL) | |
| 390 | - | if httpsErr != nil { | |
| 391 | - | logger.Warn("External HTTPS port 443 might not be accessible", | |
| 392 | - | "error", httpsErr.Error()) | |
| 177 | + | log.Info().Str("address", addr).Msg("Proxy server starting") | |
| 393 | 178 | ||
| 394 | - | // Provide more detailed troubleshooting info | |
| 395 | - | if strings.Contains(httpsErr.Error(), "certificate") { | |
| 396 | - | logger.Warn("TLS certificate issue detected", | |
| 397 | - | "hint", "Certificate verification failed but connection succeeded") | |
| 398 | - | } else if strings.Contains(httpsErr.Error(), "connection refused") { | |
| 399 | - | logger.Warn("Connection refused - firewall may be blocking port 443") | |
| 400 | - | } else if strings.Contains(httpsErr.Error(), "i/o timeout") || strings.Contains(httpsErr.Error(), "deadline exceeded") { | |
| 401 | - | logger.Warn("Connection timeout - port 443 may be filtered or not properly forwarded") | |
| 179 | + | // Start server in a goroutine | |
| 180 | + | errChan := make(chan error, 1) | |
| 181 | + | go func() { | |
| 182 | + | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |
| 183 | + | errChan <- err | |
| 402 | 184 | } | |
| 403 | - | } else { | |
| 404 | - | logger.Info("External HTTPS port 443 is accessible - TLS handshake successful") | |
| 185 | + | }() | |
| 186 | + | ||
| 187 | + | // Wait for context cancellation or server error | |
| 188 | + | select { | |
| 189 | + | case err := <-errChan: | |
| 190 | + | return err | |
| 191 | + | case <-ctx.Done(): | |
| 192 | + | log.Info().Msg("Proxy server shutting down...") | |
| 193 | + | return server.Shutdown(context.Background()) | |
| 405 | 194 | } | |
| 406 | - | */ | |
| 407 | 195 | } | |
| 408 | 196 | ||
| 409 | - | // // detectGordonContainer attempts to find the Gordon container automatically | |
| 410 | - | // func (p *Proxy) detectGordonContainer() string { | |
| 411 | - | // // Check if we're running inside a container | |
| 412 | - | // if docker.IsRunningInContainer() { | |
| 413 | - | // logger.Debug("Detected we're running in a container") | |
| 414 | - | ||
| 415 | - | // // Get our hostname which should be the container ID in Docker/Podman | |
| 416 | - | // hostname := os.Getenv("HOSTNAME") | |
| 417 | - | // if hostname != "" { | |
| 418 | - | // // Check if there's a container with this hostname | |
| 419 | - | // containers, err := docker.ListRunningContainers() | |
| 420 | - | // if err == nil { | |
| 421 | - | // for _, container := range containers { | |
| 422 | - | // // Check if this container is us by comparing hostname with container ID | |
| 423 | - | // if strings.HasPrefix(hostname, container.ID) { | |
| 424 | - | // // This is our container | |
| 425 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | |
| 426 | - | // logger.Debug("Auto-detected Gordon container name from hostname", "container_name", containerName) | |
| 427 | - | // return containerName | |
| 428 | - | // } | |
| 429 | - | // } | |
| 430 | - | // } else { | |
| 431 | - | // logger.Debug("Could not list containers when checking hostname", "error", err) | |
| 432 | - | // } | |
| 433 | - | // } | |
| 434 | - | // } | |
| 435 | - | ||
| 436 | - | // // Try to find Gordon by examining container processes | |
| 437 | - | // containers, err := docker.ListRunningContainers() | |
| 438 | - | // if err == nil { | |
| 439 | - | // for _, container := range containers { | |
| 440 | - | // containerID := container.ID | |
| 441 | - | ||
| 442 | - | // // Check container processes for Gordon | |
| 443 | - | // containerLogs, err := docker.GetContainerLogs(containerID) | |
| 444 | - | // if err == nil && (strings.Contains(containerLogs, "/gordon") || strings.Contains(containerLogs, "serve")) { | |
| 445 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | |
| 446 | - | // logger.Debug("Auto-detected Gordon container by process", "container_name", containerName) | |
| 447 | - | // return containerName | |
| 448 | - | // } else if err != nil { | |
| 449 | - | // logger.Debug("Could not get container logs", "container_id", containerID, "error", err) | |
| 450 | - | // } | |
| 451 | - | ||
| 452 | - | // // Check container command for Gordon | |
| 453 | - | // if len(container.Command) > 0 && strings.Contains(container.Command, "gordon") { | |
| 454 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | |
| 455 | - | // logger.Debug("Auto-detected Gordon container by command", "container_name", containerName) | |
| 456 | - | // return containerName | |
| 457 | - | // } | |
| 458 | - | // } | |
| 459 | - | // } else { | |
| 460 | - | // logger.Debug("Could not list containers when checking processes", "error", err) | |
| 461 | - | // } | |
| 462 | - | ||
| 463 | - | // // If we couldn't detect from processes, try to find a container with gordon in the name or image | |
| 464 | - | // if err == nil { | |
| 465 | - | // for _, container := range containers { | |
| 466 | - | // // Check container names for "gordon" | |
| 467 | - | // for _, name := range container.Names { | |
| 468 | - | // if strings.Contains(strings.ToLower(name), "gordon") { | |
| 469 | - | // containerName := strings.TrimLeft(name, "/") | |
| 470 | - | // logger.Debug("Auto-detected Gordon container by name", "container_name", containerName) | |
| 471 | - | // return containerName | |
| 472 | - | // } | |
| 473 | - | // } | |
| 474 | - | ||
| 475 | - | // // Check image name for "gordon" | |
| 476 | - | // if strings.Contains(strings.ToLower(container.Image), "gordon") { | |
| 477 | - | // containerName := strings.TrimLeft(container.Names[0], "/") | |
| 478 | - | // logger.Debug("Auto-detected Gordon container by image", "container_name", containerName, "image", container.Image) | |
| 479 | - | // return containerName | |
| 480 | - | // } | |
| 481 | - | // } | |
| 482 | - | // } | |
| 483 | - | ||
| 484 | - | // // If all else fails, default to "gordon" | |
| 485 | - | // logger.Debug("Could not auto-detect Gordon container, using default name", "container_name", "gordon") | |
| 486 | - | // return "gordon" | |
| 487 | - | // } | |
| 488 | - | ||
| 489 | - | // StartHTTPSServer starts the HTTPS server | |
| 490 | - | func (p *Proxy) StartHTTPSServer() error { | |
| 491 | - | // Setup main Echo instance for routing (used by both HTTP/HTTPS handlers) | |
| 492 | - | e := p.httpsServer // Use the existing Echo instance | |
| 493 | - | ||
| 494 | - | // If SkipCertificates is true, start a plain HTTP listener instead of HTTPS | |
| 495 | - | if p.config.SkipCertificates { | |
| 496 | - | logger.Info("Starting plain HTTP listener because SkipCertificates is true", | |
| 497 | - | "port", p.config.DefaultHttpChallengePort) | |
| 498 | - | ||
| 499 | - | // Create a standard http.Server for the plain HTTP listener | |
| 500 | - | httpServer := &http.Server{ | |
| 501 | - | Addr: fmt.Sprintf(":%s", p.config.DefaultHttpChallengePort), // Add colon prefix | |
| 502 | - | Handler: e, // Use the main Echo instance | |
| 503 | - | ReadTimeout: 5 * time.Second, | |
| 504 | - | WriteTimeout: 10 * time.Second, | |
| 505 | - | IdleTimeout: 120 * time.Second, | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | // Start plain HTTP listener using http.Server's ListenAndServe | |
| 509 | - | go func() { | |
| 510 | - | logger.Info("Plain HTTP listener starting", "address", httpServer.Addr) | |
| 511 | - | if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 512 | - | // Log specific errors if possible | |
| 513 | - | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 514 | - | logger.Error("Plain HTTP port is already in use", "port", p.config.DefaultHttpChallengePort, "error", err) | |
| 515 | - | } else if strings.Contains(err.Error(), "permission denied") { | |
| 516 | - | logger.Error("Permission denied binding to plain HTTP port", "port", p.config.DefaultHttpChallengePort, "error", err) | |
| 517 | - | } else { | |
| 518 | - | logger.Error("Plain HTTP listener failed", "error", err, "address", httpServer.Addr) | |
| 519 | - | } | |
| 520 | - | } | |
| 521 | - | }() | |
| 522 | - | p.serverStarted = true // Mark server as started | |
| 523 | - | ||
| 524 | - | } else { | |
| 525 | - | // --- Start HTTPS Server (only if SkipCertificates is false) --- | |
| 526 | - | logger.Info("Starting HTTPS server", | |
| 527 | - | "port", p.config.Port, | |
| 528 | - | "mode", p.config.LetsEncryptMode) | |
| 529 | - | ||
| 530 | - | // Verify certificate manager is initialized *only* if we need it for HTTPS | |
| 531 | - | if p.certificateManager == nil { | |
| 532 | - | logger.Error("Certificate manager is nil, cannot start HTTPS server") | |
| 533 | - | return fmt.Errorf("certificate manager not initialized for HTTPS") | |
| 534 | - | } | |
| 535 | - | logger.Info("Using certificate management system for HTTPS") | |
| 197 | + | func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { | |
| 198 | + | ctx := r.Context() | |
| 199 | + | w.Header().Set("Content-Type", "application/json") | |
| 200 | + | ||
| 201 | + | // Get container health status | |
| 202 | + | health := s.manager.HealthCheck(ctx) | |
| 203 | + | containers := s.manager.ListContainers() | |
| 204 | + | ||
| 205 | + | status := map[string]interface{}{ | |
| 206 | + | "status": "running", | |
| 207 | + | "routes": len(s.routes), | |
| 208 | + | "runtime": s.config.Server.Runtime, | |
| 209 | + | "containers": len(containers), | |
| 210 | + | "health": health, | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | w.WriteHeader(http.StatusOK) | |
| 214 | + | fmt.Fprintf(w, `{"status":"%s","routes":%d,"runtime":"%s","containers":%d}`, | |
| 215 | + | status["status"], status["routes"], status["runtime"], status["containers"]) | |
| 216 | + | } | |
| 536 | 217 | ||
| 537 | - | // Setup TLS configuration | |
| 538 | - | tlsConfig := &tls.Config{ | |
| 539 | - | GetCertificate: p.getTLSCertificateFunc(), | |
| 540 | - | MinVersion: tls.VersionTLS12, | |
| 541 | - | CipherSuites: []uint16{ | |
| 542 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, | |
| 543 | - | tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, | |
| 544 | - | tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, | |
| 545 | - | tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, | |
| 546 | - | tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305, | |
| 547 | - | tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305, | |
| 548 | - | }, | |
| 549 | - | PreferServerCipherSuites: true, | |
| 550 | - | NextProtos: []string{"h2", "http/1.1"}, | |
| 551 | - | ServerName: p.app.GetConfig().Http.FullDomain(), | |
| 552 | - | } | |
| 218 | + | func (s *Server) handleReload(w http.ResponseWriter, r *http.Request) { | |
| 219 | + | log.Info().Msg("Configuration reload requested") | |
| 220 | + | ||
| 221 | + | // TODO: Implement configuration reload | |
| 222 | + | // This should reload the config file and update routes | |
| 223 | + | ||
| 224 | + | w.WriteHeader(http.StatusOK) | |
| 225 | + | fmt.Fprint(w, `{"message":"Configuration reloaded"}`) | |
| 226 | + | } | |
| 553 | 227 | ||
| 554 | - | // Create a custom HTTPS server with proper TLS config | |
| 555 | - | httpsServer := &http.Server{ | |
| 556 | - | Addr: fmt.Sprintf(":%s", p.config.Port), // Use the configured HTTPS port (e.g., :443) | |
| 557 | - | Handler: e, // Use the main Echo instance | |
| 558 | - | TLSConfig: tlsConfig, | |
| 559 | - | ReadTimeout: 60 * time.Second, | |
| 560 | - | WriteTimeout: 60 * time.Second, | |
| 561 | - | IdleTimeout: 120 * time.Second, | |
| 562 | - | ReadHeaderTimeout: 30 * time.Second, | |
| 228 | + | func (s *Server) handleListContainers(w http.ResponseWriter, r *http.Request) { | |
| 229 | + | log.Info().Msg("Container list requested") | |
| 230 | + | ||
| 231 | + | containers := s.manager.ListContainers() | |
| 232 | + | ||
| 233 | + | w.Header().Set("Content-Type", "application/json") | |
| 234 | + | w.WriteHeader(http.StatusOK) | |
| 235 | + | ||
| 236 | + | // Simple JSON response - in production would use proper JSON marshaling | |
| 237 | + | fmt.Fprint(w, `{"containers":[`) | |
| 238 | + | first := true | |
| 239 | + | for domain, container := range containers { | |
| 240 | + | if !first { | |
| 241 | + | fmt.Fprint(w, ",") | |
| 563 | 242 | } | |
| 564 | - | ||
| 565 | - | // Store the HTTPS server for later use in Stop() | |
| 566 | - | p.httpsServer.Server = httpsServer // Store the http.Server within Echo | |
| 567 | - | ||
| 568 | - | // Start HTTPS server in a goroutine | |
| 569 | - | go func() { | |
| 570 | - | logger.Info("HTTPS server starting", "address", httpsServer.Addr) | |
| 571 | - | // Using empty strings for cert and key files since GetCertificate is used | |
| 572 | - | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { | |
| 573 | - | // Log specific errors if possible | |
| 574 | - | if strings.Contains(err.Error(), "bind: address already in use") { | |
| 575 | - | logger.Error("HTTPS port is already in use", "port", p.config.Port, "error", err) | |
| 576 | - | } else if strings.Contains(err.Error(), "permission denied") { | |
| 577 | - | logger.Error("Permission denied binding to HTTPS port", "port", p.config.Port, "error", err) | |
| 578 | - | } else if strings.Contains(err.Error(), "certificate") { | |
| 579 | - | logger.Error("Certificate-related error starting HTTPS server", "error", err) | |
| 580 | - | } else { | |
| 581 | - | logger.Error("HTTPS server failed", "error", err, "address", httpsServer.Addr) | |
| 582 | - | } | |
| 583 | - | } | |
| 584 | - | }() | |
| 585 | - | p.serverStarted = true // Mark server as started | |
| 243 | + | fmt.Fprintf(w, `{"domain":"%s","id":"%s","image":"%s","status":"%s"}`, | |
| 244 | + | domain, container.ID, container.Image, container.Status) | |
| 245 | + | first = false | |
| 586 | 246 | } | |
| 587 | - | ||
| 588 | - | // Test admin connection after attempting to start the appropriate server | |
| 589 | - | go p.TestAdminConnectionLater() | |
| 590 | - | ||
| 591 | - | // Remove the old/duplicate HTTP listener logic that was here | |
| 592 | - | ||
| 593 | - | return nil // Return nil indicating server start was initiated | |
| 247 | + | fmt.Fprint(w, `]}`) | |
| 594 | 248 | } | |
| 595 | 249 | ||
| 596 | - | // getTLSCertificateFunc returns a function that gets certificates for TLS handshakes | |
| 597 | - | func (p *Proxy) getTLSCertificateFunc() func(*tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 598 | - | return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { | |
| 599 | - | logger.Debug("Getting certificate for TLS handshake", "server_name", hello.ServerName) | |
| 600 | - | return p.certificateManager.GetCertificateForTLS(hello) | |
| 250 | + | func (s *Server) handleDeploy(w http.ResponseWriter, r *http.Request) { | |
| 251 | + | routeName := r.PathValue("route") | |
| 252 | + | if routeName == "" { | |
| 253 | + | http.Error(w, "Route name required", http.StatusBadRequest) | |
| 254 | + | return | |
| 601 | 255 | } | |
| 602 | - | } | |
| 256 | + | ||
| 257 | + | log.Info().Str("route", routeName).Msg("Manual deployment requested") | |
| 258 | + | ||
| 259 | + | // Find the route | |
| 260 | + | var targetRoute *config.Route | |
| 261 | + | for _, route := range s.routes { | |
| 262 | + | if route.Domain == routeName { | |
| 263 | + | targetRoute = &route | |
| 264 | + | break | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | if targetRoute == nil { | |
| 269 | + | http.Error(w, "Route not found", http.StatusNotFound) | |
| 270 | + | return | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Deploy the container | |
| 274 | + | ctx := r.Context() | |
| 275 | + | container, err := s.manager.DeployContainer(ctx, *targetRoute) | |
| 276 | + | if err != nil { | |
| 277 | + | log.Error().Err(err).Str("route", routeName).Msg("Deployment failed") | |
| 278 | + | http.Error(w, "Deployment failed", http.StatusInternalServerError) | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | w.Header().Set("Content-Type", "application/json") | |
| 283 | + | w.WriteHeader(http.StatusOK) | |
| 284 | + | fmt.Fprintf(w, `{"message":"Deployed successfully","container":"%s","domain":"%s","image":"%s"}`, | |
| 285 | + | container.ID, targetRoute.Domain, targetRoute.Image) | |
| 286 | + | }⏎̸ | |
A internal/registry/server.go +360 -0
| @@ -0,0 +1,360 @@ | |||
| 1 | + | package registry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "net/http" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | ||
| 12 | + | "github.com/rs/zerolog/log" | |
| 13 | + | "gordon/internal/config" | |
| 14 | + | "gordon/internal/middleware" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type Server struct { | |
| 18 | + | config *config.Config | |
| 19 | + | mux *http.ServeMux | |
| 20 | + | storage Storage | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func NewServer(cfg *config.Config) (*Server, error) { | |
| 24 | + | // Initialize storage | |
| 25 | + | storage, err := NewFilesystemStorage(cfg.Server.DataDir + "/registry") | |
| 26 | + | if err != nil { | |
| 27 | + | return nil, fmt.Errorf("failed to initialize storage: %w", err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | s := &Server{ | |
| 31 | + | config: cfg, | |
| 32 | + | mux: http.NewServeMux(), | |
| 33 | + | storage: storage, | |
| 34 | + | } | |
| 35 | + | s.setupRoutes() | |
| 36 | + | return s, nil | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (s *Server) setupRoutes() { | |
| 40 | + | // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns | |
| 41 | + | ||
| 42 | + | // Registry info endpoint | |
| 43 | + | s.mux.HandleFunc("GET /v2/", s.handleRegistryInfo) | |
| 44 | + | ||
| 45 | + | // Since Go's ServeMux requires wildcards to be at the end, we need a different approach | |
| 46 | + | // Use a catch-all handler for /v2/ paths and route internally | |
| 47 | + | s.mux.HandleFunc("/v2/", s.handleV2Routes) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (s *Server) handleV2Routes(w http.ResponseWriter, r *http.Request) { | |
| 51 | + | path := r.URL.Path | |
| 52 | + | method := r.Method | |
| 53 | + | ||
| 54 | + | // Route based on path pattern and method | |
| 55 | + | switch { | |
| 56 | + | case path == "/v2/" && method == "GET": | |
| 57 | + | s.handleRegistryInfo(w, r) | |
| 58 | + | case method == "GET" && matches(path, "/v2/.*/manifests/.*"): | |
| 59 | + | s.handleGetManifest(w, r) | |
| 60 | + | case method == "PUT" && matches(path, "/v2/.*/manifests/.*"): | |
| 61 | + | s.handlePutManifest(w, r) | |
| 62 | + | case method == "GET" && matches(path, "/v2/.*/blobs/.*"): | |
| 63 | + | s.handleGetBlob(w, r) | |
| 64 | + | case method == "POST" && matches(path, "/v2/.*/blobs/uploads/"): | |
| 65 | + | s.handleStartBlobUpload(w, r) | |
| 66 | + | case (method == "PUT" || method == "PATCH") && matches(path, "/v2/.*/blobs/uploads/.*"): | |
| 67 | + | s.handleBlobUpload(w, r) | |
| 68 | + | case method == "GET" && matches(path, "/v2/.*/tags/list"): | |
| 69 | + | s.handleListTags(w, r) | |
| 70 | + | default: | |
| 71 | + | http.NotFound(w, r) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // Simple pattern matching helper | |
| 76 | + | func matches(path, pattern string) bool { | |
| 77 | + | // Convert pattern to regex-like matching | |
| 78 | + | // For now, just check if the pattern structure matches | |
| 79 | + | if pattern == "/v2/.*/manifests/.*" { | |
| 80 | + | return len(path) > 4 && path[:4] == "/v2/" && | |
| 81 | + | len(path) > 14 && path[len(path)-10:] != "/manifests/" && | |
| 82 | + | contains(path, "/manifests/") | |
| 83 | + | } | |
| 84 | + | if pattern == "/v2/.*/blobs/.*" { | |
| 85 | + | return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/") | |
| 86 | + | } | |
| 87 | + | if pattern == "/v2/.*/blobs/uploads/" { | |
| 88 | + | return len(path) > 4 && path[:4] == "/v2/" && | |
| 89 | + | contains(path, "/blobs/uploads/") && path[len(path)-1:] == "/" | |
| 90 | + | } | |
| 91 | + | if pattern == "/v2/.*/blobs/uploads/.*" { | |
| 92 | + | return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/uploads/") | |
| 93 | + | } | |
| 94 | + | if pattern == "/v2/.*/tags/list" { | |
| 95 | + | return len(path) > 4 && path[:4] == "/v2/" && | |
| 96 | + | len(path) > 10 && path[len(path)-10:] == "/tags/list" | |
| 97 | + | } | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func contains(s, substr string) bool { | |
| 102 | + | for i := 0; i <= len(s)-len(substr); i++ { | |
| 103 | + | if s[i:i+len(substr)] == substr { | |
| 104 | + | return true | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | return false | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Path parsing helpers | |
| 111 | + | func (s *Server) parseManifestPath(path string) (name, reference string) { | |
| 112 | + | // Expected format: /v2/{name}/manifests/{reference} | |
| 113 | + | parts := strings.Split(path, "/") | |
| 114 | + | if len(parts) >= 5 && parts[3] == "manifests" { | |
| 115 | + | name = parts[2] | |
| 116 | + | reference = parts[4] | |
| 117 | + | } | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func (s *Server) parseBlobPath(path string) (name, digest string) { | |
| 122 | + | // Expected format: /v2/{name}/blobs/{digest} | |
| 123 | + | parts := strings.Split(path, "/") | |
| 124 | + | if len(parts) >= 5 && parts[3] == "blobs" { | |
| 125 | + | name = parts[2] | |
| 126 | + | digest = parts[4] | |
| 127 | + | } | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | func (s *Server) parseUploadPath(path string) (name, uuid string) { | |
| 132 | + | // Expected format: /v2/{name}/blobs/uploads/{uuid} | |
| 133 | + | parts := strings.Split(path, "/") | |
| 134 | + | if len(parts) >= 6 && parts[3] == "blobs" && parts[4] == "uploads" { | |
| 135 | + | name = parts[2] | |
| 136 | + | if len(parts) > 5 { | |
| 137 | + | uuid = parts[5] | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | return | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (s *Server) parseTagsPath(path string) string { | |
| 144 | + | // Expected format: /v2/{name}/tags/list | |
| 145 | + | parts := strings.Split(path, "/") | |
| 146 | + | if len(parts) >= 4 && parts[3] == "tags" { | |
| 147 | + | return parts[2] | |
| 148 | + | } | |
| 149 | + | return "" | |
| 150 | + | } | |
| 151 | + | ||
| 152 | + | func (s *Server) Start(ctx context.Context) error { | |
| 153 | + | addr := ":" + strconv.Itoa(s.config.Server.RegistryPort) | |
| 154 | + | ||
| 155 | + | // Build middleware chain | |
| 156 | + | middlewares := []func(http.Handler) http.Handler{ | |
| 157 | + | middleware.PanicRecovery, | |
| 158 | + | middleware.RequestLogger, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | // Add registry authentication if enabled | |
| 162 | + | if s.config.Auth.Enabled && s.config.Auth.RegistryAuth { | |
| 163 | + | if s.config.Auth.Username != "" && s.config.Auth.Password != "" { | |
| 164 | + | middlewares = append(middlewares, middleware.RegistryAuth(s.config.Auth.Username, s.config.Auth.Password)) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Wrap the mux with middleware | |
| 169 | + | handler := middleware.Chain(middlewares...)(s.mux) | |
| 170 | + | ||
| 171 | + | server := &http.Server{ | |
| 172 | + | Addr: addr, | |
| 173 | + | Handler: handler, | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | log.Info().Str("address", addr).Msg("Registry server starting") | |
| 177 | + | ||
| 178 | + | // Start server in a goroutine | |
| 179 | + | errChan := make(chan error, 1) | |
| 180 | + | go func() { | |
| 181 | + | if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |
| 182 | + | errChan <- err | |
| 183 | + | } | |
| 184 | + | }() | |
| 185 | + | ||
| 186 | + | // Wait for context cancellation or server error | |
| 187 | + | select { | |
| 188 | + | case err := <-errChan: | |
| 189 | + | return err | |
| 190 | + | case <-ctx.Done(): | |
| 191 | + | log.Info().Msg("Registry server shutting down...") | |
| 192 | + | return server.Shutdown(context.Background()) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func (s *Server) handleRegistryInfo(w http.ResponseWriter, r *http.Request) { | |
| 197 | + | w.Header().Set("Content-Type", "application/json") | |
| 198 | + | w.WriteHeader(http.StatusOK) | |
| 199 | + | fmt.Fprint(w, `{"name":"gordon-registry"}`) | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) { | |
| 203 | + | name, reference := s.parseManifestPath(r.URL.Path) | |
| 204 | + | ||
| 205 | + | log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest") | |
| 206 | + | ||
| 207 | + | manifest, err := s.storage.GetManifest(name, reference) | |
| 208 | + | if err != nil { | |
| 209 | + | log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found") | |
| 210 | + | w.WriteHeader(http.StatusNotFound) | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json") | |
| 215 | + | w.Header().Set("Content-Length", strconv.Itoa(len(manifest))) | |
| 216 | + | w.WriteHeader(http.StatusOK) | |
| 217 | + | w.Write(manifest) | |
| 218 | + | } | |
| 219 | + | ||
| 220 | + | func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |
| 221 | + | name, reference := s.parseManifestPath(r.URL.Path) | |
| 222 | + | ||
| 223 | + | log.Debug().Str("name", name).Str("reference", reference).Msg("PUT manifest") | |
| 224 | + | ||
| 225 | + | // Read manifest data | |
| 226 | + | data, err := io.ReadAll(r.Body) | |
| 227 | + | if err != nil { | |
| 228 | + | log.Error().Err(err).Msg("Failed to read manifest data") | |
| 229 | + | http.Error(w, "Bad Request", http.StatusBadRequest) | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | // Store manifest | |
| 234 | + | if err := s.storage.PutManifest(name, reference, data); err != nil { | |
| 235 | + | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest") | |
| 236 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 237 | + | return | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // Calculate digest for Docker-Content-Digest header | |
| 241 | + | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data)) | |
| 242 | + | w.Header().Set("Docker-Content-Digest", digest) | |
| 243 | + | w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference)) | |
| 244 | + | w.WriteHeader(http.StatusCreated) | |
| 245 | + | ||
| 246 | + | // TODO: Trigger deployment webhook | |
| 247 | + | log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored - deployment should be triggered") | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) { | |
| 251 | + | name, digest := s.parseBlobPath(r.URL.Path) | |
| 252 | + | ||
| 253 | + | log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob") | |
| 254 | + | ||
| 255 | + | reader, err := s.storage.GetBlob(digest) | |
| 256 | + | if err != nil { | |
| 257 | + | log.Warn().Err(err).Str("digest", digest).Msg("Blob not found") | |
| 258 | + | w.WriteHeader(http.StatusNotFound) | |
| 259 | + | return | |
| 260 | + | } | |
| 261 | + | defer reader.Close() | |
| 262 | + | ||
| 263 | + | w.Header().Set("Content-Type", "application/octet-stream") | |
| 264 | + | w.WriteHeader(http.StatusOK) | |
| 265 | + | ||
| 266 | + | _, err = io.Copy(w, reader) | |
| 267 | + | if err != nil { | |
| 268 | + | log.Error().Err(err).Str("digest", digest).Msg("Failed to serve blob") | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) { | |
| 273 | + | name, _ := s.parseUploadPath(r.URL.Path) | |
| 274 | + | ||
| 275 | + | log.Debug().Str("name", name).Msg("Start blob upload") | |
| 276 | + | ||
| 277 | + | uuid, err := s.storage.StartBlobUpload(name) | |
| 278 | + | if err != nil { | |
| 279 | + | log.Error().Err(err).Str("name", name).Msg("Failed to start blob upload") | |
| 280 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 281 | + | return | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid)) | |
| 285 | + | w.Header().Set("Range", "0-0") | |
| 286 | + | w.WriteHeader(http.StatusAccepted) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) { | |
| 290 | + | name, uuid := s.parseUploadPath(r.URL.Path) | |
| 291 | + | ||
| 292 | + | log.Debug().Str("name", name).Str("uuid", uuid).Msg("Blob upload") | |
| 293 | + | ||
| 294 | + | // Check if this is a completion request (has digest parameter) | |
| 295 | + | digest := r.URL.Query().Get("digest") | |
| 296 | + | ||
| 297 | + | if r.Method == "PUT" && digest != "" { | |
| 298 | + | // This is a completion request | |
| 299 | + | if err := s.storage.FinishBlobUpload(uuid, digest); err != nil { | |
| 300 | + | log.Error().Err(err).Str("uuid", uuid).Str("digest", digest).Msg("Failed to finish blob upload") | |
| 301 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 302 | + | return | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest)) | |
| 306 | + | w.Header().Set("Docker-Content-Digest", digest) | |
| 307 | + | w.WriteHeader(http.StatusCreated) | |
| 308 | + | ||
| 309 | + | log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed") | |
| 310 | + | return | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // This is a chunk upload (PATCH) | |
| 314 | + | writer, err := s.storage.GetBlobUpload(uuid) | |
| 315 | + | if err != nil { | |
| 316 | + | log.Error().Err(err).Str("uuid", uuid).Msg("Failed to get blob upload") | |
| 317 | + | http.Error(w, "Not Found", http.StatusNotFound) | |
| 318 | + | return | |
| 319 | + | } | |
| 320 | + | defer writer.Close() | |
| 321 | + | ||
| 322 | + | written, err := io.Copy(writer, r.Body) | |
| 323 | + | if err != nil { | |
| 324 | + | log.Error().Err(err).Str("uuid", uuid).Msg("Failed to write blob chunk") | |
| 325 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid)) | |
| 330 | + | w.Header().Set("Range", fmt.Sprintf("0-%d", written-1)) | |
| 331 | + | w.WriteHeader(http.StatusAccepted) | |
| 332 | + | ||
| 333 | + | log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded") | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) { | |
| 337 | + | name := s.parseTagsPath(r.URL.Path) | |
| 338 | + | ||
| 339 | + | log.Debug().Str("name", name).Msg("List tags") | |
| 340 | + | ||
| 341 | + | tags, err := s.storage.ListTags(name) | |
| 342 | + | if err != nil { | |
| 343 | + | log.Error().Err(err).Str("name", name).Msg("Failed to list tags") | |
| 344 | + | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 345 | + | return | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | w.Header().Set("Content-Type", "application/json") | |
| 349 | + | w.WriteHeader(http.StatusOK) | |
| 350 | + | ||
| 351 | + | // Build JSON response | |
| 352 | + | fmt.Fprintf(w, `{"name":"%s","tags":[`, name) | |
| 353 | + | for i, tag := range tags { | |
| 354 | + | if i > 0 { | |
| 355 | + | fmt.Fprint(w, ",") | |
| 356 | + | } | |
| 357 | + | fmt.Fprintf(w, `"%s"`, tag) | |
| 358 | + | } | |
| 359 | + | fmt.Fprint(w, "]}") | |
| 360 | + | }⏎̸ | |
A internal/registry/storage.go +413 -0
| @@ -0,0 +1,413 @@ | |||
| 1 | + | package registry | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strings" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/rs/zerolog/log" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Storage interface defines the contract for registry storage backends | |
| 16 | + | type Storage interface { | |
| 17 | + | // Manifest operations | |
| 18 | + | GetManifest(name, reference string) ([]byte, error) | |
| 19 | + | PutManifest(name, reference string, data []byte) error | |
| 20 | + | DeleteManifest(name, reference string) error | |
| 21 | + | ||
| 22 | + | // Blob operations | |
| 23 | + | GetBlob(digest string) (io.ReadCloser, error) | |
| 24 | + | PutBlob(digest string, data io.Reader, size int64) error | |
| 25 | + | DeleteBlob(digest string) error | |
| 26 | + | BlobExists(digest string) bool | |
| 27 | + | ||
| 28 | + | // Upload operations | |
| 29 | + | StartBlobUpload(name string) (string, error) // returns upload UUID | |
| 30 | + | GetBlobUpload(uuid string) (io.WriteCloser, error) | |
| 31 | + | FinishBlobUpload(uuid, digest string) error | |
| 32 | + | CancelBlobUpload(uuid string) error | |
| 33 | + | ||
| 34 | + | // Tag operations | |
| 35 | + | ListTags(name string) ([]string, error) | |
| 36 | + | ||
| 37 | + | // Repository operations | |
| 38 | + | ListRepositories() ([]string, error) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // FilesystemStorage implements Storage using the local filesystem | |
| 42 | + | type FilesystemStorage struct { | |
| 43 | + | rootDir string | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | // NewFilesystemStorage creates a new filesystem storage instance | |
| 47 | + | func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) { | |
| 48 | + | // Create directory structure if it doesn't exist | |
| 49 | + | dirs := []string{ | |
| 50 | + | filepath.Join(rootDir, "repositories"), | |
| 51 | + | filepath.Join(rootDir, "blobs"), | |
| 52 | + | filepath.Join(rootDir, "uploads"), | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | for _, dir := range dirs { | |
| 56 | + | if err := os.MkdirAll(dir, 0755); err != nil { | |
| 57 | + | return nil, fmt.Errorf("failed to create directory %s: %w", dir, err) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized") | |
| 62 | + | ||
| 63 | + | return &FilesystemStorage{ | |
| 64 | + | rootDir: rootDir, | |
| 65 | + | }, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Manifest operations | |
| 69 | + | ||
| 70 | + | func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, error) { | |
| 71 | + | manifestPath := fs.getManifestPath(name, reference) | |
| 72 | + | ||
| 73 | + | data, err := os.ReadFile(manifestPath) | |
| 74 | + | if err != nil { | |
| 75 | + | if os.IsNotExist(err) { | |
| 76 | + | return nil, fmt.Errorf("manifest not found: %s/%s", name, reference) | |
| 77 | + | } | |
| 78 | + | return nil, fmt.Errorf("failed to read manifest: %w", err) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | return data, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func (fs *FilesystemStorage) PutManifest(name, reference string, data []byte) error { | |
| 85 | + | manifestPath := fs.getManifestPath(name, reference) | |
| 86 | + | ||
| 87 | + | // Create directory if it doesn't exist | |
| 88 | + | if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil { | |
| 89 | + | return fmt.Errorf("failed to create manifest directory: %w", err) | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Write manifest file | |
| 93 | + | if err := os.WriteFile(manifestPath, data, 0644); err != nil { | |
| 94 | + | return fmt.Errorf("failed to write manifest: %w", err) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // Update tags list | |
| 98 | + | if err := fs.updateTagsList(name, reference); err != nil { | |
| 99 | + | log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored") | |
| 103 | + | return nil | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (fs *FilesystemStorage) DeleteManifest(name, reference string) error { | |
| 107 | + | manifestPath := fs.getManifestPath(name, reference) | |
| 108 | + | ||
| 109 | + | if err := os.Remove(manifestPath); err != nil { | |
| 110 | + | if os.IsNotExist(err) { | |
| 111 | + | return fmt.Errorf("manifest not found: %s/%s", name, reference) | |
| 112 | + | } | |
| 113 | + | return fmt.Errorf("failed to delete manifest: %w", err) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // Remove from tags list | |
| 117 | + | if err := fs.removeFromTagsList(name, reference); err != nil { | |
| 118 | + | log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted") | |
| 122 | + | return nil | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Blob operations | |
| 126 | + | ||
| 127 | + | func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) { | |
| 128 | + | blobPath := fs.getBlobPath(digest) | |
| 129 | + | ||
| 130 | + | file, err := os.Open(blobPath) | |
| 131 | + | if err != nil { | |
| 132 | + | if os.IsNotExist(err) { | |
| 133 | + | return nil, fmt.Errorf("blob not found: %s", digest) | |
| 134 | + | } | |
| 135 | + | return nil, fmt.Errorf("failed to open blob: %w", err) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | return file, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error { | |
| 142 | + | blobPath := fs.getBlobPath(digest) | |
| 143 | + | ||
| 144 | + | // Create directory if it doesn't exist | |
| 145 | + | if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { | |
| 146 | + | return fmt.Errorf("failed to create blob directory: %w", err) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // Create temporary file first | |
| 150 | + | tmpPath := blobPath + ".tmp" | |
| 151 | + | file, err := os.Create(tmpPath) | |
| 152 | + | if err != nil { | |
| 153 | + | return fmt.Errorf("failed to create temporary blob file: %w", err) | |
| 154 | + | } | |
| 155 | + | defer file.Close() | |
| 156 | + | ||
| 157 | + | // Copy data to file | |
| 158 | + | written, err := io.Copy(file, data) | |
| 159 | + | if err != nil { | |
| 160 | + | os.Remove(tmpPath) | |
| 161 | + | return fmt.Errorf("failed to write blob data: %w", err) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // Verify size if provided | |
| 165 | + | if size > 0 && written != size { | |
| 166 | + | os.Remove(tmpPath) | |
| 167 | + | return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | // Move to final location | |
| 171 | + | if err := os.Rename(tmpPath, blobPath); err != nil { | |
| 172 | + | os.Remove(tmpPath) | |
| 173 | + | return fmt.Errorf("failed to move blob to final location: %w", err) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored") | |
| 177 | + | return nil | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (fs *FilesystemStorage) DeleteBlob(digest string) error { | |
| 181 | + | blobPath := fs.getBlobPath(digest) | |
| 182 | + | ||
| 183 | + | if err := os.Remove(blobPath); err != nil { | |
| 184 | + | if os.IsNotExist(err) { | |
| 185 | + | return fmt.Errorf("blob not found: %s", digest) | |
| 186 | + | } | |
| 187 | + | return fmt.Errorf("failed to delete blob: %w", err) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | log.Info().Str("digest", digest).Msg("Blob deleted") | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (fs *FilesystemStorage) BlobExists(digest string) bool { | |
| 195 | + | blobPath := fs.getBlobPath(digest) | |
| 196 | + | _, err := os.Stat(blobPath) | |
| 197 | + | return err == nil | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | // Upload operations | |
| 201 | + | ||
| 202 | + | func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) { | |
| 203 | + | // Generate UUID-like upload ID using timestamp | |
| 204 | + | uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_")) | |
| 205 | + | ||
| 206 | + | uploadPath := fs.getUploadPath(uuid) | |
| 207 | + | ||
| 208 | + | // Create directory if it doesn't exist | |
| 209 | + | if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil { | |
| 210 | + | return "", fmt.Errorf("failed to create upload directory: %w", err) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // Create empty upload file | |
| 214 | + | file, err := os.Create(uploadPath) | |
| 215 | + | if err != nil { | |
| 216 | + | return "", fmt.Errorf("failed to create upload file: %w", err) | |
| 217 | + | } | |
| 218 | + | file.Close() | |
| 219 | + | ||
| 220 | + | log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started") | |
| 221 | + | return uuid, nil | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) { | |
| 225 | + | uploadPath := fs.getUploadPath(uuid) | |
| 226 | + | ||
| 227 | + | file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644) | |
| 228 | + | if err != nil { | |
| 229 | + | if os.IsNotExist(err) { | |
| 230 | + | return nil, fmt.Errorf("upload not found: %s", uuid) | |
| 231 | + | } | |
| 232 | + | return nil, fmt.Errorf("failed to open upload file: %w", err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | return file, nil | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error { | |
| 239 | + | uploadPath := fs.getUploadPath(uuid) | |
| 240 | + | blobPath := fs.getBlobPath(digest) | |
| 241 | + | ||
| 242 | + | // Create blob directory if it doesn't exist | |
| 243 | + | if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { | |
| 244 | + | return fmt.Errorf("failed to create blob directory: %w", err) | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Move upload file to blob location | |
| 248 | + | if err := os.Rename(uploadPath, blobPath); err != nil { | |
| 249 | + | return fmt.Errorf("failed to move upload to blob location: %w", err) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished") | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error { | |
| 257 | + | uploadPath := fs.getUploadPath(uuid) | |
| 258 | + | ||
| 259 | + | if err := os.Remove(uploadPath); err != nil { | |
| 260 | + | if os.IsNotExist(err) { | |
| 261 | + | return fmt.Errorf("upload not found: %s", uuid) | |
| 262 | + | } | |
| 263 | + | return fmt.Errorf("failed to cancel upload: %w", err) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | log.Info().Str("uuid", uuid).Msg("Blob upload cancelled") | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | // Tag operations | |
| 271 | + | ||
| 272 | + | func (fs *FilesystemStorage) ListTags(name string) ([]string, error) { | |
| 273 | + | tagsPath := fs.getTagsPath(name) | |
| 274 | + | ||
| 275 | + | data, err := os.ReadFile(tagsPath) | |
| 276 | + | if err != nil { | |
| 277 | + | if os.IsNotExist(err) { | |
| 278 | + | return []string{}, nil // No tags file means no tags | |
| 279 | + | } | |
| 280 | + | return nil, fmt.Errorf("failed to read tags file: %w", err) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | var tags []string | |
| 284 | + | if err := json.Unmarshal(data, &tags); err != nil { | |
| 285 | + | return nil, fmt.Errorf("failed to parse tags file: %w", err) | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | return tags, nil | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Repository operations | |
| 292 | + | ||
| 293 | + | func (fs *FilesystemStorage) ListRepositories() ([]string, error) { | |
| 294 | + | reposDir := filepath.Join(fs.rootDir, "repositories") | |
| 295 | + | ||
| 296 | + | var repositories []string | |
| 297 | + | err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error { | |
| 298 | + | if err != nil { | |
| 299 | + | return err | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | if info.IsDir() && path != reposDir { | |
| 303 | + | // Get relative path from repositories directory | |
| 304 | + | relPath, err := filepath.Rel(reposDir, path) | |
| 305 | + | if err != nil { | |
| 306 | + | return err | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // Check if this directory contains manifests | |
| 310 | + | manifestsDir := filepath.Join(path, "manifests") | |
| 311 | + | if _, err := os.Stat(manifestsDir); err == nil { | |
| 312 | + | repositories = append(repositories, relPath) | |
| 313 | + | } | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | return nil | |
| 317 | + | }) | |
| 318 | + | ||
| 319 | + | if err != nil { | |
| 320 | + | return nil, fmt.Errorf("failed to list repositories: %w", err) | |
| 321 | + | } | |
| 322 | + | ||
| 323 | + | return repositories, nil | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | // Helper methods for path generation | |
| 327 | + | ||
| 328 | + | func (fs *FilesystemStorage) getManifestPath(name, reference string) string { | |
| 329 | + | return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference) | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func (fs *FilesystemStorage) getBlobPath(digest string) string { | |
| 333 | + | // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...) | |
| 334 | + | parts := strings.SplitN(digest, ":", 2) | |
| 335 | + | if len(parts) != 2 { | |
| 336 | + | // Fallback if digest format is unexpected | |
| 337 | + | return filepath.Join(fs.rootDir, "blobs", digest) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | algorithm := parts[0] | |
| 341 | + | hash := parts[1] | |
| 342 | + | ||
| 343 | + | // Create two-level directory structure for better performance | |
| 344 | + | if len(hash) >= 2 { | |
| 345 | + | return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash) | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | return filepath.Join(fs.rootDir, "blobs", algorithm, hash) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | func (fs *FilesystemStorage) getUploadPath(uuid string) string { | |
| 352 | + | return filepath.Join(fs.rootDir, "uploads", uuid) | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func (fs *FilesystemStorage) getTagsPath(name string) string { | |
| 356 | + | return filepath.Join(fs.rootDir, "repositories", name, "tags.json") | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | // Helper methods for tags management | |
| 360 | + | ||
| 361 | + | func (fs *FilesystemStorage) updateTagsList(name, reference string) error { | |
| 362 | + | tags, err := fs.ListTags(name) | |
| 363 | + | if err != nil { | |
| 364 | + | return err | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // Add tag if not already present | |
| 368 | + | for _, tag := range tags { | |
| 369 | + | if tag == reference { | |
| 370 | + | return nil // Already exists | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | tags = append(tags, reference) | |
| 375 | + | return fs.saveTagsList(name, tags) | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error { | |
| 379 | + | tags, err := fs.ListTags(name) | |
| 380 | + | if err != nil { | |
| 381 | + | return err | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | // Remove tag if present | |
| 385 | + | var newTags []string | |
| 386 | + | for _, tag := range tags { | |
| 387 | + | if tag != reference { | |
| 388 | + | newTags = append(newTags, tag) | |
| 389 | + | } | |
| 390 | + | } | |
| 391 | + | ||
| 392 | + | return fs.saveTagsList(name, newTags) | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error { | |
| 396 | + | tagsPath := fs.getTagsPath(name) | |
| 397 | + | ||
| 398 | + | // Create directory if it doesn't exist | |
| 399 | + | if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil { | |
| 400 | + | return fmt.Errorf("failed to create tags directory: %w", err) | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | data, err := json.Marshal(tags) | |
| 404 | + | if err != nil { | |
| 405 | + | return fmt.Errorf("failed to marshal tags: %w", err) | |
| 406 | + | } | |
| 407 | + | ||
| 408 | + | if err := os.WriteFile(tagsPath, data, 0644); err != nil { | |
| 409 | + | return fmt.Errorf("failed to write tags file: %w", err) | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | return nil | |
| 413 | + | }⏎̸ | |
D internal/server/app_server.go +0 -103
| @@ -1,103 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "database/sql" | |
| 6 | - | "fmt" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | "github.com/bnema/gordon/internal/common" | |
| 10 | - | "github.com/bnema/gordon/internal/db" | |
| 11 | - | "github.com/charmbracelet/log" | |
| 12 | - | _ "github.com/joho/godotenv/autoload" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | type App struct { | |
| 16 | - | // TemplateFS fs.FS // No longer needed | |
| 17 | - | // PublicFS fs.FS // Removed as we now serve assets directly | |
| 18 | - | // LocYML []byte // Replaced by parsed Strings map | |
| 19 | - | Strings map[string]interface{} // Parsed localization strings | |
| 20 | - | DBDir string | |
| 21 | - | DBFilename string | |
| 22 | - | DBPath string | |
| 23 | - | InitialChecksum string | |
| 24 | - | Config common.Config | |
| 25 | - | DB *sql.DB | |
| 26 | - | DBTables DBTables | |
| 27 | - | DBSaveCtx context.Context // Context for the auto-save routine | |
| 28 | - | DBSaveCancel context.CancelFunc // Cancel function for the auto-save routine | |
| 29 | - | StartTime time.Time | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | type DBTables struct { | |
| 33 | - | User db.User `sql:"user"` | |
| 34 | - | Account db.Account `sql:"account"` | |
| 35 | - | Sessions db.Sessions `sql:"sessions"` | |
| 36 | - | Provider db.Provider `sql:"provider"` | |
| 37 | - | Clients db.Clients `sql:"clients"` | |
| 38 | - | Domain db.Domain `sql:"domain"` | |
| 39 | - | Certificate db.Certificate `sql:"certificate"` | |
| 40 | - | ProxyRoute db.ProxyRoute `sql:"proxy_route"` | |
| 41 | - | AcmeAccount db.AcmeAccount `sql:"acme_account"` | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // GetConfig returns the configuration | |
| 45 | - | func (a *App) GetConfig() *common.Config { | |
| 46 | - | return &a.Config | |
| 47 | - | } | |
| 48 | - | ||
| 49 | - | // GetDB returns the database connection | |
| 50 | - | func (a *App) GetDB() *sql.DB { | |
| 51 | - | return a.DB | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // GenerateOauthCallbackURL generates the OAuth callback URL | |
| 55 | - | func (a *App) GenerateOauthCallbackURL() string { | |
| 56 | - | var scheme, port string | |
| 57 | - | config := a.Config | |
| 58 | - | ||
| 59 | - | if config.Http.Https { | |
| 60 | - | scheme = "https" | |
| 61 | - | port = "" | |
| 62 | - | } else { | |
| 63 | - | scheme = "http" | |
| 64 | - | port = fmt.Sprintf(":%s", a.Config.Http.Port) | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | domain := config.Http.Domain | |
| 68 | - | if config.Http.SubDomain != "" { | |
| 69 | - | domain = fmt.Sprintf("%s.%s", config.Http.SubDomain, config.Http.Domain) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | callbackURL := fmt.Sprintf("%s://%s%s/callback", scheme, domain, port) | |
| 73 | - | log.Debug("Generated OAuth Callback URL", "url", callbackURL) | |
| 74 | - | return callbackURL | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func (a *App) IsDevEnvironment() bool { | |
| 78 | - | return a.Config.Build.RunEnv == "dev" | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | func (a *App) GetUptime() string { | |
| 82 | - | uptime := time.Since(a.StartTime) | |
| 83 | - | return uptime.String() | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // GetVersion returns the version of the app | |
| 87 | - | func (a *App) GetVersionstring() string { | |
| 88 | - | return fmt.Sprint(a.Config.Build.BuildVersion) | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // Shutdown performs a clean shutdown of the application | |
| 92 | - | func (a *App) Shutdown() error { | |
| 93 | - | log.Info("Initiating application shutdown") | |
| 94 | - | ||
| 95 | - | // Close the database connection | |
| 96 | - | if err := CloseDB(a); err != nil { | |
| 97 | - | log.Error("Error during database shutdown", "error", err) | |
| 98 | - | return err | |
| 99 | - | } | |
| 100 | - | ||
| 101 | - | log.Info("Application shutdown completed successfully") | |
| 102 | - | return nil | |
| 103 | - | } | |
D internal/server/cronjobs.go +0 -30
| @@ -1,30 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "log" | |
| 5 | - | "time" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // SessionCleaner, time as a string | |
| 9 | - | func SessionCleaner(a *App, currentTime string) error { | |
| 10 | - | query := "DELETE FROM sessions WHERE expires < ?" | |
| 11 | - | _, err := a.DB.Exec(query, currentTime) | |
| 12 | - | if err != nil { | |
| 13 | - | return err | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | return nil | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // StartSessionCleaner initializes the session cleaner to run at regular intervals. | |
| 20 | - | func (a *App) StartSessionCleaner() { | |
| 21 | - | go func() { | |
| 22 | - | for { | |
| 23 | - | currentTime := time.Now().Format(time.RFC3339) | |
| 24 | - | if err := SessionCleaner(a, currentTime); err != nil { | |
| 25 | - | log.Printf("Failed to clean up sessions: %v", err) | |
| 26 | - | } | |
| 27 | - | time.Sleep(1 * time.Hour) | |
| 28 | - | } | |
| 29 | - | }() | |
| 30 | - | } | |
D internal/server/new_server.go +0 -88
| @@ -1,88 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "os" | |
| 5 | - | "path/filepath" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/common" | |
| 9 | - | "github.com/bnema/gordon/internal/proxy" | |
| 10 | - | "github.com/bnema/gordon/internal/templating" | |
| 11 | - | "github.com/bnema/gordon/pkg/logger" | |
| 12 | - | "gopkg.in/yaml.v3" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | func NewServerApp(buildConfig *common.BuildConfig) (*App, error) { | |
| 16 | - | // Get global config singleton instead of loading the config again | |
| 17 | - | config, err := common.GetGlobalConfig(buildConfig) | |
| 18 | - | if err != nil { | |
| 19 | - | logger.Fatalf("Failed to load configuration: %v", err) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | logger.Info("Starting Gordon server", "version", config.Build.BuildVersion) | |
| 23 | - | ||
| 24 | - | // For development environment, use the project root directory | |
| 25 | - | if config.Build.RunEnv == "dev" { | |
| 26 | - | // Get the current working directory as the project root | |
| 27 | - | projectRoot, err := os.Getwd() | |
| 28 | - | if err != nil { | |
| 29 | - | logger.Fatalf("Failed to get current working directory: %v", err) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Set storage directory to project root + /data instead of ~/.gordon | |
| 33 | - | config.General.StorageDir = filepath.Join(projectRoot, "data") | |
| 34 | - | logger.Debug("Dev environment detected, using local storage path", "path", config.General.StorageDir) | |
| 35 | - | ||
| 36 | - | // Create the directory if it doesn't exist | |
| 37 | - | if err := os.MkdirAll(config.General.StorageDir, 0755); err != nil { | |
| 38 | - | logger.Fatalf("Failed to create storage directory: %v", err) | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Read the embedded localization file | |
| 43 | - | locBytes, err := templating.LocFS.ReadFile("models/txt/locstrings.yml") | |
| 44 | - | if err != nil { | |
| 45 | - | logger.Fatalf("Failed to read embedded locstrings.yml: %v", err) | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Parse the YAML content | |
| 49 | - | var stringsMap map[string]interface{} | |
| 50 | - | if err := yaml.Unmarshal(locBytes, &stringsMap); err != nil { | |
| 51 | - | logger.Fatalf("Failed to parse locstrings.yml: %v", err) | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Initialize DB | |
| 55 | - | DBDir := config.General.StorageDir + "/db" | |
| 56 | - | ||
| 57 | - | // Initialize App | |
| 58 | - | a := &App{ | |
| 59 | - | Strings: stringsMap, | |
| 60 | - | DBDir: DBDir, | |
| 61 | - | DBFilename: DBFilename, | |
| 62 | - | Config: *config, | |
| 63 | - | StartTime: time.Now(), | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | a.GenerateOauthCallbackURL() | |
| 67 | - | ||
| 68 | - | return a, nil | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // InitializeProxy initializes and starts the reverse proxy | |
| 72 | - | func (a *App) InitializeProxy() (*proxy.Proxy, error) { | |
| 73 | - | logger.Info("Initializing reverse proxy") | |
| 74 | - | ||
| 75 | - | // Create a new proxy | |
| 76 | - | p, err := proxy.NewProxy(a) | |
| 77 | - | if err != nil { | |
| 78 | - | return nil, err | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Start the proxy | |
| 82 | - | if err := p.Start(); err != nil { | |
| 83 | - | return nil, err | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | logger.Info("Reverse proxy initialized and started") | |
| 87 | - | return p, nil | |
| 88 | - | } | |
D internal/server/sqlite_close.go +0 -62
| @@ -1,62 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/charmbracelet/log" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // CloseDB saves the in-memory database to disk and then closes the database connection. | |
| 10 | - | func CloseDB(a *App) error { | |
| 11 | - | log.Debug("CloseDB called, checking if database connection exists") | |
| 12 | - | ||
| 13 | - | if a.DB != nil { | |
| 14 | - | log.Debug("Database connection exists, proceeding with cleanup") | |
| 15 | - | ||
| 16 | - | // Cancel the auto-save routine | |
| 17 | - | if a.DBSaveCancel != nil { | |
| 18 | - | log.Debug("Canceling auto-save routine") | |
| 19 | - | a.DBSaveCancel() | |
| 20 | - | } else { | |
| 21 | - | log.Debug("No auto-save routine to cancel") | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | log.Info("Saving in-memory database to disk before shutdown") | |
| 25 | - | ||
| 26 | - | // Save the in-memory database to disk | |
| 27 | - | if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil { | |
| 28 | - | log.Error("Failed to save in-memory database to disk during shutdown", "error", err) | |
| 29 | - | // Continue with closing even if saving fails | |
| 30 | - | } else { | |
| 31 | - | log.Info("Successfully saved in-memory database to disk") | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Close the database connection | |
| 35 | - | log.Debug("Closing database connection") | |
| 36 | - | if err := a.DB.Close(); err != nil { | |
| 37 | - | log.Error("Failed to close database connection", "error", err) | |
| 38 | - | return fmt.Errorf("failed to close database: %w", err) | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | log.Info("Database connection closed successfully") | |
| 42 | - | // Set DB to nil to prevent double-close attempts | |
| 43 | - | a.DB = nil | |
| 44 | - | } else { | |
| 45 | - | log.Debug("No database connection to close") | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | return nil | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | // func saveDB(a *App) error { | |
| 52 | - | ||
| 53 | - | // return nil | |
| 54 | - | // } | |
| 55 | - | ||
| 56 | - | // // TODO | |
| 57 | - | // func backupDBFile(a *App) error { | |
| 58 | - | // // Create a backup of the database file at the same location as the database file. | |
| 59 | - | // // The backup file name is the database file name with a timestamp appended to it. | |
| 60 | - | // // If the backup file already exists, overwrite it. | |
| 61 | - | // return nil | |
| 62 | - | // } | |
D internal/server/sqlite_init.go +0 -713
| @@ -1,713 +0,0 @@ | |||
| 1 | - | package server | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "database/sql" | |
| 6 | - | "fmt" | |
| 7 | - | "os" | |
| 8 | - | "path/filepath" | |
| 9 | - | "reflect" | |
| 10 | - | "strings" | |
| 11 | - | "time" | |
| 12 | - | ||
| 13 | - | "github.com/bnema/gordon/internal/db" | |
| 14 | - | pkgsqlite "github.com/bnema/gordon/pkg/sqlite" | |
| 15 | - | "github.com/charmbracelet/log" | |
| 16 | - | "github.com/google/uuid" | |
| 17 | - | _ "modernc.org/sqlite" | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | const ( | |
| 21 | - | DBFilename = "sqlite.db" | |
| 22 | - | // Auto-save interval in minutes | |
| 23 | - | AutoSaveInterval = 5 | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | func (a *App) getDiskDBFilePath() string { | |
| 27 | - | DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename) | |
| 28 | - | // if a.DBDir is empty return log "Set a path using storageDir in the config file" | |
| 29 | - | log.Debug("DB file path", "path", DiskDBfilepath) | |
| 30 | - | return DiskDBfilepath | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // ensureDBDir ensures that the database directory exists. | |
| 34 | - | func (a *App) ensureDBDir() error { | |
| 35 | - | if _, err := os.Stat(a.DBDir); os.IsNotExist(err) { | |
| 36 | - | log.Debug("Creating database directory", "dir", a.DBDir) | |
| 37 | - | if err := os.MkdirAll(a.DBDir, 0755); err != nil { | |
| 38 | - | return err | |
| 39 | - | } | |
| 40 | - | } | |
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // InitializeDB initializes the database in memory and loads data from disk if available | |
| 45 | - | func InitializeDB(a *App) (*sql.DB, error) { | |
| 46 | - | log.Debug("Initializing database") | |
| 47 | - | if err := a.ensureDBDir(); err != nil { | |
| 48 | - | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | diskDBPath := a.getDiskDBFilePath() | |
| 52 | - | a.DBPath = diskDBPath | |
| 53 | - | ||
| 54 | - | // --- Start Refactored Schema Handling --- | |
| 55 | - | log.Debug("Ensuring database schema is up-to-date on disk", "path", diskDBPath) | |
| 56 | - | // UpdateDatabase will create the file and tables if they don't exist, | |
| 57 | - | // or update the schema if they do. It also handles backups. | |
| 58 | - | if err := pkgsqlite.UpdateDatabase(diskDBPath, a.DBTables); err != nil { | |
| 59 | - | return nil, fmt.Errorf("failed to create/update database schema on disk: %w", err) | |
| 60 | - | } | |
| 61 | - | log.Debug("Database schema on disk is up-to-date.") | |
| 62 | - | // --- End Refactored Schema Handling --- | |
| 63 | - | ||
| 64 | - | // --- BEGIN Seed Admin Account if Necessary --- | |
| 65 | - | log.Debug("Checking if admin account seeding is necessary", "path", diskDBPath) | |
| 66 | - | // Need to temporarily open the disk DB to check and potentially write | |
| 67 | - | seedDB, err := sql.Open("sqlite", diskDBPath) | |
| 68 | - | if err != nil { | |
| 69 | - | return nil, fmt.Errorf("failed to open disk DB for seeding check: %w", err) | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | var accountCount int | |
| 73 | - | err = seedDB.QueryRow("SELECT COUNT(*) FROM account").Scan(&accountCount) | |
| 74 | - | if err != nil { | |
| 75 | - | seedDB.Close() | |
| 76 | - | // If the table doesn't exist yet (shouldn't happen after UpdateDatabase), log it | |
| 77 | - | if pkgsqlite.IsNoSuchTableError(err) { | |
| 78 | - | log.Warn("Account table not found during seeding check, skipping seed.", "error", err) | |
| 79 | - | // Proceed without seeding, assuming UpdateDatabase handled creation correctly | |
| 80 | - | } else { | |
| 81 | - | return nil, fmt.Errorf("failed to query account count from disk DB: %w", err) | |
| 82 | - | } | |
| 83 | - | } else if accountCount == 0 { | |
| 84 | - | log.Info("No accounts found, seeding default admin user and account.") | |
| 85 | - | // Begin transaction for seeding | |
| 86 | - | seedTx, txErr := seedDB.Begin() | |
| 87 | - | if txErr != nil { | |
| 88 | - | seedDB.Close() | |
| 89 | - | return nil, fmt.Errorf("failed to begin transaction for seeding: %w", txErr) | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | adminUserID := uuid.NewString() | |
| 93 | - | adminAccountID := uuid.NewString() | |
| 94 | - | adminEmail := "admin@local.host" // Or some other placeholder | |
| 95 | - | ||
| 96 | - | // Insert User | |
| 97 | - | _, txErr = seedTx.Exec("INSERT INTO user (id, name, email) VALUES (?, ?, ?)", | |
| 98 | - | adminUserID, "Admin User", adminEmail) | |
| 99 | - | if txErr != nil { | |
| 100 | - | seedTx.Rollback() | |
| 101 | - | seedDB.Close() | |
| 102 | - | return nil, fmt.Errorf("failed to insert default admin user: %w", txErr) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Insert Account | |
| 106 | - | _, txErr = seedTx.Exec("INSERT INTO account (id, user_id) VALUES (?, ?)", | |
| 107 | - | adminAccountID, adminUserID) | |
| 108 | - | if txErr != nil { | |
| 109 | - | seedTx.Rollback() | |
| 110 | - | seedDB.Close() | |
| 111 | - | return nil, fmt.Errorf("failed to insert default admin account: %w", txErr) | |
| 112 | - | } | |
| 113 | - | ||
| 114 | - | // Commit transaction | |
| 115 | - | txErr = seedTx.Commit() | |
| 116 | - | if txErr != nil { | |
| 117 | - | seedDB.Close() | |
| 118 | - | return nil, fmt.Errorf("failed to commit seeding transaction: %w", txErr) | |
| 119 | - | } | |
| 120 | - | log.Info("Default admin user and account seeded successfully", "user_id", adminUserID, "account_id", adminAccountID) | |
| 121 | - | } else { | |
| 122 | - | log.Debug("Accounts already exist, skipping admin seeding.", "count", accountCount) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Close the temporary connection used for seeding | |
| 126 | - | if err := seedDB.Close(); err != nil { | |
| 127 | - | // Log error but don't necessarily fail initialization at this point | |
| 128 | - | log.Error("Error closing temporary seed DB connection", "error", err) | |
| 129 | - | } | |
| 130 | - | // --- END Seed Admin Account if Necessary --- | |
| 131 | - | ||
| 132 | - | // Create in-memory database connection (remains the same) | |
| 133 | - | log.Debug("Opening in-memory SQLite database") | |
| 134 | - | memDB, err := sql.Open("sqlite", "file::memory:?cache=shared") | |
| 135 | - | if err != nil { | |
| 136 | - | return nil, fmt.Errorf("failed to open in-memory DB: %w", err) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // Test that the in-memory database is working (remains the same) | |
| 140 | - | log.Debug("Testing in-memory database connection") | |
| 141 | - | if err := memDB.Ping(); err != nil { | |
| 142 | - | memDB.Close() // Close memDB if ping fails | |
| 143 | - | return nil, fmt.Errorf("failed to ping in-memory database: %w", err) | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // --- Load from Disk to Memory --- | |
| 147 | - | // Now that the disk schema is guaranteed to be correct, load it into memory. | |
| 148 | - | // We need to check if the file actually contains data before loading. | |
| 149 | - | // A simple way is to check if the file size is > 0 after UpdateDatabase. | |
| 150 | - | fileInfo, err := os.Stat(diskDBPath) | |
| 151 | - | loadNeeded := true | |
| 152 | - | if err != nil { | |
| 153 | - | if os.IsNotExist(err) { | |
| 154 | - | log.Debug("Database file still doesn't exist after schema update (likely first run with no tables?), skipping load from disk.") | |
| 155 | - | loadNeeded = false // Nothing to load | |
| 156 | - | } else { | |
| 157 | - | memDB.Close() | |
| 158 | - | return nil, fmt.Errorf("failed to stat disk DB file after schema update: %w", err) | |
| 159 | - | } | |
| 160 | - | } else if fileInfo.Size() == 0 { | |
| 161 | - | log.Debug("Database file is empty after schema update, skipping load from disk.") | |
| 162 | - | loadNeeded = false // Nothing to load | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | if loadNeeded { | |
| 166 | - | log.Debug("Loading database from disk into memory", "path", diskDBPath) | |
| 167 | - | if err := loadDiskDBToMemory(diskDBPath, memDB); err != nil { | |
| 168 | - | memDB.Close() | |
| 169 | - | return nil, fmt.Errorf("failed to load disk DB into memory: %w", err) | |
| 170 | - | } | |
| 171 | - | log.Debug("Successfully loaded disk database into memory") | |
| 172 | - | } else { | |
| 173 | - | // If we didn't load, the memory DB is empty but has the correct schema | |
| 174 | - | // implicitly because UpdateDatabase already ran on the (possibly new) disk file. | |
| 175 | - | // However, loadDiskDBToMemory *also* creates the schema in memory based on the disk file. | |
| 176 | - | // If the disk file was *just* created by UpdateDatabase and is empty, | |
| 177 | - | // loadDiskDBToMemory won't run, and memDB won't have the tables yet. | |
| 178 | - | // We need to ensure the schema exists in memory even if the disk file was initially empty. | |
| 179 | - | // Easiest way: re-apply the schema creation logic to memDB. | |
| 180 | - | log.Debug("Applying schema to empty in-memory database") | |
| 181 | - | memTx, err := memDB.Begin() | |
| 182 | - | if err != nil { | |
| 183 | - | memDB.Close() | |
| 184 | - | return nil, fmt.Errorf("failed to begin transaction on memory DB for schema creation: %w", err) | |
| 185 | - | } | |
| 186 | - | schemaCreated := false | |
| 187 | - | // Iterate through DBTables fields using reflection to get correct table names | |
| 188 | - | dbTablesType := reflect.TypeOf(a.DBTables) | |
| 189 | - | dbTablesValue := reflect.ValueOf(a.DBTables) | |
| 190 | - | if dbTablesType.Kind() == reflect.Ptr { | |
| 191 | - | dbTablesType = dbTablesType.Elem() | |
| 192 | - | dbTablesValue = dbTablesValue.Elem() | |
| 193 | - | } | |
| 194 | - | ||
| 195 | - | if dbTablesType.Kind() == reflect.Struct { | |
| 196 | - | log.Debug("Reflecting DBTables struct for in-memory schema creation", "type", dbTablesType.Name()) | |
| 197 | - | for i := 0; i < dbTablesType.NumField(); i++ { | |
| 198 | - | field := dbTablesType.Field(i) | |
| 199 | - | fieldValue := dbTablesValue.Field(i).Interface() // e.g., db.ProxyRoute instance | |
| 200 | - | sqlTag := field.Tag.Get("sql") | |
| 201 | - | ||
| 202 | - | if sqlTag == "" || sqlTag == "-" { | |
| 203 | - | log.Debug("Skipping field without sql tag in memory schema creation", "field", field.Name) | |
| 204 | - | continue // Skip fields without a sql tag or marked to be ignored | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | tableName := sqlTag // Use the tag directly as the table name | |
| 208 | - | log.Debug("Attempting to create table in memory", "tableName", tableName, "structType", reflect.TypeOf(fieldValue).Name()) | |
| 209 | - | ||
| 210 | - | if err := pkgsqlite.CreateTableInTx(memTx, fieldValue, tableName); err != nil { | |
| 211 | - | // Ignore "table already exists" errors if somehow they occur | |
| 212 | - | if !strings.Contains(strings.ToLower(err.Error()), "already exists") { | |
| 213 | - | log.Error("Failed to create table in memory", "table", tableName, "error", err) | |
| 214 | - | memTx.Rollback() | |
| 215 | - | memDB.Close() | |
| 216 | - | return nil, fmt.Errorf("failed to create table %s in memory: %w", tableName, err) | |
| 217 | - | } else { | |
| 218 | - | log.Debug("Table already exists in memory, skipping creation", "table", tableName) | |
| 219 | - | } | |
| 220 | - | } else { | |
| 221 | - | log.Debug("Successfully created table in memory", "table", tableName) | |
| 222 | - | schemaCreated = true | |
| 223 | - | } | |
| 224 | - | } | |
| 225 | - | } else { | |
| 226 | - | log.Error("a.DBTables is not a struct, cannot create schema in memory") | |
| 227 | - | memDB.Close() | |
| 228 | - | return nil, fmt.Errorf("internal error: DBTables is not a struct type (%T)", a.DBTables) | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | if err := memTx.Commit(); err != nil { | |
| 232 | - | memDB.Close() | |
| 233 | - | return nil, fmt.Errorf("failed to commit schema creation transaction to memory DB: %w", err) | |
| 234 | - | } | |
| 235 | - | if schemaCreated { | |
| 236 | - | log.Debug("Successfully applied schema to in-memory database") | |
| 237 | - | } else { | |
| 238 | - | log.Debug("In-memory schema already existed or no tables defined.") | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | } | |
| 242 | - | // --- End Load from Disk to Memory --- | |
| 243 | - | ||
| 244 | - | a.DB = memDB | |
| 245 | - | ||
| 246 | - | // Populate struct tables if data was loaded | |
| 247 | - | if loadNeeded { | |
| 248 | - | log.Debug("Populating struct tables from loaded database") | |
| 249 | - | if err := PopulateStructWithTables(a); err != nil { | |
| 250 | - | // Don't fail initialization for this, maybe just log an error | |
| 251 | - | log.Error("Failed to populate struct tables after loading DB", "error", err) | |
| 252 | - | // Depending on severity, you might want to return nil, err here | |
| 253 | - | } | |
| 254 | - | } else { | |
| 255 | - | log.Debug("Skipping struct table population as no data was loaded from disk.") | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | // Create context for auto-save (remains the same) | |
| 259 | - | a.DBSaveCtx, a.DBSaveCancel = context.WithCancel(context.Background()) | |
| 260 | - | ||
| 261 | - | // Start auto-save routine (remains the same) | |
| 262 | - | log.Debug("Starting auto-save routine with interval", "minutes", AutoSaveInterval) | |
| 263 | - | go autoSaveDB(a.DBSaveCtx, a, AutoSaveInterval) | |
| 264 | - | ||
| 265 | - | log.Info("Database initialization completed successfully") | |
| 266 | - | return memDB, nil | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | // loadDiskDBToMemory loads the database from disk into memory | |
| 270 | - | func loadDiskDBToMemory(diskDBPath string, memDB *sql.DB) error { | |
| 271 | - | log.Debug("Opening disk database for loading", "path", diskDBPath) | |
| 272 | - | // Open the disk database | |
| 273 | - | diskDB, err := sql.Open("sqlite", diskDBPath) | |
| 274 | - | if err != nil { | |
| 275 | - | return fmt.Errorf("failed to open disk DB for loading: %w", err) | |
| 276 | - | } | |
| 277 | - | defer diskDB.Close() | |
| 278 | - | ||
| 279 | - | // Backup disk database to memory | |
| 280 | - | // First, get all table names | |
| 281 | - | log.Debug("Retrieving table names from disk database") | |
| 282 | - | rows, err := diskDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") | |
| 283 | - | if err != nil { | |
| 284 | - | return fmt.Errorf("failed to get table names: %w", err) | |
| 285 | - | } | |
| 286 | - | defer rows.Close() | |
| 287 | - | ||
| 288 | - | // Begin transaction for memory DB | |
| 289 | - | log.Debug("Beginning transaction on memory database") | |
| 290 | - | memTx, err := memDB.Begin() | |
| 291 | - | if err != nil { | |
| 292 | - | return fmt.Errorf("failed to begin transaction on memory DB: %w", err) | |
| 293 | - | } | |
| 294 | - | defer func() { | |
| 295 | - | if err != nil { | |
| 296 | - | log.Debug("Rolling back memory database transaction due to error") | |
| 297 | - | memTx.Rollback() | |
| 298 | - | } | |
| 299 | - | }() | |
| 300 | - | ||
| 301 | - | tableCount := 0 | |
| 302 | - | // For each table, create it in memory and copy data | |
| 303 | - | for rows.Next() { | |
| 304 | - | var tableName string | |
| 305 | - | if err := rows.Scan(&tableName); err != nil { | |
| 306 | - | return fmt.Errorf("failed to scan table name: %w", err) | |
| 307 | - | } | |
| 308 | - | tableCount++ | |
| 309 | - | ||
| 310 | - | log.Debug("Processing table", "name", tableName) | |
| 311 | - | ||
| 312 | - | // Get table schema | |
| 313 | - | var createStmt string | |
| 314 | - | err := diskDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt) | |
| 315 | - | if err != nil { | |
| 316 | - | return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err) | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // Create table in memory | |
| 320 | - | log.Debug("Creating table in memory", "name", tableName) | |
| 321 | - | _, err = memTx.Exec(createStmt) | |
| 322 | - | if err != nil { | |
| 323 | - | return fmt.Errorf("failed to create table %s in memory: %w", tableName, err) | |
| 324 | - | } | |
| 325 | - | ||
| 326 | - | // Get all data from disk table | |
| 327 | - | log.Debug("Retrieving data from disk table", "name", tableName) | |
| 328 | - | dataRows, err := diskDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName)) | |
| 329 | - | if err != nil { | |
| 330 | - | return fmt.Errorf("failed to query data from table %s: %w", tableName, err) | |
| 331 | - | } | |
| 332 | - | defer dataRows.Close() | |
| 333 | - | ||
| 334 | - | // Get column names | |
| 335 | - | columns, err := dataRows.Columns() | |
| 336 | - | if err != nil { | |
| 337 | - | return fmt.Errorf("failed to get columns for table %s: %w", tableName, err) | |
| 338 | - | } | |
| 339 | - | log.Debug("Table columns", "name", tableName, "columns", columns) | |
| 340 | - | ||
| 341 | - | // Prepare insert statement for memory DB | |
| 342 | - | placeholders := make([]string, len(columns)) | |
| 343 | - | for i := range placeholders { | |
| 344 | - | placeholders[i] = "?" | |
| 345 | - | } | |
| 346 | - | insertStmt, err := memTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ","))) | |
| 347 | - | if err != nil { | |
| 348 | - | return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err) | |
| 349 | - | } | |
| 350 | - | defer insertStmt.Close() | |
| 351 | - | ||
| 352 | - | // Copy data row by row | |
| 353 | - | rowCount := 0 | |
| 354 | - | for dataRows.Next() { | |
| 355 | - | // Create a slice of interface{} to hold the values | |
| 356 | - | values := make([]interface{}, len(columns)) | |
| 357 | - | valuePtrs := make([]interface{}, len(columns)) | |
| 358 | - | for i := range values { | |
| 359 | - | valuePtrs[i] = &values[i] | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | // Scan the row into the slice | |
| 363 | - | if err := dataRows.Scan(valuePtrs...); err != nil { | |
| 364 | - | return fmt.Errorf("failed to scan row from table %s: %w", tableName, err) | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | // Insert into memory DB | |
| 368 | - | _, err = insertStmt.Exec(values...) | |
| 369 | - | if err != nil { | |
| 370 | - | return fmt.Errorf("failed to insert row into memory table %s: %w", tableName, err) | |
| 371 | - | } | |
| 372 | - | rowCount++ | |
| 373 | - | } | |
| 374 | - | log.Debug("Copied rows to memory", "table", tableName, "count", rowCount) | |
| 375 | - | ||
| 376 | - | if err := dataRows.Err(); err != nil { | |
| 377 | - | return fmt.Errorf("error iterating rows for table %s: %w", tableName, err) | |
| 378 | - | } | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | if err := rows.Err(); err != nil { | |
| 382 | - | return fmt.Errorf("error iterating table names: %w", err) | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | // Commit transaction | |
| 386 | - | log.Debug("Committing transaction to memory database") | |
| 387 | - | if err := memTx.Commit(); err != nil { | |
| 388 | - | return fmt.Errorf("failed to commit transaction to memory DB: %w", err) | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | log.Debug("Successfully loaded database from disk to memory", "tables", tableCount) | |
| 392 | - | return nil | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | // saveMemDBToDisk saves the in-memory database to disk | |
| 396 | - | func saveMemDBToDisk(memDB *sql.DB, diskDBPath string) error { | |
| 397 | - | log.Debug("Saving in-memory database to disk", "path", diskDBPath) | |
| 398 | - | ||
| 399 | - | // Create a backup of the existing file if it exists | |
| 400 | - | if _, err := os.Stat(diskDBPath); err == nil { | |
| 401 | - | backupPath := diskDBPath + ".bak" | |
| 402 | - | log.Debug("Creating backup of existing database file", "backup", backupPath) | |
| 403 | - | if err := os.Rename(diskDBPath, backupPath); err != nil { | |
| 404 | - | log.Warn("Failed to create backup of database file", "error", err) | |
| 405 | - | // Continue anyway, as this is just a backup | |
| 406 | - | } else { | |
| 407 | - | log.Debug("Created backup of database file", "backup", backupPath) | |
| 408 | - | } | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | // Open the disk database | |
| 412 | - | log.Debug("Opening disk database for saving") | |
| 413 | - | diskDB, err := sql.Open("sqlite", diskDBPath) | |
| 414 | - | if err != nil { | |
| 415 | - | return fmt.Errorf("failed to open disk DB for saving: %w", err) | |
| 416 | - | } | |
| 417 | - | defer diskDB.Close() | |
| 418 | - | ||
| 419 | - | // Get all table names from memory DB | |
| 420 | - | log.Debug("Retrieving table names from memory database") | |
| 421 | - | rows, err := memDB.Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'") | |
| 422 | - | if err != nil { | |
| 423 | - | return fmt.Errorf("failed to get table names from memory: %w", err) | |
| 424 | - | } | |
| 425 | - | defer rows.Close() | |
| 426 | - | ||
| 427 | - | // Begin transaction for disk DB | |
| 428 | - | log.Debug("Beginning transaction on disk database") | |
| 429 | - | diskTx, err := diskDB.Begin() | |
| 430 | - | if err != nil { | |
| 431 | - | return fmt.Errorf("failed to begin transaction on disk DB: %w", err) | |
| 432 | - | } | |
| 433 | - | defer func() { | |
| 434 | - | if err != nil { | |
| 435 | - | log.Debug("Rolling back disk database transaction due to error") | |
| 436 | - | diskTx.Rollback() | |
| 437 | - | } | |
| 438 | - | }() | |
| 439 | - | ||
| 440 | - | tableCount := 0 | |
| 441 | - | // For each table, create it on disk and copy data | |
| 442 | - | for rows.Next() { | |
| 443 | - | var tableName string | |
| 444 | - | if err := rows.Scan(&tableName); err != nil { | |
| 445 | - | return fmt.Errorf("failed to scan table name: %w", err) | |
| 446 | - | } | |
| 447 | - | tableCount++ | |
| 448 | - | ||
| 449 | - | log.Debug("Processing table for saving", "name", tableName) | |
| 450 | - | ||
| 451 | - | // Get table schema | |
| 452 | - | var createStmt string | |
| 453 | - | err := memDB.QueryRow(fmt.Sprintf("SELECT sql FROM sqlite_master WHERE type='table' AND name='%s'", tableName)).Scan(&createStmt) | |
| 454 | - | if err != nil { | |
| 455 | - | return fmt.Errorf("failed to get create statement for table %s: %w", tableName, err) | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Create table on disk | |
| 459 | - | log.Debug("Creating table on disk", "name", tableName) | |
| 460 | - | _, err = diskTx.Exec(createStmt) | |
| 461 | - | if err != nil { | |
| 462 | - | return fmt.Errorf("failed to create table %s on disk: %w", tableName, err) | |
| 463 | - | } | |
| 464 | - | ||
| 465 | - | // Get all data from memory table | |
| 466 | - | log.Debug("Retrieving data from memory table", "name", tableName) | |
| 467 | - | dataRows, err := memDB.Query(fmt.Sprintf("SELECT * FROM %s", tableName)) | |
| 468 | - | if err != nil { | |
| 469 | - | return fmt.Errorf("failed to query data from memory table %s: %w", tableName, err) | |
| 470 | - | } | |
| 471 | - | defer dataRows.Close() | |
| 472 | - | ||
| 473 | - | // Get column names | |
| 474 | - | columns, err := dataRows.Columns() | |
| 475 | - | if err != nil { | |
| 476 | - | return fmt.Errorf("failed to get columns for table %s: %w", tableName, err) | |
| 477 | - | } | |
| 478 | - | ||
| 479 | - | // Prepare insert statement for disk DB | |
| 480 | - | placeholders := make([]string, len(columns)) | |
| 481 | - | for i := range placeholders { | |
| 482 | - | placeholders[i] = "?" | |
| 483 | - | } | |
| 484 | - | insertStmt, err := diskTx.Prepare(fmt.Sprintf("INSERT INTO %s VALUES (%s)", tableName, joinStrings(placeholders, ","))) | |
| 485 | - | if err != nil { | |
| 486 | - | return fmt.Errorf("failed to prepare insert statement for table %s: %w", tableName, err) | |
| 487 | - | } | |
| 488 | - | defer insertStmt.Close() | |
| 489 | - | ||
| 490 | - | // Copy data row by row | |
| 491 | - | rowCount := 0 | |
| 492 | - | for dataRows.Next() { | |
| 493 | - | // Create a slice of interface{} to hold the values | |
| 494 | - | values := make([]interface{}, len(columns)) | |
| 495 | - | valuePtrs := make([]interface{}, len(columns)) | |
| 496 | - | for i := range values { | |
| 497 | - | valuePtrs[i] = &values[i] | |
| 498 | - | } | |
| 499 | - | ||
| 500 | - | // Scan the row into the slice | |
| 501 | - | if err := dataRows.Scan(valuePtrs...); err != nil { | |
| 502 | - | return fmt.Errorf("failed to scan row from memory table %s: %w", tableName, err) | |
| 503 | - | } | |
| 504 | - | ||
| 505 | - | // Insert into disk DB | |
| 506 | - | _, err = insertStmt.Exec(values...) | |
| 507 | - | if err != nil { | |
| 508 | - | return fmt.Errorf("failed to insert row into disk table %s: %w", tableName, err) | |
| 509 | - | } | |
| 510 | - | rowCount++ | |
| 511 | - | } | |
| 512 | - | log.Debug("Copied rows to disk", "table", tableName, "count", rowCount) | |
| 513 | - | ||
| 514 | - | if err := dataRows.Err(); err != nil { | |
| 515 | - | return fmt.Errorf("error iterating rows for table %s: %w", tableName, err) | |
| 516 | - | } | |
| 517 | - | } | |
| 518 | - | ||
| 519 | - | if err := rows.Err(); err != nil { | |
| 520 | - | return fmt.Errorf("error iterating table names: %w", err) | |
| 521 | - | } | |
| 522 | - | ||
| 523 | - | // Commit transaction | |
| 524 | - | log.Debug("Committing transaction to disk database") | |
| 525 | - | if err := diskTx.Commit(); err != nil { | |
| 526 | - | return fmt.Errorf("failed to commit transaction to disk DB: %w", err) | |
| 527 | - | } | |
| 528 | - | ||
| 529 | - | log.Debug("Successfully saved in-memory database to disk", "tables", tableCount) | |
| 530 | - | return nil | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | // autoSaveDB periodically saves the in-memory database to disk | |
| 534 | - | func autoSaveDB(ctx context.Context, a *App, intervalMinutes int) { | |
| 535 | - | ticker := time.NewTicker(time.Duration(intervalMinutes) * time.Minute) | |
| 536 | - | defer ticker.Stop() | |
| 537 | - | ||
| 538 | - | log.Debug("Auto-save routine started", "interval_minutes", intervalMinutes) | |
| 539 | - | ||
| 540 | - | for { | |
| 541 | - | select { | |
| 542 | - | case <-ticker.C: | |
| 543 | - | log.Debug("Auto-saving in-memory database to disk") | |
| 544 | - | if err := saveMemDBToDisk(a.DB, a.DBPath); err != nil { | |
| 545 | - | log.Error("Failed to auto-save in-memory database to disk", "error", err) | |
| 546 | - | } else { | |
| 547 | - | log.Debug("Auto-save completed successfully") | |
| 548 | - | } | |
| 549 | - | case <-ctx.Done(): | |
| 550 | - | log.Debug("Auto-save routine stopping due to context cancellation") | |
| 551 | - | return | |
| 552 | - | } | |
| 553 | - | } | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | // Helper function to join strings with a separator | |
| 557 | - | func joinStrings(strings []string, separator string) string { | |
| 558 | - | if len(strings) == 0 { | |
| 559 | - | return "" | |
| 560 | - | } | |
| 561 | - | result := strings[0] | |
| 562 | - | for i := 1; i < len(strings); i++ { | |
| 563 | - | result += separator + strings[i] | |
| 564 | - | } | |
| 565 | - | return result | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | type DBTable interface { | |
| 569 | - | ScanFromRows(rows *sql.Rows) error | |
| 570 | - | } | |
| 571 | - | ||
| 572 | - | type UserWrapper struct { | |
| 573 | - | *db.User | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | type AccountWrapper struct { | |
| 577 | - | *db.Account | |
| 578 | - | } | |
| 579 | - | ||
| 580 | - | type ProviderWrapper struct { | |
| 581 | - | *db.Provider | |
| 582 | - | } | |
| 583 | - | ||
| 584 | - | type SessionsWrapper struct { | |
| 585 | - | *db.Sessions | |
| 586 | - | } | |
| 587 | - | ||
| 588 | - | type ClientsWrapper struct { | |
| 589 | - | *db.Clients | |
| 590 | - | } | |
| 591 | - | ||
| 592 | - | type DomainWrapper struct { | |
| 593 | - | *db.Domain | |
| 594 | - | } | |
| 595 | - | ||
| 596 | - | type CertificateWrapper struct { | |
| 597 | - | *db.Certificate | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | type ProxyRouteWrapper struct { | |
| 601 | - | *db.ProxyRoute | |
| 602 | - | } | |
| 603 | - | ||
| 604 | - | func populateTableFromDB(a *App, query string, table DBTable) error { | |
| 605 | - | rows, err := a.DB.Query(query) | |
| 606 | - | if err != nil { | |
| 607 | - | return err | |
| 608 | - | } | |
| 609 | - | defer rows.Close() | |
| 610 | - | ||
| 611 | - | for rows.Next() { | |
| 612 | - | if err := table.ScanFromRows(rows); err != nil { | |
| 613 | - | return err | |
| 614 | - | } | |
| 615 | - | } | |
| 616 | - | return rows.Err() | |
| 617 | - | } | |
| 618 | - | ||
| 619 | - | // Implementation of DBTable for User, Account, Provider, Sessions | |
| 620 | - | func (u *UserWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 621 | - | return rows.Scan(&u.ID, &u.Name, &u.Email) | |
| 622 | - | } | |
| 623 | - | ||
| 624 | - | func (a *AccountWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 625 | - | return rows.Scan(&a.ID, &a.UserID) | |
| 626 | - | } | |
| 627 | - | ||
| 628 | - | func (p *ProviderWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 629 | - | return rows.Scan(&p.ID, &p.AccountID, &p.Name, &p.Login, &p.AvatarURL, &p.ProfileURL, &p.Email) | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | func (s *SessionsWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 633 | - | return rows.Scan(&s.ID, &s.AccountID, &s.AccessToken, &s.BrowserInfo, &s.Expires, &s.IsActive) | |
| 634 | - | } | |
| 635 | - | ||
| 636 | - | func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 637 | - | return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires) | |
| 638 | - | } | |
| 639 | - | ||
| 640 | - | func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 641 | - | return rows.Scan( | |
| 642 | - | &d.ID, | |
| 643 | - | &d.Name, | |
| 644 | - | &d.AccountID, | |
| 645 | - | &d.CreatedAt, | |
| 646 | - | &d.UpdatedAt, | |
| 647 | - | &d.AcmeEnabled, | |
| 648 | - | &d.AcmeChallengeType, | |
| 649 | - | &d.AcmeDnsProvider, | |
| 650 | - | &d.AcmeDnsCredentialsRef, | |
| 651 | - | ) | |
| 652 | - | } | |
| 653 | - | ||
| 654 | - | func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 655 | - | return rows.Scan(&c.ID, &c.DomainID, &c.CertFile, &c.KeyFile, &c.IssuedAt, &c.ExpiresAt, &c.Issuer, &c.Status) | |
| 656 | - | } | |
| 657 | - | ||
| 658 | - | func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 659 | - | return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active) | |
| 660 | - | } | |
| 661 | - | ||
| 662 | - | // PopulateStructWithTables updates db struct with tables | |
| 663 | - | func PopulateStructWithTables(a *App) error { | |
| 664 | - | // Populate the User table | |
| 665 | - | userWrapper := &UserWrapper{User: &a.DBTables.User} | |
| 666 | - | if err := populateTableFromDB(a, "SELECT id, name, email FROM user", userWrapper); err != nil { | |
| 667 | - | return err | |
| 668 | - | } | |
| 669 | - | ||
| 670 | - | // Populate the Account table | |
| 671 | - | accountWrapper := &AccountWrapper{Account: &a.DBTables.Account} | |
| 672 | - | if err := populateTableFromDB(a, "SELECT id, user_id FROM account", accountWrapper); err != nil { | |
| 673 | - | return err | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | // Populate the Provider table | |
| 677 | - | providerWrapper := &ProviderWrapper{Provider: &a.DBTables.Provider} | |
| 678 | - | if err := populateTableFromDB(a, "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider", providerWrapper); err != nil { | |
| 679 | - | return err | |
| 680 | - | } | |
| 681 | - | ||
| 682 | - | // Populate the Sessions table | |
| 683 | - | sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions} | |
| 684 | - | if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_active FROM sessions", sessionsWrapper); err != nil { | |
| 685 | - | return err | |
| 686 | - | } | |
| 687 | - | ||
| 688 | - | // Populate the Clients table | |
| 689 | - | clientsWrapper := &ClientsWrapper{Clients: &a.DBTables.Clients} | |
| 690 | - | if err := populateTableFromDB(a, "SELECT id, account_id, os, ip, hostname, expires FROM clients", clientsWrapper); err != nil { | |
| 691 | - | return err | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | // Populate the Domain table | |
| 695 | - | domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain} | |
| 696 | - | if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at, acme_enabled, acme_challenge_type, acme_dns_provider, acme_dns_credentials_ref FROM domain", domainWrapper); err != nil { | |
| 697 | - | return err | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | // Populate the Certificate table | |
| 701 | - | certificateWrapper := &CertificateWrapper{Certificate: &a.DBTables.Certificate} | |
| 702 | - | if err := populateTableFromDB(a, "SELECT id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status FROM certificate", certificateWrapper); err != nil { | |
| 703 | - | return err | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | // Populate the ProxyRoute table | |
| 707 | - | proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute} | |
| 708 | - | if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active FROM proxy_route", proxyRouteWrapper); err != nil { | |
| 709 | - | return err | |
| 710 | - | } | |
| 711 | - | ||
| 712 | - | return nil | |
| 713 | - | } | |
D internal/templating/cmdparams/from_cli.go +0 -76
| @@ -1,76 +0,0 @@ | |||
| 1 | - | package cmdparams | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "net/url" | |
| 6 | - | "strings" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/gordon/internal/common" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/bnema/gordon/pkg/docker" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct | |
| 14 | - | func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imageID string) (docker.ContainerCommandParams, error) { | |
| 15 | - | ||
| 16 | - | // volume empty for now | |
| 17 | - | volumeSlice := ParseVolumeSlice("") | |
| 18 | - | ||
| 19 | - | environmentSlice := ParseEnvironmentSlice("") | |
| 20 | - | ||
| 21 | - | // from ppl.TargetDomain, (eg. "https://test.example.com") get container_name (eg. "test"), get container_subdomain (eg. "test"), get container_domain (eg. "example.com") and get container_protocol (eg. "https") | |
| 22 | - | // Parse TargetDomain to get the various components | |
| 23 | - | parsedURL, err := url.Parse(ppl.TargetDomain) | |
| 24 | - | if err != nil { | |
| 25 | - | return docker.ContainerCommandParams{}, err | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // Extract the subdomain and domain from the host | |
| 29 | - | hostParts := strings.Split(parsedURL.Host, ".") | |
| 30 | - | if len(hostParts) < 2 { | |
| 31 | - | return docker.ContainerCommandParams{}, fmt.Errorf("invalid domain: %s", parsedURL.Host) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | // Protocol (http/https), Container name, Container subdomain, and Container domain | |
| 35 | - | protocol := parsedURL.Scheme | |
| 36 | - | ||
| 37 | - | var containerSubdomain string | |
| 38 | - | var containerDomain string | |
| 39 | - | ||
| 40 | - | if len(hostParts) == 2 { | |
| 41 | - | // No subdomain (e.g., domain.tld) | |
| 42 | - | containerSubdomain = "" | |
| 43 | - | containerDomain = parsedURL.Host | |
| 44 | - | } else { | |
| 45 | - | // Has subdomain (e.g., sub.domain.tld) | |
| 46 | - | containerSubdomain = hostParts[0] | |
| 47 | - | containerDomain = strings.Join(hostParts[1:], ".") | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Use subdomain as container name, or domain with hyphens if no subdomain | |
| 51 | - | containerName := containerSubdomain | |
| 52 | - | if containerName == "" { | |
| 53 | - | containerName = strings.ReplaceAll(containerDomain, ".", "-") | |
| 54 | - | } | |
| 55 | - | ||
| 56 | - | params := docker.ContainerCommandParams{ | |
| 57 | - | IsSSL: protocol == "https", | |
| 58 | - | ContainerName: containerName, | |
| 59 | - | ServiceName: containerName, | |
| 60 | - | Domain: containerDomain, | |
| 61 | - | ImageName: ppl.ImageName, | |
| 62 | - | ImageID: imageID, | |
| 63 | - | Restart: "always", | |
| 64 | - | Volumes: volumeSlice, | |
| 65 | - | Environment: environmentSlice, | |
| 66 | - | Network: a.Config.ContainerEngine.Network, | |
| 67 | - | ProxyPort: ppl.Port, | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | err = SetupProxyRoute(¶ms, params.ProxyPort, a) | |
| 71 | - | if err != nil { | |
| 72 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | return params, nil | |
| 76 | - | } | |
D internal/templating/cmdparams/from_inputs.go +0 -66
| @@ -1,66 +0,0 @@ | |||
| 1 | - | package cmdparams | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | "github.com/bnema/gordon/pkg/docker" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // FromInputsToCmdParams transforms the inputs map into a ContainerCommandParams struct | |
| 11 | - | func FromInputsToCmdParams(inputs map[string]string, a *server.App) (docker.ContainerCommandParams, error) { | |
| 12 | - | volumeSlice := ParseVolumeSlice(inputs["volumes"]) | |
| 13 | - | environmentSlice := ParseEnvironmentSlice(inputs["environment_variables"]) | |
| 14 | - | ||
| 15 | - | var portMappings []docker.PortMapping | |
| 16 | - | var err error | |
| 17 | - | ||
| 18 | - | // Parse port mappings if provided | |
| 19 | - | if inputs["ports"] != "" { | |
| 20 | - | portMappings, err = ParsePortMappingsSlice(inputs["ports"]) | |
| 21 | - | if err != nil { | |
| 22 | - | return docker.ContainerCommandParams{}, err | |
| 23 | - | } | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Retrieve proxy port from the correct input key | |
| 27 | - | proxyPortValue := inputs["container_port"] // Changed from proxy_port | |
| 28 | - | ||
| 29 | - | params := docker.ContainerCommandParams{ | |
| 30 | - | IsSSL: inputs["container_protocol"] == "https", | |
| 31 | - | ContainerName: inputs["container_name"], | |
| 32 | - | ServiceName: inputs["container_subdomain"], | |
| 33 | - | Domain: inputs["container_domain"], | |
| 34 | - | ImageName: inputs["image_name"], | |
| 35 | - | ImageID: inputs["image_id"], | |
| 36 | - | Restart: inputs["restart"], | |
| 37 | - | ProxyPort: proxyPortValue, // Assign the retrieved value | |
| 38 | - | Volumes: volumeSlice, | |
| 39 | - | Environment: environmentSlice, | |
| 40 | - | Network: a.Config.ContainerEngine.Network, | |
| 41 | - | PortMappings: portMappings, | |
| 42 | - | // Labels will be populated below | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | // Initialize Labels slice if nil | |
| 46 | - | if params.Labels == nil { | |
| 47 | - | params.Labels = []string{} | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Add standard labels and the proxy port label | |
| 51 | - | params.Labels = append(params.Labels, | |
| 52 | - | fmt.Sprintf("gordon.managed=true"), | |
| 53 | - | fmt.Sprintf("gordon.domain=%s.%s", params.ServiceName, params.Domain), | |
| 54 | - | fmt.Sprintf("gordon.proxy.ssl=%t", params.IsSSL), | |
| 55 | - | fmt.Sprintf("gordon.proxy.port=%s", proxyPortValue), // Add the proxy port label | |
| 56 | - | ) | |
| 57 | - | ||
| 58 | - | // SetupProxyRoute might need adjustment if it relies on params.ProxyPort directly | |
| 59 | - | // Assuming SetupProxyRoute uses the passed proxyPortValue or reads labels | |
| 60 | - | err = SetupProxyRoute(¶ms, proxyPortValue, a) // Pass the correct port value | |
| 61 | - | if err != nil { | |
| 62 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) | |
| 63 | - | } | |
| 64 | - | ||
| 65 | - | return params, nil | |
| 66 | - | } | |
D internal/templating/cmdparams/from_yaml.go +0 -58
| @@ -1,58 +0,0 @@ | |||
| 1 | - | package cmdparams | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strings" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/gordon/pkg/docker" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | type YAMLContainerParams struct { | |
| 11 | - | Name string `yaml:"Name"` | |
| 12 | - | Image string `yaml:"Image"` | |
| 13 | - | Hostname string `yaml:"Hostname"` | |
| 14 | - | Ports []string `yaml:"Ports"` | |
| 15 | - | Volumes []string `yaml:"Volumes"` | |
| 16 | - | Environment []string `yaml:"Environment"` | |
| 17 | - | Labels map[string]string `yaml:"Labels"` | |
| 18 | - | Network []string `yaml:"Network"` | |
| 19 | - | Restart string `yaml:"Restart"` | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // FromYAMLStructToCmdParams converts a YAMLContainerParams struct to a ContainerCommandParams struct | |
| 23 | - | func FromYAMLStructToCmdParams(yamlParams YAMLContainerParams) (docker.ContainerCommandParams, error) { | |
| 24 | - | // with the image name find the image id | |
| 25 | - | imageID, err := docker.GetImageIDByName(yamlParams.Image) | |
| 26 | - | if err != nil { | |
| 27 | - | return docker.ContainerCommandParams{}, err | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // Convert the map of labels to a slice of strings | |
| 31 | - | var labels []string | |
| 32 | - | for k, v := range yamlParams.Labels { | |
| 33 | - | labels = append(labels, fmt.Sprintf("%s=%s", k, v)) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // Ports input parsing | |
| 37 | - | portMappings, err := docker.ParsePortsSpecs(yamlParams.Ports) | |
| 38 | - | if err != nil { | |
| 39 | - | return docker.ContainerCommandParams{}, err | |
| 40 | - | } | |
| 41 | - | ||
| 42 | - | // Convert the slice of strings to a comma separated string | |
| 43 | - | network := strings.Join(yamlParams.Network, ",") | |
| 44 | - | ||
| 45 | - | params := docker.ContainerCommandParams{ | |
| 46 | - | ContainerName: yamlParams.Name, | |
| 47 | - | ImageName: yamlParams.Image, | |
| 48 | - | ImageID: imageID, | |
| 49 | - | Volumes: yamlParams.Volumes, | |
| 50 | - | Environment: yamlParams.Environment, | |
| 51 | - | Labels: labels, | |
| 52 | - | Network: network, | |
| 53 | - | Restart: yamlParams.Restart, | |
| 54 | - | PortMappings: portMappings, | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | return params, nil | |
| 58 | - | } | |
D internal/templating/cmdparams/parsing.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | package cmdparams | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/pkg/docker" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // ParseVolumeSlice parses the volumes string into a slice. | |
| 10 | - | func ParseVolumeSlice(volumeStr string) []string { | |
| 11 | - | volumeSlice := strings.Split(volumeStr, ",") | |
| 12 | - | if len(volumeSlice) == 1 && volumeSlice[0] == "" { | |
| 13 | - | return []string{} | |
| 14 | - | } | |
| 15 | - | return volumeSlice | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // ParseEnvironmentSlice parses the environment variables string into a slice. | |
| 19 | - | func ParseEnvironmentSlice(environmentStr string) []string { | |
| 20 | - | environmentSlice := strings.Split(environmentStr, "\n") | |
| 21 | - | if len(environmentSlice) == 1 && environmentSlice[0] == "" { | |
| 22 | - | return []string{} | |
| 23 | - | } | |
| 24 | - | return environmentSlice | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | // ParsePortMappingsSlice parses the port mappings string into a slice and a struct. | |
| 28 | - | func ParsePortMappingsSlice(portMappingsStr string) ([]docker.PortMapping, error) { | |
| 29 | - | if portMappingsStr == "" { | |
| 30 | - | return []docker.PortMapping{}, nil | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | portMappingsSliceRaw := strings.Split(portMappingsStr, ",") | |
| 34 | - | portMappingsSlice := make([]string, len(portMappingsSliceRaw)) | |
| 35 | - | for i, spec := range portMappingsSliceRaw { | |
| 36 | - | portMappingsSlice[i] = strings.TrimSpace(spec) | |
| 37 | - | } | |
| 38 | - | return docker.ParsePortsSpecs(portMappingsSlice) | |
| 39 | - | } | |
D internal/templating/cmdparams/proxy.go +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | package cmdparams | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/interfaces" | |
| 5 | - | "github.com/bnema/gordon/pkg/docker" | |
| 6 | - | "github.com/charmbracelet/log" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // SetupProxyRoute configures the container for our reverse proxy | |
| 10 | - | func SetupProxyRoute(params *docker.ContainerCommandParams, containerPort string, a interfaces.AppInterface) error { | |
| 11 | - | // Set the container port to be used by the reverse proxy | |
| 12 | - | params.ProxyPort = containerPort | |
| 13 | - | ||
| 14 | - | // Create the full domain (service.domain) | |
| 15 | - | fullDomain := params.ServiceName + "." + params.Domain | |
| 16 | - | ||
| 17 | - | // Add custom labels to identify this container as manageable by Gordon | |
| 18 | - | params.Labels = []string{ | |
| 19 | - | "gordon.managed=true", | |
| 20 | - | "gordon.domain=" + fullDomain, | |
| 21 | - | "gordon.proxy.port=" + containerPort, | |
| 22 | - | "gordon.proxy.ssl=" + boolToString(params.IsSSL), | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | log.Debug("Container proxy configuration set up", | |
| 26 | - | "domain", fullDomain, | |
| 27 | - | "proxyPort", containerPort, | |
| 28 | - | "ssl", params.IsSSL) | |
| 29 | - | ||
| 30 | - | return nil | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // boolToString converts a boolean to a string "true" or "false" | |
| 34 | - | func boolToString(b bool) string { | |
| 35 | - | if b { | |
| 36 | - | return "true" | |
| 37 | - | } | |
| 38 | - | return "false" | |
| 39 | - | } | |
D internal/templating/embed.go +0 -13
| @@ -1,13 +0,0 @@ | |||
| 1 | - | package templating | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "embed" | |
| 5 | - | ) | |
| 6 | - | ||
| 7 | - | // Embedding localization file | |
| 8 | - | ||
| 9 | - | //go:embed models/txt/locstrings.yml | |
| 10 | - | var locFS embed.FS | |
| 11 | - | ||
| 12 | - | // LocFS provides access to the embedded localization file. | |
| 13 | - | var LocFS = locFS | |
D internal/templating/models/templ/README.md +0 -48
| @@ -1,48 +0,0 @@ | |||
| 1 | - | # Templ Templates for Gordon | |
| 2 | - | ||
| 3 | - | This directory contains [templ](https://github.com/a-h/templ) templates for the Gordon application. We're migrating from Go's html/template to templ for improved type safety and maintainability. | |
| 4 | - | ||
| 5 | - | ## Directory Structure | |
| 6 | - | ||
| 7 | - | - `components/` - Reusable UI components | |
| 8 | - | - `layouts/` - Page layouts and base templates | |
| 9 | - | - `pages/` - Full page templates | |
| 10 | - | - `admin/` - Admin pages | |
| 11 | - | - `login/` - Login pages | |
| 12 | - | ||
| 13 | - | ## Working with Templates | |
| 14 | - | ||
| 15 | - | To create or modify templates: | |
| 16 | - | ||
| 17 | - | 1. Edit the `.templ` files in the appropriate directory | |
| 18 | - | 2. Run `templ generate` or `make build-templ` to generate Go code | |
| 19 | - | 3. Use the templates in your handlers with the `TemplRenderer` | |
| 20 | - | ||
| 21 | - | ## Converting from gohtml | |
| 22 | - | ||
| 23 | - | When converting from gohtml: | |
| 24 | - | ||
| 25 | - | 1. Create a new `.templ` file | |
| 26 | - | 2. Copy HTML structure from gohtml | |
| 27 | - | 3. Replace `{{ .varName }}` with `{ varName }` | |
| 28 | - | 4. Use templ's control flow syntax like `if`, `for`, etc. | |
| 29 | - | 5. Use `@` to invoke other components | |
| 30 | - | ||
| 31 | - | ## Example: | |
| 32 | - | ||
| 33 | - | ```templ | |
| 34 | - | package components | |
| 35 | - | ||
| 36 | - | templ Button(label string, classes string) { | |
| 37 | - | <button class={classes}>{ label }</button> | |
| 38 | - | } | |
| 39 | - | ``` | |
| 40 | - | ||
| 41 | - | ## Usage in Handlers | |
| 42 | - | ||
| 43 | - | ```go | |
| 44 | - | func RenderMyPage(c echo.Context, a *server.App) error { | |
| 45 | - | renderer := render.NewTemplRenderer(a) | |
| 46 | - | return renderer.RenderTempl(c, mypage.PageTemplate("Page Title", someData)) | |
| 47 | - | } | |
| 48 | - | ```⏎̸ | |
D internal/templating/models/templ/components/containerlist.templ +0 -132
| @@ -1,132 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/docker/docker/api/types/container" | |
| 5 | - | "strconv" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // ContainerList displays a list of containers | |
| 9 | - | templ ContainerList(containers []container.Summary, noContainer bool) { | |
| 10 | - | <!-- Container list using daisyUI components --> | |
| 11 | - | <div class="card w-full bg-base-100"> | |
| 12 | - | <div class="card-body p-4"> | |
| 13 | - | ||
| 14 | - | <!-- Action buttons --> | |
| 15 | - | <div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4"> | |
| 16 | - | <h3 class="font-semibold text-base-content">Available Containers: { strconv.Itoa(len(containers)) }</h3> | |
| 17 | - | <div class="btn-group"> | |
| 18 | - | <button | |
| 19 | - | class="btn btn-sm btn-primary" | |
| 20 | - | hx-get="/htmx/container-manager" | |
| 21 | - | hx-target="#container-manager" | |
| 22 | - | hx-swap="innerHTML"> | |
| 23 | - | <span class="iconf"></span> Refresh | |
| 24 | - | </button> | |
| 25 | - | </div> | |
| 26 | - | </div> | |
| 27 | - | ||
| 28 | - | <!-- Container list --> | |
| 29 | - | <table class="table"> | |
| 30 | - | <!-- head --> | |
| 31 | - | <thead> | |
| 32 | - | <tr> | |
| 33 | - | <th>Name</th> | |
| 34 | - | <th>Image</th> | |
| 35 | - | <th>Status</th> | |
| 36 | - | <th>Created</th> | |
| 37 | - | <th>Actions</th> | |
| 38 | - | </tr> | |
| 39 | - | </thead> | |
| 40 | - | <tbody> | |
| 41 | - | if noContainer || len(containers) == 0 { | |
| 42 | - | <tr> | |
| 43 | - | <td colspan="5" class="text-center py-4"> | |
| 44 | - | <div class="alert alert-info"> | |
| 45 | - | <span class="iconf">ℹ️</span> | |
| 46 | - | <span>No containers found. Upload an image first, then create a container.</span> | |
| 47 | - | </div> | |
| 48 | - | </td> | |
| 49 | - | </tr> | |
| 50 | - | } else { | |
| 51 | - | for _, container := range containers { | |
| 52 | - | <tr class="hover"> | |
| 53 | - | <td> | |
| 54 | - | <div class="flex items-center gap-3"> | |
| 55 | - | <div> | |
| 56 | - | <div class="font-bold">{ container.Names[0] }</div> | |
| 57 | - | <div class="text-sm opacity-50">{ container.ID }</div> | |
| 58 | - | </div> | |
| 59 | - | </div> | |
| 60 | - | </td> | |
| 61 | - | <td>{ container.Image }</td> | |
| 62 | - | <td> | |
| 63 | - | if container.State == "running" { | |
| 64 | - | <div class="badge badge-success badge-sm">Running</div> | |
| 65 | - | } else if container.State == "exited" { | |
| 66 | - | <div class="badge badge-warning badge-sm">Stopped</div> | |
| 67 | - | } else { | |
| 68 | - | <div class="badge badge-ghost badge-sm">{ container.State }</div> | |
| 69 | - | } | |
| 70 | - | </td> | |
| 71 | - | <td>{ strconv.FormatInt(container.Created, 10) }</td> | |
| 72 | - | <th> | |
| 73 | - | <div class="dropdown dropdown-end"> | |
| 74 | - | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |
| 75 | - | <span class="iconf"></span> | |
| 76 | - | </div> | |
| 77 | - | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-40"> | |
| 78 | - | if container.State == "running" { | |
| 79 | - | <li> | |
| 80 | - | <button | |
| 81 | - | class="text-warning flex items-center" | |
| 82 | - | hx-post={ "/htmx/container-manager/stop/" + container.ID } | |
| 83 | - | hx-target="#container-manager" | |
| 84 | - | hx-swap="innerHTML"> | |
| 85 | - | <span class="iconf mr-2"></span> Stop | |
| 86 | - | </button> | |
| 87 | - | </li> | |
| 88 | - | } else if container.State == "exited" { | |
| 89 | - | <li> | |
| 90 | - | <button | |
| 91 | - | class="text-success flex items-center" | |
| 92 | - | hx-post={ "/htmx/container-manager/start/" + container.ID } | |
| 93 | - | hx-target="#container-manager" | |
| 94 | - | hx-swap="innerHTML"> | |
| 95 | - | <span class="iconf mr-2"></span> Start | |
| 96 | - | </button> | |
| 97 | - | </li> | |
| 98 | - | } | |
| 99 | - | <li> | |
| 100 | - | <button | |
| 101 | - | class="text-info flex items-center" | |
| 102 | - | hx-get={ "/htmx/container-manager/edit/" + container.ID } | |
| 103 | - | hx-target="#container-manager" | |
| 104 | - | hx-swap="innerHTML"> | |
| 105 | - | <span class="iconf mr-2"></span> Edit | |
| 106 | - | </button> | |
| 107 | - | </li> | |
| 108 | - | <li> | |
| 109 | - | <button | |
| 110 | - | class="text-error flex items-center" | |
| 111 | - | hx-delete={ "/htmx/container-manager/delete/" + container.ID } | |
| 112 | - | hx-target="#container-manager" | |
| 113 | - | hx-swap="innerHTML" | |
| 114 | - | hx-confirm="Are you sure you want to delete this container?"> | |
| 115 | - | <span class="iconf mr-2"></span> Delete | |
| 116 | - | </button> | |
| 117 | - | </li> | |
| 118 | - | </ul> | |
| 119 | - | </div> | |
| 120 | - | </th> | |
| 121 | - | </tr> | |
| 122 | - | } | |
| 123 | - | } | |
| 124 | - | </tbody> | |
| 125 | - | </table> | |
| 126 | - | </div> | |
| 127 | - | </div> | |
| 128 | - | ||
| 129 | - | <script> | |
| 130 | - | console.debug('Container list rendered'); | |
| 131 | - | </script> | |
| 132 | - | }⏎̸ | |
D internal/templating/models/templ/components/containerlist_templ.go +0 -228 generated file: collapsed
D internal/templating/models/templ/components/createcontainer.templ +0 -163
| @@ -1,163 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | import "fmt" | |
| 4 | - | ||
| 5 | - | // ContainerFormData holds the data needed for the container creation form | |
| 6 | - | type ContainerFormData struct { | |
| 7 | - | ImageName string | |
| 8 | - | ImageID string | |
| 9 | - | ShortID string | |
| 10 | - | ContainerName string | |
| 11 | - | ContainerSubdomain string | |
| 12 | - | ContainerDomain string | |
| 13 | - | Ports string | |
| 14 | - | ProxyPort string | |
| 15 | - | Volumes string | |
| 16 | - | EnvironmentVariables string | |
| 17 | - | ErrorMessage string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // CreateContainerForm displays a form for creating a new container | |
| 21 | - | templ CreateContainerForm(data ContainerFormData) { | |
| 22 | - | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | |
| 23 | - | <!-- Left Side: Docker Run Options --> | |
| 24 | - | <div class="flex-grow card p-6 bg-base-100"> | |
| 25 | - | <h2 class="card-title pb-4">Deploy a container based on { data.ImageName }</h2> | |
| 26 | - | if data.ErrorMessage != "" { | |
| 27 | - | <div class="alert alert-error"> | |
| 28 | - | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg> | |
| 29 | - | <span>{ data.ErrorMessage }</span> | |
| 30 | - | </div> | |
| 31 | - | } | |
| 32 | - | <form id={ "create-container-form-" + data.ShortID } | |
| 33 | - | action={ templ.URL(fmt.Sprintf("/create-container/%s", data.ShortID)) } | |
| 34 | - | method="post" | |
| 35 | - | hx-post={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", data.ShortID))) } | |
| 36 | - | hx-swap="innerHTML" | |
| 37 | - | hx-target={ "#create-form-target-" + data.ShortID }> | |
| 38 | - | <!-- Image Name --> | |
| 39 | - | <input type="hidden" name="image_id" value={ data.ImageID } /> | |
| 40 | - | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 pb-4"> | |
| 41 | - | <div class="md:col-span-1"> | |
| 42 | - | <label class="label"> | |
| 43 | - | <span class="label-text">Image name</span> | |
| 44 | - | </label> | |
| 45 | - | <input type="text" name="image_name" class="input input-bordered input-disabled w-full" | |
| 46 | - | value={ data.ImageName } readonly /> | |
| 47 | - | </div> | |
| 48 | - | ||
| 49 | - | <div class="md:col-span-2"> | |
| 50 | - | <label class="label"> | |
| 51 | - | <span class="label-text">Image ID</span> | |
| 52 | - | </label> | |
| 53 | - | <input type="text" name="image_id" class="input input-bordered input-disabled w-full" | |
| 54 | - | value={ data.ImageID } readonly /> | |
| 55 | - | </div> | |
| 56 | - | </div> | |
| 57 | - | ||
| 58 | - | <!-- Container Name --> | |
| 59 | - | <div class="form-control pb-4"> | |
| 60 | - | <label class="label"> | |
| 61 | - | <span class="label-text">Container name</span> | |
| 62 | - | </label> | |
| 63 | - | <input type="text" name="container_name" class="input input-bordered w-full" value={ data.ContainerName } /> | |
| 64 | - | </div> | |
| 65 | - | ||
| 66 | - | <!-- Http(s) / Subdomain / Domain --> | |
| 67 | - | <div class="grid grid-cols-1 md:grid-cols-12 gap-4 pb-4"> | |
| 68 | - | <!-- Protocol --> | |
| 69 | - | <div class="md:col-span-3"> | |
| 70 | - | <label class="label"> | |
| 71 | - | <span class="label-text">Protocol</span> | |
| 72 | - | </label> | |
| 73 | - | <select name="container_protocol" class="select select-bordered w-full"> | |
| 74 | - | <option value="https">https</option> | |
| 75 | - | <option value="http">http</option> | |
| 76 | - | </select> | |
| 77 | - | </div> | |
| 78 | - | ||
| 79 | - | <!-- Subdomain --> | |
| 80 | - | <div class="md:col-span-4"> | |
| 81 | - | <label class="label"> | |
| 82 | - | <span class="label-text">Subdomain</span> | |
| 83 | - | </label> | |
| 84 | - | <input type="text" name="container_subdomain" class="input input-bordered w-full" value={ data.ContainerSubdomain } /> | |
| 85 | - | </div> | |
| 86 | - | ||
| 87 | - | <!-- Domain --> | |
| 88 | - | <div class="md:col-span-5"> | |
| 89 | - | <label class="label"> | |
| 90 | - | <span class="label-text">Domain</span> | |
| 91 | - | </label> | |
| 92 | - | <input type="text" name="container_domain" class="input input-bordered w-full" value={ data.ContainerDomain } /> | |
| 93 | - | </div> | |
| 94 | - | </div> | |
| 95 | - | ||
| 96 | - | <!-- Exposed Ports --> | |
| 97 | - | <div class="form-control pb-4"> | |
| 98 | - | <label class="label"> | |
| 99 | - | <span class="label-text">Exposed Ports (Optional)</span> | |
| 100 | - | <span class="label-text-alt">eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)</span> | |
| 101 | - | </label> | |
| 102 | - | <input type="text" name="ports" class="input input-bordered w-full" value={ data.Ports } /> | |
| 103 | - | </div> | |
| 104 | - | ||
| 105 | - | <!-- Proxy Port --> | |
| 106 | - | <div class="form-control pb-4"> | |
| 107 | - | <label class="label"> | |
| 108 | - | <span class="label-text">Container Proxy Port (Required)</span> | |
| 109 | - | <span class="label-text-alt">Specify the internal container port that the proxy will route traffic to</span> | |
| 110 | - | </label> | |
| 111 | - | <input type="text" name="container_port" class="input input-bordered w-full" value={ data.ProxyPort } required /> | |
| 112 | - | </div> | |
| 113 | - | ||
| 114 | - | <!-- Volumes --> | |
| 115 | - | <div class="form-control pb-4"> | |
| 116 | - | <label class="label"> | |
| 117 | - | <span class="label-text">Volumes</span> | |
| 118 | - | <span class="label-text-alt">e.g. ./data:/container/data</span> | |
| 119 | - | </label> | |
| 120 | - | <input type="text" name="volumes" class="input input-bordered w-full" value={ data.Volumes } /> | |
| 121 | - | </div> | |
| 122 | - | ||
| 123 | - | <div class="form-control pb-4"> | |
| 124 | - | <label class="label"> | |
| 125 | - | <span class="label-text">Environment variables</span> | |
| 126 | - | <span class="label-text-alt">e.g. KEY=VALUE</span> | |
| 127 | - | </label> | |
| 128 | - | <textarea name="environment_variables" class="textarea textarea-bordered w-full" | |
| 129 | - | rows="4">{ data.EnvironmentVariables }</textarea> | |
| 130 | - | </div> | |
| 131 | - | ||
| 132 | - | <!-- Restart: always/on-failure/only when stopped/never --> | |
| 133 | - | <div class="form-control pb-4"> | |
| 134 | - | <label class="label"> | |
| 135 | - | <span class="label-text">Restart</span> | |
| 136 | - | </label> | |
| 137 | - | <select name="restart" class="select select-bordered w-full"> | |
| 138 | - | <option value="always">Always</option> | |
| 139 | - | <option value="on-failure">On failure</option> | |
| 140 | - | <option value="unless-stopped">Unless stopped</option> | |
| 141 | - | <option value="never">Never</option> | |
| 142 | - | </select> | |
| 143 | - | </div> | |
| 144 | - | ||
| 145 | - | <!-- Skip Proxy Setup Option --> | |
| 146 | - | <div class="form-control pb-4"> | |
| 147 | - | <label class="label cursor-pointer justify-start gap-4"> | |
| 148 | - | <input type="checkbox" name="skip_proxy_setup" value="true" class="checkbox checkbox-primary" /> | |
| 149 | - | <span>Skip proxy setup (create container only, don't set up domain/certificates)</span> | |
| 150 | - | </label> | |
| 151 | - | <div class="text-sm opacity-70 ml-10"> | |
| 152 | - | Use this option if you only want to create the container without triggering proxy setup, | |
| 153 | - | certificate generation, and other related processes. | |
| 154 | - | </div> | |
| 155 | - | </div> | |
| 156 | - | ||
| 157 | - | <button type="submit" id="create-container-submit" class="btn btn-primary"> | |
| 158 | - | Create Container | |
| 159 | - | </button> | |
| 160 | - | </form> | |
| 161 | - | </div> | |
| 162 | - | </div> | |
| 163 | - | } ⏎̸ | |
D internal/templating/models/templ/components/createcontainer_templ.go +0 -272 generated file: collapsed
D internal/templating/models/templ/components/editcontainer.templ +0 -39
| @@ -1,39 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | import t "github.com/a-h/templ" | |
| 4 | - | ||
| 5 | - | // EditContainer replaces editcontainer.gohtml | |
| 6 | - | // It renders a form to edit a container's configuration | |
| 7 | - | templ EditContainer(id string, containerInfo string) { | |
| 8 | - | <div | |
| 9 | - | id={ "edit-container-" + id } | |
| 10 | - | class="card bg-base-100 shadow-xl" | |
| 11 | - | hx-ext="response-targets" | |
| 12 | - | > | |
| 13 | - | <div id="editor" class="card-body"> | |
| 14 | - | <form | |
| 15 | - | method="post" | |
| 16 | - | action={ t.URL("/htmx/container-manager/edit/" + id) } | |
| 17 | - | hx-post={ string(t.URL("/htmx/container-manager/edit/" + id)) } | |
| 18 | - | hx-swap="innerHTML" | |
| 19 | - | hx-target-*={ "#error-message-edit-container-" + id } | |
| 20 | - | > | |
| 21 | - | <textarea | |
| 22 | - | name="container_config" | |
| 23 | - | id="container_config" | |
| 24 | - | class="textarea textarea-bordered w-full h-96 font-mono JetBrainsMonoNF" | |
| 25 | - | rows="20" | |
| 26 | - | >{ containerInfo }</textarea> | |
| 27 | - | <div class="card-actions justify-end mt-4"> | |
| 28 | - | <input | |
| 29 | - | type="submit" | |
| 30 | - | value="Save" | |
| 31 | - | id={ "save-button-" + id } | |
| 32 | - | class="btn btn-primary" | |
| 33 | - | /> | |
| 34 | - | </div> | |
| 35 | - | </form> | |
| 36 | - | <div id={ "error-message-edit-container-" + id } class="text-error mt-2"></div> | |
| 37 | - | </div> | |
| 38 | - | </div> | |
| 39 | - | } ⏎̸ | |
D internal/templating/models/templ/components/editcontainer_templ.go +0 -131 generated file: collapsed
D internal/templating/models/templ/components/footer.templ +0 -17
| @@ -1,17 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // Footer is a templ component for the page footer | |
| 4 | - | // It replaces footer.gohtml | |
| 5 | - | templ Footer() { | |
| 6 | - | <footer class="footer items-center p-4 bg-base-300 text-base-content"> | |
| 7 | - | <div class="items-center grid-flow-col"> | |
| 8 | - | <span class="iconfb text-xl"></span> | |
| 9 | - | <p class="IBMPlexSans text-sm">Gordon Container Manager © 2024</p> | |
| 10 | - | </div> | |
| 11 | - | <div class="grid-flow-col gap-4 md:place-self-center md:justify-self-end"> | |
| 12 | - | <a class="link link-hover text-sm" href="https://github.com/bnema/gordon" target="_blank">GitHub</a> | |
| 13 | - | // Add other essential links here if needed, e.g., Documentation | |
| 14 | - | // <a class="link link-hover text-sm">Documentation</a> | |
| 15 | - | </div> | |
| 16 | - | </footer> | |
| 17 | - | }⏎̸ | |
D internal/templating/models/templ/components/footer_templ.go +0 -42 generated file: collapsed
D internal/templating/models/templ/components/header.templ +0 -31
| @@ -1,31 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // Header is a templ component for the page header | |
| 4 | - | // It replaces header.gohtml | |
| 5 | - | templ Header(title string) { | |
| 6 | - | <head> | |
| 7 | - | <meta charset="UTF-8" /> | |
| 8 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 9 | - | <title>{ title }</title> | |
| 10 | - | <link rel="stylesheet" href="/assets/css/tailwind.css" /> | |
| 11 | - | ||
| 12 | - | <script src="/libs/jquery.min.js"></script> | |
| 13 | - | <script src="/libs/htmx.min.js"></script> | |
| 14 | - | <script src="/libs/htmx-response-targets.js"></script> | |
| 15 | - | <script src="/libs/htmx-debug.js"></script> | |
| 16 | - | <script src="/libs/htmx-sse.js"></script> | |
| 17 | - | <script src="/libs/daisyui.js" type="module"></script> | |
| 18 | - | <script defer src="/libs/hyperscript.min.js"></script> | |
| 19 | - | <script defer src="/assets/js/custom.js"></script> | |
| 20 | - | ||
| 21 | - | <!-- daisyUI theme configuration --> | |
| 22 | - | <script> | |
| 23 | - | // Check for saved theme preference or use preferred color scheme | |
| 24 | - | const savedTheme = localStorage.getItem('theme') || 'gordon-dark'; | |
| 25 | - | document.documentElement.setAttribute('data-theme', savedTheme); | |
| 26 | - | ||
| 27 | - | // Log theme loading for debugging | |
| 28 | - | console.debug('Theme loaded:', savedTheme); | |
| 29 | - | </script> | |
| 30 | - | </head> | |
| 31 | - | }⏎̸ | |
D internal/templating/models/templ/components/header_templ.go +0 -55 generated file: collapsed
D internal/templating/models/templ/components/htmx_components.templ +0 -80
| @@ -1,80 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // HTMXHeader creates the hero header with HTMX attributes | |
| 4 | - | templ HTMXHeader(title string, subtitle string) { | |
| 5 | - | <div id="header" class="hero bg-base-200"> | |
| 6 | - | <div class="hero-content text-center"> | |
| 7 | - | <div class="max-w-md"> | |
| 8 | - | <h1 class="text-5xl font-bold gordon-title"> | |
| 9 | - | { title } | |
| 10 | - | </h1> | |
| 11 | - | <p class="py-6 IBMPlexSans">{ subtitle }</p> | |
| 12 | - | <button class="btn btn-primary">Get Started</button> | |
| 13 | - | </div> | |
| 14 | - | </div> | |
| 15 | - | </div> | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // HTMXAlert creates an alert box that can be loaded with HTMX | |
| 19 | - | templ HTMXAlert(message string) { | |
| 20 | - | <div id="hello" class="alert alert-info shadow-lg mt-4 mb-4"> | |
| 21 | - | <div> | |
| 22 | - | <span class="iconfb"></span> | |
| 23 | - | <span class="IBMPlexSans">{ message }</span> | |
| 24 | - | </div> | |
| 25 | - | </div> | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // HTMXHeaderContainer creates a container to load the HTMX header | |
| 29 | - | templ HTMXHeaderContainer() { | |
| 30 | - | <div | |
| 31 | - | id="header" | |
| 32 | - | hx-get="/htmx" | |
| 33 | - | hx-headers='{"HX-Fragment": "header"}' | |
| 34 | - | hx-trigger="load" | |
| 35 | - | hx-swap="outerHTML" | |
| 36 | - | ></div> | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // HTMXAppContainer creates a container to load the HTMX app | |
| 40 | - | templ HTMXAppContainer() { | |
| 41 | - | <div | |
| 42 | - | id="app" | |
| 43 | - | hx-get="/htmx" | |
| 44 | - | hx-headers='{"HX-Fragment": "hello"}' | |
| 45 | - | hx-trigger="load" | |
| 46 | - | hx-swap="outerHTML" | |
| 47 | - | ></div> | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // StatsCard creates individual stat cards for the dashboard | |
| 51 | - | templ StatsCard(title string, value string, description string, icon string) { | |
| 52 | - | <div class="stat bg-base-100 shadow"> | |
| 53 | - | <div class="stat-figure text-primary"> | |
| 54 | - | <span class="iconfb text-3xl">{ icon }</span> | |
| 55 | - | </div> | |
| 56 | - | <div class="stat-title">{ title }</div> | |
| 57 | - | <div class="stat-value">{ value }</div> | |
| 58 | - | <div class="stat-desc">{ description }</div> | |
| 59 | - | </div> | |
| 60 | - | } | |
| 61 | - | ||
| 62 | - | // StatsCards creates a set of stats cards | |
| 63 | - | templ StatsCards() { | |
| 64 | - | <div class="grid grid-cols-1 md:grid-cols-3 gap-4 my-4"> | |
| 65 | - | @StatsCard("Containers", "31", "Running containers", "") | |
| 66 | - | @StatsCard("Images", "42", "Available images", "") | |
| 67 | - | @StatsCard("Networks", "12", "Container networks", "") | |
| 68 | - | </div> | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // TabsNav creates a navigation tabs component | |
| 72 | - | templ TabsNav(activeTab string) { | |
| 73 | - | <div class="tabs tabs-boxed my-4"> | |
| 74 | - | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Overview")}>Overview</a> | |
| 75 | - | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Containers")}>Containers</a> | |
| 76 | - | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Images")}>Images</a> | |
| 77 | - | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Networks")}>Networks</a> | |
| 78 | - | <a class={templ.KV("tab", true), templ.KV("tab-active", activeTab == "Volumes")}>Volumes</a> | |
| 79 | - | </div> | |
| 80 | - | }⏎̸ | |
D internal/templating/models/templ/components/htmx_components_templ.go +0 -438 generated file: collapsed
D internal/templating/models/templ/components/imagelist.templ +0 -189
| @@ -1,189 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strconv" | |
| 5 | - | "fmt" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // HumanReadableImage represents an image with human-readable attributes | |
| 9 | - | type HumanReadableImage struct { | |
| 10 | - | ID string | |
| 11 | - | ShortID string | |
| 12 | - | Name string | |
| 13 | - | Repository string | |
| 14 | - | Tag string | |
| 15 | - | Size string | |
| 16 | - | Created string | |
| 17 | - | RepoDigests []string | |
| 18 | - | RepoTags []string | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // ImageList displays a list of images | |
| 22 | - | templ ImageList(images []HumanReadableImage) { | |
| 23 | - | <!-- Image list using daisyUI components --> | |
| 24 | - | <div class="card w-full bg-base-100"> | |
| 25 | - | <div class="card-body p-4"> | |
| 26 | - | ||
| 27 | - | <!-- Action buttons --> | |
| 28 | - | <div class="flex flex-col sm:flex-row sm:justify-start items-start sm:items-center gap-4 flex-wrap"> | |
| 29 | - | <h3 class="font-semibold text-base-content">Available Images: { strconv.Itoa(len(images)) }</h3> | |
| 30 | - | <div class="flex gap-2"> | |
| 31 | - | <button | |
| 32 | - | class="btn btn-sm btn-secondary" | |
| 33 | - | hx-get="/htmx/upload-image" | |
| 34 | - | hx-target="#image-manager" | |
| 35 | - | hx-swap="innerHTML"> | |
| 36 | - | <span class="iconf"></span> Upload | |
| 37 | - | </button> | |
| 38 | - | <button | |
| 39 | - | class="btn btn-sm btn-primary" | |
| 40 | - | hx-get="/htmx/image-manager" | |
| 41 | - | hx-target="#image-manager" | |
| 42 | - | hx-swap="innerHTML"> | |
| 43 | - | <span class="iconf"></span> Refresh | |
| 44 | - | </button> | |
| 45 | - | <button | |
| 46 | - | id="purge-button" | |
| 47 | - | class="btn btn-sm btn-warning" | |
| 48 | - | hx-post="/htmx/image-manager/prune" | |
| 49 | - | hx-target="#image-manager" | |
| 50 | - | hx-swap="innerHTML" | |
| 51 | - | hx-indicator="#purge-spinner" | |
| 52 | - | hx-confirm="Are you sure you want to delete all unused (dangling and unreferenced) images? This cannot be undone."> | |
| 53 | - | <span class="btn-content flex items-center"> | |
| 54 | - | <span class="iconf mr-1"></span> Purge Unused | |
| 55 | - | </span> | |
| 56 | - | <span class="loading loading-spinner loading-sm hidden" id="purge-spinner"></span> | |
| 57 | - | </button> | |
| 58 | - | </div> | |
| 59 | - | </div> | |
| 60 | - | ||
| 61 | - | <!-- Image list --> | |
| 62 | - | <table class="table"> | |
| 63 | - | <!-- head --> | |
| 64 | - | <thead> | |
| 65 | - | <tr> | |
| 66 | - | <th>Repository</th> | |
| 67 | - | <th>Tag</th> | |
| 68 | - | <th>Size</th> | |
| 69 | - | <th>Created</th> | |
| 70 | - | <th>Actions</th> | |
| 71 | - | </tr> | |
| 72 | - | </thead> | |
| 73 | - | <tbody> | |
| 74 | - | /* Check if there are no images */ | |
| 75 | - | if len(images) == 0 { | |
| 76 | - | <tr> | |
| 77 | - | <td colspan="5" class="text-center py-4"> | |
| 78 | - | <div class="alert alert-info"> | |
| 79 | - | <span class="iconf">ℹ️</span> | |
| 80 | - | <span>No images found. Upload an image first.</span> | |
| 81 | - | </div> | |
| 82 | - | </td> | |
| 83 | - | </tr> | |
| 84 | - | } else { | |
| 85 | - | /* List all available images */ | |
| 86 | - | for _, img := range images { | |
| 87 | - | <tr class="hover"> | |
| 88 | - | <td> | |
| 89 | - | <div class="flex items-center gap-3"> | |
| 90 | - | <div> | |
| 91 | - | <div class="font-bold">{ img.Repository }</div> | |
| 92 | - | <div class="text-sm opacity-50">{ img.ID }</div> | |
| 93 | - | </div> | |
| 94 | - | </div> | |
| 95 | - | </td> | |
| 96 | - | <td>{ img.Tag }</td> | |
| 97 | - | <td>{ img.Size }</td> | |
| 98 | - | <td>{ img.Created }</td> | |
| 99 | - | <th> | |
| 100 | - | <div class="dropdown dropdown-end"> | |
| 101 | - | <div tabindex="0" role="button" class="btn btn-ghost btn-xs"> | |
| 102 | - | <span class="iconf"></span> | |
| 103 | - | </div> | |
| 104 | - | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-200 rounded-box w-52"> | |
| 105 | - | <li> | |
| 106 | - | <a | |
| 107 | - | href="#" | |
| 108 | - | hx-get={ string(templ.URL(fmt.Sprintf("/htmx/create-container/%s", img.ShortID))) } | |
| 109 | - | hx-target={ "#create-form-target-" + img.ShortID } | |
| 110 | - | hx-swap="innerHTML" | |
| 111 | - | class="text-success flex items-center"> | |
| 112 | - | <span class="iconf mr-2"></span> Create Container | |
| 113 | - | </a> | |
| 114 | - | </li> | |
| 115 | - | <li> | |
| 116 | - | <button | |
| 117 | - | class="text-error flex items-center" | |
| 118 | - | hx-delete={ string(templ.URL(fmt.Sprintf("/htmx/image-manager/delete/%s", img.ShortID))) } | |
| 119 | - | hx-target="#image-manager" | |
| 120 | - | hx-swap="innerHTML" | |
| 121 | - | hx-confirm="Are you sure you want to delete this image?"> | |
| 122 | - | <span class="iconf mr-2"></span> Delete | |
| 123 | - | </button> | |
| 124 | - | </li> | |
| 125 | - | </ul> | |
| 126 | - | </div> | |
| 127 | - | </th> | |
| 128 | - | </tr> | |
| 129 | - | <tr> | |
| 130 | - | <td colspan="5" id={ "create-form-target-" + img.ShortID }> | |
| 131 | - | <!-- Form will be loaded here by HTMX --> | |
| 132 | - | </td> | |
| 133 | - | </tr> | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | </tbody> | |
| 137 | - | </table> | |
| 138 | - | </div> | |
| 139 | - | </div> | |
| 140 | - | ||
| 141 | - | <!-- Notification Area --> | |
| 142 | - | <div id="image-prune-notifications" class="fixed bottom-4 right-4 z-50 space-y-2"></div> | |
| 143 | - | ||
| 144 | - | <style> | |
| 145 | - | #purge-button.htmx-request .btn-content { | |
| 146 | - | display: none; | |
| 147 | - | } | |
| 148 | - | #purge-button.htmx-request #purge-spinner { | |
| 149 | - | display: inline-block; | |
| 150 | - | } | |
| 151 | - | </style> | |
| 152 | - | ||
| 153 | - | <script> | |
| 154 | - | console.debug('Image list rendered'); | |
| 155 | - | ||
| 156 | - | document.body.addEventListener('showMessage', function(evt) { | |
| 157 | - | console.debug('showMessage event received:', evt.detail); | |
| 158 | - | const detail = evt.detail; | |
| 159 | - | const notificationArea = document.getElementById('image-prune-notifications'); | |
| 160 | - | if (!notificationArea || !detail || !detail.message) return; | |
| 161 | - | ||
| 162 | - | const alertDiv = document.createElement('div'); | |
| 163 | - | let alertClass = 'alert-info'; // Default | |
| 164 | - | let iconClass = 'ℹ️'; // Default icon | |
| 165 | - | ||
| 166 | - | if (detail.level === 'success') { | |
| 167 | - | alertClass = 'alert-success'; | |
| 168 | - | iconClass = ''; // Checkmark icon | |
| 169 | - | } else if (detail.level === 'error') { | |
| 170 | - | alertClass = 'alert-error'; | |
| 171 | - | iconClass = ''; // Error icon | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | alertDiv.className = `alert ${alertClass} shadow-lg flex`; | |
| 175 | - | alertDiv.innerHTML = ` | |
| 176 | - | <span class="iconf text-xl">${iconClass}</span> | |
| 177 | - | <span>${detail.message}</span> | |
| 178 | - | <button class="btn btn-sm btn-ghost ml-auto" onclick="this.parentElement.remove()">✕</button> | |
| 179 | - | `; | |
| 180 | - | ||
| 181 | - | notificationArea.appendChild(alertDiv); | |
| 182 | - | ||
| 183 | - | // Automatically remove the alert after 5 seconds | |
| 184 | - | setTimeout(() => { | |
| 185 | - | alertDiv.remove(); | |
| 186 | - | }, 5000); | |
| 187 | - | }); | |
| 188 | - | </script> | |
| 189 | - | } ⏎̸ | |
D internal/templating/models/templ/components/imagelist_templ.go +0 -206 generated file: collapsed
D internal/templating/models/templ/components/navbar.templ +0 -72
| @@ -1,72 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // Navbar is a menu component for navigation | |
| 4 | - | templ Navbar(buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |
| 5 | - | <!-- DaisyUI Navbar --> | |
| 6 | - | <div class="navbar bg-base-100 shadow-lg fixed top-0 z-50"> | |
| 7 | - | <div class="navbar-start"> | |
| 8 | - | <div class="flex items-center"> | |
| 9 | - | <h1 class="gordon-title text-2xl md:text-3xl text-primary tracking-tighter px-4"> | |
| 10 | - | Gordon | |
| 11 | - | </h1> | |
| 12 | - | <span id="actual-version" class="text-xs md:text-sm pr-1 JetBrainsMonoNF text-base-content/70"> | |
| 13 | - | { buildVersion } | |
| 14 | - | </span> | |
| 15 | - | <span hidden id="update-available" class="text-success text-xs md:text-sm JetBrainsMonoNF hidden" title=""> | |
| 16 | - | <a href="https://github.com/bnema/gordon/releases" class="link"></a> | |
| 17 | - | </span> | |
| 18 | - | </div> | |
| 19 | - | </div> | |
| 20 | - | <div class="navbar-center hidden lg:flex"> | |
| 21 | - | <ul class="menu menu-horizontal px-1"> | |
| 22 | - | <li> | |
| 23 | - | <a href={ templ.URL(adminPath + "/manager") } class="font-semibold"> | |
| 24 | - | <span class="JetBrainsMonoBoldNF pr-1"></span> Manager | |
| 25 | - | </a> | |
| 26 | - | </li> | |
| 27 | - | </ul> | |
| 28 | - | </div> | |
| 29 | - | <div class="navbar-end"> | |
| 30 | - | <!-- Theme toggle --> | |
| 31 | - | <div class="dropdown dropdown-end"> | |
| 32 | - | <label tabindex="0" class="btn btn-ghost btn-circle"> | |
| 33 | - | <span class="iconf text-lg md:text-xl"></span> | |
| 34 | - | </label> | |
| 35 | - | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |
| 36 | - | <li><button onclick="setTheme('gordon')">Gordon Light</button></li> | |
| 37 | - | <li><button onclick="setTheme('gordon-dark')">Gordon Dark</button></li> | |
| 38 | - | <li><button onclick="setTheme('light')">Light</button></li> | |
| 39 | - | <li><button onclick="setTheme('dark')">Dark</button></li> | |
| 40 | - | </ul> | |
| 41 | - | </div> | |
| 42 | - | ||
| 43 | - | <!-- User menu --> | |
| 44 | - | <div class="dropdown dropdown-end"> | |
| 45 | - | <label tabindex="0" class="btn btn-ghost btn-circle"> | |
| 46 | - | <span class="iconf text-lg md:text-xl"></span> | |
| 47 | - | </label> | |
| 48 | - | <ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow bg-base-100 rounded-box w-52"> | |
| 49 | - | <li> | |
| 50 | - | <a href={ templ.URL(userSettingsURL) } title="User settings"> | |
| 51 | - | <span class="iconf"></span> Settings | |
| 52 | - | </a> | |
| 53 | - | </li> | |
| 54 | - | <li> | |
| 55 | - | <a href={ templ.URL(logoutURL) } title="Logout"> | |
| 56 | - | <span class="iconf"></span> Logout | |
| 57 | - | </a> | |
| 58 | - | </li> | |
| 59 | - | </ul> | |
| 60 | - | </div> | |
| 61 | - | </div> | |
| 62 | - | </div> | |
| 63 | - | ||
| 64 | - | <!-- Theme toggle script --> | |
| 65 | - | <script> | |
| 66 | - | function setTheme(theme) { | |
| 67 | - | document.documentElement.setAttribute('data-theme', theme); | |
| 68 | - | localStorage.setItem('theme', theme); | |
| 69 | - | console.debug('Theme changed to:', theme); | |
| 70 | - | } | |
| 71 | - | </script> | |
| 72 | - | }⏎̸ | |
D internal/templating/models/templ/components/navbar_templ.go +0 -81 generated file: collapsed
D internal/templating/models/templ/components/success.templ +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // Success displays a success message | |
| 4 | - | templ Success(message string) { | |
| 5 | - | <div | |
| 6 | - | id="success" | |
| 7 | - | class="flex text-success content-center justify-center items-center w-full h-full"> | |
| 8 | - | <p class="text-2xl text-center"> | |
| 9 | - | if message != "" { | |
| 10 | - | { message } | |
| 11 | - | } else { | |
| 12 | - | Success! | |
| 13 | - | } | |
| 14 | - | </p> | |
| 15 | - | </div> | |
| 16 | - | }⏎̸ | |
D internal/templating/models/templ/components/success_templ.go +0 -61 generated file: collapsed
D internal/templating/models/templ/components/uploadimage.templ +0 -66
| @@ -1,66 +0,0 @@ | |||
| 1 | - | package components | |
| 2 | - | ||
| 3 | - | // UploadImageForm displays a form for uploading Docker images | |
| 4 | - | templ UploadImageForm() { | |
| 5 | - | <!-- Upload image form using DaisyUI components --> | |
| 6 | - | <div class="card bg-base-100 shadow"> | |
| 7 | - | <div class="card-body"> | |
| 8 | - | <div class="flex justify-between items-center"> | |
| 9 | - | <h2 class="card-title text-primary">Upload Image</h2> | |
| 10 | - | <button | |
| 11 | - | class="btn btn-sm btn-circle btn-ghost" | |
| 12 | - | hx-get="/htmx/image-manager" | |
| 13 | - | hx-target="#image-manager" | |
| 14 | - | hx-swap="innerHTML"> | |
| 15 | - | <span class="iconf text-lg">✕</span> | |
| 16 | - | </button> | |
| 17 | - | </div> | |
| 18 | - | <div class="divider"></div> | |
| 19 | - | ||
| 20 | - | <form | |
| 21 | - | id="upload-form" | |
| 22 | - | hx-encoding="multipart/form-data" | |
| 23 | - | hx-post="/htmx/upload-image" | |
| 24 | - | hx-target="#image-manager" | |
| 25 | - | hx-swap="innerHTML" | |
| 26 | - | class="space-y-4"> | |
| 27 | - | ||
| 28 | - | <div class="alert alert-info shadow-lg mb-4"> | |
| 29 | - | <span class="iconf">ℹ️</span> | |
| 30 | - | <div> | |
| 31 | - | <p>This form allows you to upload a container image (.tar file) to Gordon.</p> | |
| 32 | - | <p>After uploading, you can create and manage containers from this image.</p> | |
| 33 | - | </div> | |
| 34 | - | </div> | |
| 35 | - | ||
| 36 | - | <div class="form-control w-full"> | |
| 37 | - | <label class="label"> | |
| 38 | - | <span class="label-text">Select a container image file (.tar)</span> | |
| 39 | - | </label> | |
| 40 | - | <input | |
| 41 | - | type="file" | |
| 42 | - | name="imageFile" | |
| 43 | - | class="file-input file-input-bordered w-full" | |
| 44 | - | accept=".tar" | |
| 45 | - | required /> | |
| 46 | - | </div> | |
| 47 | - | ||
| 48 | - | <div class="card-actions justify-end mt-4"> | |
| 49 | - | <button | |
| 50 | - | class="btn btn-ghost" | |
| 51 | - | hx-get="/htmx/image-manager" | |
| 52 | - | hx-target="#image-manager" | |
| 53 | - | hx-swap="innerHTML" | |
| 54 | - | type="button"> | |
| 55 | - | Cancel | |
| 56 | - | </button> | |
| 57 | - | <button | |
| 58 | - | class="btn btn-primary" | |
| 59 | - | type="submit"> | |
| 60 | - | <span class="iconf mr-1"></span> Upload | |
| 61 | - | </button> | |
| 62 | - | </div> | |
| 63 | - | </form> | |
| 64 | - | </div> | |
| 65 | - | </div> | |
| 66 | - | } ⏎̸ | |
D internal/templating/models/templ/components/uploadimage_templ.go +0 -41 generated file: collapsed
D internal/templating/models/templ/layouts/admin_layout.templ +0 -21
| @@ -1,21 +0,0 @@ | |||
| 1 | - | package layouts | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 4 | - | ||
| 5 | - | // AdminLayout provides the base layout for admin pages | |
| 6 | - | templ AdminLayout(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |
| 7 | - | <!DOCTYPE html> | |
| 8 | - | <html lang="en" data-theme="gordon-dark"> | |
| 9 | - | @components.Header(title) | |
| 10 | - | <body class="min-h-screen bg-base-250"> | |
| 11 | - | @components.Navbar(buildVersion, adminPath, userSettingsURL, logoutURL) | |
| 12 | - | ||
| 13 | - | <!-- Main content starts after navbar height --> | |
| 14 | - | <div class="container mx-auto pt-20 px-4"> | |
| 15 | - | { children... } | |
| 16 | - | </div> | |
| 17 | - | ||
| 18 | - | @components.Footer() | |
| 19 | - | </body> | |
| 20 | - | </html> | |
| 21 | - | }⏎̸ | |
D internal/templating/models/templ/layouts/admin_layout_templ.go +0 -75 generated file: collapsed
D internal/templating/models/templ/layouts/base.templ +0 -16
| @@ -1,16 +0,0 @@ | |||
| 1 | - | package layouts | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 4 | - | ||
| 5 | - | // Base layout template that provides the HTML structure | |
| 6 | - | // All pages will use this layout template | |
| 7 | - | templ Base(title string) { | |
| 8 | - | <!DOCTYPE html> | |
| 9 | - | <html lang="en"> | |
| 10 | - | @components.Header(title) | |
| 11 | - | <body class="min-h-screen bg-base-200"> | |
| 12 | - | { children... } | |
| 13 | - | @components.Footer() | |
| 14 | - | </body> | |
| 15 | - | </html> | |
| 16 | - | }⏎̸ | |
D internal/templating/models/templ/layouts/base_templ.go +0 -64 generated file: collapsed
D internal/templating/models/templ/pages/admin/createcontainerfull.templ +0 -30
| @@ -1,30 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 5 | - | "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // CreateContainerFullData represents the data needed for the create container full page | |
| 9 | - | type CreateContainerFullData struct { | |
| 10 | - | Title string | |
| 11 | - | BuildVersion string | |
| 12 | - | AdminPath string | |
| 13 | - | UserSettings string | |
| 14 | - | LogoutURL string | |
| 15 | - | ContainerData components.ContainerFormData | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | // CreateContainerFullPage replaces createcontainerfull.gohtml | |
| 19 | - | // It renders the full page for creating a container | |
| 20 | - | templ CreateContainerFullPage(data CreateContainerFullData) { | |
| 21 | - | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |
| 22 | - | <div class="flex flex-col md:flex-row w-full flex-grow"> | |
| 23 | - | <div class="card flex-grow"> | |
| 24 | - | <h1 class="card-header mb-4">Create Container</h1> | |
| 25 | - | <!-- Include the container creation form component --> | |
| 26 | - | @components.CreateContainerForm(data.ContainerData) | |
| 27 | - | </div> | |
| 28 | - | </div> | |
| 29 | - | } | |
| 30 | - | } ⏎̸ | |
D internal/templating/models/templ/pages/admin/createcontainerfull_templ.go +0 -83 generated file: collapsed
D internal/templating/models/templ/pages/admin/dashboard.templ +0 -18
| @@ -1,18 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 4 | - | ||
| 5 | - | // DashboardPage implements the admin dashboard | |
| 6 | - | templ DashboardPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string) { | |
| 7 | - | @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) { | |
| 8 | - | <div id="dashboard"> | |
| 9 | - | <h1 | |
| 10 | - | class="text-5xl fw100 text-indigo-600 animate-bounce-alt animate-count-infinite animate-duration-1s" | |
| 11 | - | > | |
| 12 | - | Hello World from dashboard.html | |
| 13 | - | </h1> | |
| 14 | - | <!-- We load Chart.js here to avoid loading it in every page end when all the rest is loaded --> | |
| 15 | - | <script src="libs/chart.js"></script> | |
| 16 | - | </div> | |
| 17 | - | } | |
| 18 | - | }⏎̸ | |
D internal/templating/models/templ/pages/admin/dashboard_templ.go +0 -61 generated file: collapsed
D internal/templating/models/templ/pages/admin/index.templ +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 4 | - | ||
| 5 | - | // IndexPageData represents data needed for the index page | |
| 6 | - | type IndexPageData struct { | |
| 7 | - | Title string | |
| 8 | - | BuildVersion string | |
| 9 | - | AdminPath string | |
| 10 | - | UserSettings string | |
| 11 | - | LogoutURL string | |
| 12 | - | LangContent map[string]string // For storing language translations | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | // IndexPage replaces admin/index.gohtml | |
| 16 | - | // It renders the admin dashboard index page | |
| 17 | - | templ IndexPage(data IndexPageData) { | |
| 18 | - | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |
| 19 | - | <div class="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4 flex-grow"> | |
| 20 | - | <div class="card flex-grow"> | |
| 21 | - | <h2 class="card-header">{ data.LangContent["title"] }</h2> | |
| 22 | - | </div> | |
| 23 | - | <div class="w-full md:w-2/6 flex flex-col space-y-4"> | |
| 24 | - | <div class="card"> | |
| 25 | - | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 26 | - | </div> | |
| 27 | - | <div class="card"> | |
| 28 | - | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 29 | - | </div> | |
| 30 | - | <div class="card"> | |
| 31 | - | <div class="w-full h-44 rounded-sm bg-indigo-100"></div> | |
| 32 | - | </div> | |
| 33 | - | </div> | |
| 34 | - | </div> | |
| 35 | - | } | |
| 36 | - | } ⏎̸ | |
D internal/templating/models/templ/pages/admin/index_templ.go +0 -85 generated file: collapsed
D internal/templating/models/templ/pages/admin/install.templ +0 -59
| @@ -1,59 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 4 | - | ||
| 5 | - | // InstallPage replaces install.gohtml | |
| 6 | - | // It renders the installation page | |
| 7 | - | templ InstallPage(title string, version string) { | |
| 8 | - | @layouts.Base(title) { | |
| 9 | - | <!-- Logo and Title --> | |
| 10 | - | <div | |
| 11 | - | id="logo-mascot" | |
| 12 | - | class="flex flex-col justify-center items-center p-4 mb-4" | |
| 13 | - | > | |
| 14 | - | <div class="circle-shadow flex justify-center w-100"> | |
| 15 | - | <img | |
| 16 | - | src="/assets/imgs/gordon-mascot-mq-trsp.png" | |
| 17 | - | alt="logo" | |
| 18 | - | class="sm:h-24 h-32 xl:h-48" | |
| 19 | - | /> | |
| 20 | - | </div> | |
| 21 | - | <div class="flex justify-center mt-4"> | |
| 22 | - | <h1 | |
| 23 | - | class="gordon-title text-2xl lg:text-4xl xl:text-5xl text-indigo-800 tracking-tighter" | |
| 24 | - | > | |
| 25 | - | Gordon | |
| 26 | - | </h1> | |
| 27 | - | <span class="gordon-v-subtitle text-indigo-600 text-sm ml-2"> | |
| 28 | - | v{ version } | |
| 29 | - | </span> | |
| 30 | - | </div> | |
| 31 | - | </div> | |
| 32 | - | ||
| 33 | - | <!-- Main Content --> | |
| 34 | - | <div class="card w-2/3"> | |
| 35 | - | <!-- Built-in Proxy --> | |
| 36 | - | <div id="built-in-proxy" class="mt-8"> | |
| 37 | - | <div class="flex items-center mb-4"> | |
| 38 | - | <div class="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center"> | |
| 39 | - | <span class="text-white font-bold">✓</span> | |
| 40 | - | </div> | |
| 41 | - | <h2 class="text-lg ml-2 text-title">Reverse Proxy</h2> | |
| 42 | - | </div> | |
| 43 | - | ||
| 44 | - | <div class="bg-indigo-50 p-4 rounded-md"> | |
| 45 | - | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | |
| 46 | - | <p>The proxy automatically:</p> | |
| 47 | - | <ul class="list-disc ml-6 mb-4"> | |
| 48 | - | <li>Manages Let's Encrypt certificates</li> | |
| 49 | - | <li>Routes domains to the appropriate containers</li> | |
| 50 | - | <li>Handles HTTP to HTTPS redirects</li> | |
| 51 | - | <li>Provides proper headers for forwarded requests</li> | |
| 52 | - | </ul> | |
| 53 | - | <p>No additional configuration is required.</p> | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 56 | - | <!-- End Built-in Proxy --> | |
| 57 | - | </div> | |
| 58 | - | } | |
| 59 | - | } ⏎̸ | |
D internal/templating/models/templ/pages/admin/install_templ.go +0 -75 generated file: collapsed
D internal/templating/models/templ/pages/admin/manager.templ +0 -73
| @@ -1,73 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 5 | - | "strconv" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // ManagerPage implements the container and image manager page | |
| 9 | - | templ ManagerPage(title string, buildVersion string, adminPath string, userSettingsURL string, logoutURL string, containerCount int, imageCount int) { | |
| 10 | - | @layouts.AdminLayout(title, buildVersion, adminPath, userSettingsURL, logoutURL) { | |
| 11 | - | <!-- Main content --> | |
| 12 | - | <div class="text-sm breadcrumbs mb-4"> | |
| 13 | - | <ul> | |
| 14 | - | <li><a href={ templ.URL(adminPath) }>Gordon</a></li> | |
| 15 | - | <li>Manager</li> | |
| 16 | - | </ul> | |
| 17 | - | </div> | |
| 18 | - | ||
| 19 | - | <!-- Stats overview --> | |
| 20 | - | <div class="stats shadow mb-6 w-full bg-base-100"> | |
| 21 | - | <div class="stat"> | |
| 22 | - | <div class="stat-figure text-primary"> | |
| 23 | - | <span class="iconf text-3xl"></span> | |
| 24 | - | </div> | |
| 25 | - | <div class="stat-title">Containers</div> | |
| 26 | - | <div class="stat-value" id="container-count">{ strconv.Itoa(containerCount) }</div> | |
| 27 | - | <div class="stat-desc">Docker containers managed by Gordon</div> | |
| 28 | - | </div> | |
| 29 | - | ||
| 30 | - | <div class="stat"> | |
| 31 | - | <div class="stat-figure text-secondary"> | |
| 32 | - | <span class="iconf text-3xl"></span> | |
| 33 | - | </div> | |
| 34 | - | <div class="stat-title">Images</div> | |
| 35 | - | <div class="stat-value" id="image-count">{ strconv.Itoa(imageCount) }</div> | |
| 36 | - | <div class="stat-desc">Docker images available</div> | |
| 37 | - | </div> | |
| 38 | - | </div> | |
| 39 | - | ||
| 40 | - | <!-- Tabs for containers and images --> | |
| 41 | - | <div role="tablist" class="tabs tabs-border mb-4"> | |
| 42 | - | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Containers" id="containers-tab" checked /> | |
| 43 | - | <div role="tabpanel" class="tab-content p-0 bg-base-300"> | |
| 44 | - | <!-- Container manager section --> | |
| 45 | - | <div id="container-manager" | |
| 46 | - | hx-get="/htmx/container-manager" | |
| 47 | - | hx-trigger="load" | |
| 48 | - | hx-swap="innerHTML"> | |
| 49 | - | <!-- Container content loaded via HTMX --> | |
| 50 | - | <div class="text-center p-4"> | |
| 51 | - | <span class="iconf text-3xl animate-spin inline-block"></span> | |
| 52 | - | Loading containers... | |
| 53 | - | </div> | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 56 | - | ||
| 57 | - | <input type="radio" name="my_tabs" role="tab" class="tab" aria-label="Images" id="images-tab" /> | |
| 58 | - | <div role="tabpanel" class="tab-content p-0 bg-base-300"> | |
| 59 | - | <!-- Image manager section --> | |
| 60 | - | <div id="image-manager" | |
| 61 | - | hx-get="/htmx/image-manager" | |
| 62 | - | hx-trigger="load" | |
| 63 | - | hx-swap="innerHTML"> | |
| 64 | - | <!-- Image content loaded via HTMX --> | |
| 65 | - | <div class="text-center p-4"> | |
| 66 | - | <span class="iconf text-3xl animate-spin inline-block"></span> | |
| 67 | - | Loading images... | |
| 68 | - | </div> | |
| 69 | - | </div> | |
| 70 | - | </div> | |
| 71 | - | </div> | |
| 72 | - | } | |
| 73 | - | }⏎̸ | |
D internal/templating/models/templ/pages/admin/manager_templ.go +0 -99 generated file: collapsed
D internal/templating/models/templ/pages/admin/user.templ +0 -58
| @@ -1,58 +0,0 @@ | |||
| 1 | - | package admin | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 4 | - | ||
| 5 | - | // UserPageData represents data needed for the user page | |
| 6 | - | type UserPageData struct { | |
| 7 | - | Title string | |
| 8 | - | BuildVersion string | |
| 9 | - | AdminPath string | |
| 10 | - | UserSettings string | |
| 11 | - | LogoutURL string | |
| 12 | - | Username string | |
| 13 | - | Email string | |
| 14 | - | Role string | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | // UserPage replaces admin/user.gohtml | |
| 18 | - | // It renders the user profile page | |
| 19 | - | templ UserPage(data UserPageData) { | |
| 20 | - | @layouts.AdminLayout(data.Title, data.BuildVersion, data.AdminPath, data.UserSettings, data.LogoutURL) { | |
| 21 | - | <div class="text-sm breadcrumbs mb-4"> | |
| 22 | - | <ul> | |
| 23 | - | <li><a href={ templ.URL(data.AdminPath) }>Gordon</a></li> | |
| 24 | - | <li>User Profile</li> | |
| 25 | - | </ul> | |
| 26 | - | </div> | |
| 27 | - | ||
| 28 | - | <div class="card bg-base-100 shadow-xl"> | |
| 29 | - | <div class="card-body"> | |
| 30 | - | <h2 class="card-title">User Profile</h2> | |
| 31 | - | <div class="divider"></div> | |
| 32 | - | ||
| 33 | - | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| 34 | - | <div class="form-control"> | |
| 35 | - | <label class="label"> | |
| 36 | - | <span class="label-text">Username</span> | |
| 37 | - | </label> | |
| 38 | - | <input type="text" class="input input-bordered" value={ data.Username } readonly /> | |
| 39 | - | </div> | |
| 40 | - | ||
| 41 | - | <div class="form-control"> | |
| 42 | - | <label class="label"> | |
| 43 | - | <span class="label-text">Email</span> | |
| 44 | - | </label> | |
| 45 | - | <input type="email" class="input input-bordered" value={ data.Email } readonly /> | |
| 46 | - | </div> | |
| 47 | - | ||
| 48 | - | <div class="form-control"> | |
| 49 | - | <label class="label"> | |
| 50 | - | <span class="label-text">Role</span> | |
| 51 | - | </label> | |
| 52 | - | <input type="text" class="input input-bordered" value={ data.Role } readonly /> | |
| 53 | - | </div> | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 56 | - | </div> | |
| 57 | - | } | |
| 58 | - | } ⏎̸ | |
D internal/templating/models/templ/pages/admin/user_templ.go +0 -122 generated file: collapsed
D internal/templating/models/templ/pages/errors/errors.templ +0 -43
| @@ -1,43 +0,0 @@ | |||
| 1 | - | package errors | |
| 2 | - | ||
| 3 | - | import "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 4 | - | ||
| 5 | - | // ErrorPageData represents data needed for error pages | |
| 6 | - | type ErrorPageData struct { | |
| 7 | - | Title string | |
| 8 | - | BuildVersion string | |
| 9 | - | AdminPath string | |
| 10 | - | StatusCode int | |
| 11 | - | Message string | |
| 12 | - | } | |
| 13 | - | ||
| 14 | - | // NotFoundPage renders a 404 error page | |
| 15 | - | templ NotFoundPage(data ErrorPageData) { | |
| 16 | - | @layouts.Base(data.Title) { | |
| 17 | - | <div class="flex flex-col items-center justify-center min-h-screen p-4"> | |
| 18 | - | <div class="flex flex-col items-center mb-8"> | |
| 19 | - | <img src="/assets/imgs/gordon-mascot-mq-trsp.png" alt="Gordon Mascot" class="w-64 mb-4" /> | |
| 20 | - | <h1 class="text-4xl font-bold text-error">404</h1> | |
| 21 | - | <h2 class="text-2xl mb-2">Page Not Found</h2> | |
| 22 | - | <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p> | |
| 23 | - | </div> | |
| 24 | - | </div> | |
| 25 | - | } | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // ForbiddenPage renders a 403 error page | |
| 29 | - | templ ForbiddenPage(data ErrorPageData) { | |
| 30 | - | @layouts.Base(data.Title) { | |
| 31 | - | <div class="flex flex-col items-center justify-center min-h-screen p-4"> | |
| 32 | - | <div class="flex flex-col items-center mb-8"> | |
| 33 | - | <img src="/assets/imgs/gordon-mascot-mq-trsp.png" alt="Gordon Mascot" class="w-64 h-64 mb-4" /> | |
| 34 | - | <h1 class="text-4xl font-bold text-error">403</h1> | |
| 35 | - | <h2 class="text-2xl mb-2">Access Forbidden</h2> | |
| 36 | - | <p class="text-center text-base-content opacity-70 max-w-md mb-8">{ data.Message }</p> | |
| 37 | - | <a href={ templ.URL(data.AdminPath) } class="btn btn-primary"> | |
| 38 | - | <span class="iconf mr-2">🏠</span> Go Home | |
| 39 | - | </a> | |
| 40 | - | </div> | |
| 41 | - | </div> | |
| 42 | - | } | |
| 43 | - | } ⏎̸ | |
D internal/templating/models/templ/pages/errors/errors_templ.go +0 -153 generated file: collapsed
D internal/templating/models/templ/pages/login/index.templ +0 -115
| @@ -1,115 +0,0 @@ | |||
| 1 | - | package login | |
| 2 | - | ||
| 3 | - | // LoginPage implements the login page with token and GitHub authentication | |
| 4 | - | templ LoginPage(title string, buildVersion string, errorMsg string) { | |
| 5 | - | <!doctype html> | |
| 6 | - | <html lang="en" data-theme="gordon-dark"> | |
| 7 | - | <head> | |
| 8 | - | <meta charset="UTF-8" /> | |
| 9 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 10 | - | <title>{ title }</title> | |
| 11 | - | <meta name="description" content="Gordon Container Management System" /> | |
| 12 | - | <meta name="keywords" content="container, management, docker, podman" /> | |
| 13 | - | <!-- Custom CSS + Tailwind --> | |
| 14 | - | <link rel="stylesheet" href="/assets/css/tailwind.css" /> | |
| 15 | - | <!-- HTMX min js --> | |
| 16 | - | <script src="/libs/htmx.min.js"></script> | |
| 17 | - | <!-- HTMX SSE --> | |
| 18 | - | <script src="/libs/htmx-sse.js"></script> | |
| 19 | - | <!-- DaisyUI --> | |
| 20 | - | <script src="/libs/daisyui.js" type="module"></script> | |
| 21 | - | <!-- jQuery --> | |
| 22 | - | <script src="/libs/jquery.min.js"></script> | |
| 23 | - | <!-- Custom JS from assets/js --> | |
| 24 | - | <script src="/assets/js/custom.js" defer></script> | |
| 25 | - | ||
| 26 | - | <!-- daisyUI theme configuration --> | |
| 27 | - | <script> | |
| 28 | - | // Check for saved theme preference or use preferred color scheme | |
| 29 | - | const savedTheme = localStorage.getItem('theme') || 'gordon-dark'; | |
| 30 | - | document.documentElement.setAttribute('data-theme', savedTheme); | |
| 31 | - | console.debug('Theme loaded:', savedTheme); | |
| 32 | - | </script> | |
| 33 | - | </head> | |
| 34 | - | ||
| 35 | - | <body class="IBMPlexSans bg-base-200 min-h-screen flex flex-col items-center justify-center p-4"> | |
| 36 | - | <!-- Logo and Title --> | |
| 37 | - | <div | |
| 38 | - | id="logo-mascot" | |
| 39 | - | class="flex flex-col justify-center items-center p-4 mb-4"> | |
| 40 | - | <div class="flex justify-center w-100"> | |
| 41 | - | <img | |
| 42 | - | src="/assets/imgs/gordon-mascot-mq-trsp.png" | |
| 43 | - | alt="logo gordon" | |
| 44 | - | class="sm:h-24 h-32 xl:h-48 drop-shadow-xl" /> | |
| 45 | - | </div> | |
| 46 | - | <div class="flex justify-center mt-4"> | |
| 47 | - | <h1 | |
| 48 | - | class="gordon-title text-4xl lg:text-4xl xl:text-5xl text-primary tracking-tighter"> | |
| 49 | - | Gordon | |
| 50 | - | </h1> | |
| 51 | - | <span | |
| 52 | - | class="gordon-v-subtitle text-secondary text-sm ml-2" | |
| 53 | - | >{ buildVersion }</span> | |
| 54 | - | </div> | |
| 55 | - | </div> | |
| 56 | - | ||
| 57 | - | <!-- Main Content --> | |
| 58 | - | <div class="card bg-base-100 shadow-xl w-full max-w-md"> | |
| 59 | - | <div class="card-body"> | |
| 60 | - | <!-- Login Section --> | |
| 61 | - | <div id="login-section"> | |
| 62 | - | <div class="flex flex-col items-center"> | |
| 63 | - | <h2 class="card-title text-center mb-4">Login</h2> | |
| 64 | - | ||
| 65 | - | <!-- Error Message Display --> | |
| 66 | - | if errorMsg != "" { | |
| 67 | - | <div class="alert alert-error mb-4"> | |
| 68 | - | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> | |
| 69 | - | <span> | |
| 70 | - | // --- MODIFIED: Handle new error messages --- | |
| 71 | - | switch errorMsg { | |
| 72 | - | case "server_error": | |
| 73 | - | A server error occurred during login. Please try again later or contact support. | |
| 74 | - | case "github_error": // Kept for potential future use | |
| 75 | - | An error occurred during GitHub authentication. Please try again. | |
| 76 | - | case "unauthorized": | |
| 77 | - | This GitHub account is not authorized to access Gordon. | |
| 78 | - | case "email_mismatch": | |
| 79 | - | Login failed: Your GitHub email does not match the configured administrator email. | |
| 80 | - | case "no_email": | |
| 81 | - | Login failed: Your GitHub account must have a public primary email address configured. | |
| 82 | - | case "config_email_missing": | |
| 83 | - | Server Configuration Error: The administrator email is not set up. Please contact support. | |
| 84 | - | case "session_invalid_or_expired": // Added from login page handler | |
| 85 | - | Your session is invalid or has expired. Please log in again. | |
| 86 | - | default: | |
| 87 | - | An unknown login error occurred: { errorMsg } | |
| 88 | - | } | |
| 89 | - | // --- END MODIFICATION --- | |
| 90 | - | </span> | |
| 91 | - | </div> | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | <!-- Token Input Form Removed --> | |
| 95 | - | ||
| 96 | - | <!-- GitHub OAuth --> | |
| 97 | - | <div class="w-full"> | |
| 98 | - | <a | |
| 99 | - | href="/admin/login/oauth/github" | |
| 100 | - | class="btn btn-secondary w-full" | |
| 101 | - | > | |
| 102 | - | <svg class="mr-2 h-5 w-5" fill="currentColor" viewBox="0 0 20 20" aria-hidden="true"> | |
| 103 | - | <path fill-rule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clip-rule="evenodd" /> | |
| 104 | - | </svg> | |
| 105 | - | Sign in with GitHub | |
| 106 | - | </a> | |
| 107 | - | </div> | |
| 108 | - | </div> | |
| 109 | - | </div> | |
| 110 | - | <!-- End Login Section --> | |
| 111 | - | </div> | |
| 112 | - | </div> | |
| 113 | - | </body> | |
| 114 | - | </html> | |
| 115 | - | }⏎̸ | |
D internal/templating/models/templ/pages/login/index.templ (lines 84-87) +0 -5
| @@ -1,5 +0,0 @@ | |||
| 1 | - | <div class="w-full"> | |
| 2 | - | <a | |
| 3 | - | href="login/oauth/github" | |
| 4 | - | class="btn btn-secondary w-full" | |
| 5 | - | > ⏎̸ | |
D internal/templating/models/templ/pages/login/index_templ.go +0 -132 generated file: collapsed
D internal/templating/models/txt/locstrings.yml +0 -149
| @@ -1,149 +0,0 @@ | |||
| 1 | - | en: | |
| 2 | - | common: | |
| 3 | - | meta: | |
| 4 | - | description: Admin interface for Gordon | |
| 5 | - | keywords: gordon, admin, interface | |
| 6 | - | header: | |
| 7 | - | someKey: Hello World! | |
| 8 | - | update_available: Update available | |
| 9 | - | navmenu: | |
| 10 | - | dashboard: Dashboard | |
| 11 | - | manager: Manager | |
| 12 | - | settings: Settings | |
| 13 | - | logout: Logout | |
| 14 | - | content: | |
| 15 | - | update_available: "Update available" | |
| 16 | - | upload: "Upload" | |
| 17 | - | login: | |
| 18 | - | meta: | |
| 19 | - | title: Login to Gordon | |
| 20 | - | content: | |
| 21 | - | title: Login | |
| 22 | - | github_login: Login with GitHub | |
| 23 | - | dashboard: | |
| 24 | - | meta: | |
| 25 | - | title: Gordon - Admin Dashboard | |
| 26 | - | content: | |
| 27 | - | title: Dashboard | |
| 28 | - | manager: | |
| 29 | - | meta: | |
| 30 | - | title: Gordon - Container Engine Manager | |
| 31 | - | content: | |
| 32 | - | title: Container Engine Manager | |
| 33 | - | h1-containers: Containers | |
| 34 | - | h1-images: Images | |
| 35 | - | button-upload: Upload | |
| 36 | - | confirm-delete: Are you sure you want to delete this? | |
| 37 | - | confirm-container-stop: Are you sure you want to stop this container? | |
| 38 | - | no-containers: No available containers. | |
| 39 | - | thName: Name | |
| 40 | - | thPorts: Exposed Ports | |
| 41 | - | thStatus: Status | |
| 42 | - | thState: State | |
| 43 | - | thActions: Actions | |
| 44 | - | thEntryPoint: "Entry Point" | |
| 45 | - | images: Images | |
| 46 | - | manually_upload: Manually upload a .tar container image | |
| 47 | - | image_name: Image name | |
| 48 | - | image_id: Image ID | |
| 49 | - | container_name: Container name | |
| 50 | - | protocol: Protocol | |
| 51 | - | subdomain: Subdomain | |
| 52 | - | domain: Domain | |
| 53 | - | exposed_ports: Exposed Ports | |
| 54 | - | exposed_ports_hint: "eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)" | |
| 55 | - | proxy_port: Proxy Port | |
| 56 | - | proxy_port_hint: Specify the internal container port that the proxy will route traffic to | |
| 57 | - | volumes: Volumes | |
| 58 | - | volumes_hint: "e.g. ./data:/container/data" | |
| 59 | - | environment_variables: Environment Variables | |
| 60 | - | environment_variables_hint: One per line, in the format KEY=VALUE | |
| 61 | - | deploy: Deploy | |
| 62 | - | edit_container: Edit Container | |
| 63 | - | save: Save | |
| 64 | - | settings: | |
| 65 | - | meta: | |
| 66 | - | title: Gordon - Settings | |
| 67 | - | content: | |
| 68 | - | title: Settings | |
| 69 | - | error: | |
| 70 | - | meta: | |
| 71 | - | title: Error | |
| 72 | - | content: | |
| 73 | - | not_found: Page not found | |
| 74 | - | internal_server: Something went really wrong o/ | |
| 75 | - | ||
| 76 | - | fr: | |
| 77 | - | common: | |
| 78 | - | meta: | |
| 79 | - | description: Interface d'administration pour Gordon | |
| 80 | - | keywords: gordon, admin, interface | |
| 81 | - | header: | |
| 82 | - | someKey: Bonjour le monde ! | |
| 83 | - | update_available: Mise à jour disponible | |
| 84 | - | navmenu: | |
| 85 | - | dashboard: Tableau de bord | |
| 86 | - | manager: Gestionnaire | |
| 87 | - | settings: Paramètres | |
| 88 | - | logout: Déconnexion | |
| 89 | - | content: | |
| 90 | - | update_available: "Mise à jour disponible" | |
| 91 | - | upload: "Télécharger" | |
| 92 | - | login: | |
| 93 | - | meta: | |
| 94 | - | title: Connexion à Gordon | |
| 95 | - | content: | |
| 96 | - | title: Connexion | |
| 97 | - | github_login: Se connecter avec GitHub | |
| 98 | - | dashboard: | |
| 99 | - | meta: | |
| 100 | - | title: Gordon - Tableau de bord | |
| 101 | - | content: | |
| 102 | - | title: Tableau de bord | |
| 103 | - | manager: | |
| 104 | - | meta: | |
| 105 | - | title: Gordon - Gestionnaire de moteur de conteneur | |
| 106 | - | content: | |
| 107 | - | title: Gestionnaire de moteur de conteneur | |
| 108 | - | h1-containers: Conteneurs | |
| 109 | - | h1-images: Images | |
| 110 | - | button-upload: Télécharger | |
| 111 | - | confirm-delete: Êtes-vous sûr de vouloir supprimer ceci? | |
| 112 | - | confirm-container-stop: Êtes-vous sûr de vouloir arrêter ce conteneur? | |
| 113 | - | no-containers: Aucun conteneur disponible. | |
| 114 | - | thName: Nom | |
| 115 | - | thPorts: Ports exposés | |
| 116 | - | thStatus: Statut | |
| 117 | - | thState: État | |
| 118 | - | thActions: Actions | |
| 119 | - | thEntryPoint: "Point d'entrée" | |
| 120 | - | images: Images | |
| 121 | - | manually_upload: Télécharger manuellement une image de conteneur .tar | |
| 122 | - | image_name: Nom de l'image | |
| 123 | - | image_id: ID de l'image | |
| 124 | - | container_name: Nom du conteneur | |
| 125 | - | protocol: Protocole | |
| 126 | - | subdomain: Sous-domaine | |
| 127 | - | domain: Domaine | |
| 128 | - | exposed_ports: Ports exposés | |
| 129 | - | exposed_ports_hint: "ex: 8080:1887/tcp (Vous pouvez spécifier plusieurs ports en les séparant par une virgule)" | |
| 130 | - | proxy_port: Proxy Port | |
| 131 | - | proxy_port_hint: Spécifiez le port interne du conteneur que le proxy va router le trafic vers | |
| 132 | - | volumes: Volumes | |
| 133 | - | volumes_hint: "ex. ./data:/container/data" | |
| 134 | - | environment_variables: Variables d'environnement | |
| 135 | - | environment_variables_hint: Une par ligne, au format CLÉ=VALEUR | |
| 136 | - | deploy: Déployer | |
| 137 | - | edit_container: Modifier le conteneur | |
| 138 | - | save: Enregistrer | |
| 139 | - | settings: | |
| 140 | - | meta: | |
| 141 | - | title: Gordon - Paramètres | |
| 142 | - | content: | |
| 143 | - | title: Paramètres | |
| 144 | - | error: | |
| 145 | - | meta: | |
| 146 | - | title: Erreur | |
| 147 | - | content: | |
| 148 | - | not_found: Page non trouvée | |
| 149 | - | internal_server: Quelque chose s'est vraiment mal passé o/⏎̸ | |
D internal/templating/render/html.go +0 -77
| @@ -1,77 +0,0 @@ | |||
| 1 | - | package render | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "fmt" | |
| 6 | - | "html/template" | |
| 7 | - | "io/fs" | |
| 8 | - | "path" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/gordon/internal/server" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | type Renderer struct { | |
| 14 | - | Template *template.Template | |
| 15 | - | ParseError error | |
| 16 | - | BuildVersion string | |
| 17 | - | LatestVersion string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // Render function renders the template with the given data | |
| 21 | - | func (r *Renderer) Render(data interface{}, a *server.App) (string, error) { | |
| 22 | - | if r.ParseError != nil { | |
| 23 | - | return "", fmt.Errorf("failed to parse template: %w", r.ParseError) | |
| 24 | - | ||
| 25 | - | } | |
| 26 | - | if r.Template == nil { | |
| 27 | - | return "", fmt.Errorf("template is nil") | |
| 28 | - | } | |
| 29 | - | // Type assert data to map[string]interface{} to add BuildVersion | |
| 30 | - | dataMap, ok := data.(map[string]interface{}) | |
| 31 | - | if !ok { | |
| 32 | - | return "", fmt.Errorf("data is not a map[string]interface{}") | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Automatically add BuildVersion to the data map | |
| 36 | - | dataMap["BuildVersion"] = r.BuildVersion | |
| 37 | - | buf := new(bytes.Buffer) | |
| 38 | - | err := r.Template.Execute(buf, dataMap) | |
| 39 | - | if err != nil { | |
| 40 | - | return "", fmt.Errorf("failed to execute template: %w", err) | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | return buf.String(), nil | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // GetHTMLRenderer function returns a new Renderer instance | |
| 47 | - | func GetHTMLRenderer(mainPath string, filename string, fs fs.FS, a *server.App, fragmentsPath ...string) (*Renderer, error) { | |
| 48 | - | // Full path to the main template | |
| 49 | - | fullPath := path.Join(mainPath, filename) | |
| 50 | - | // Check if the file exists in the provided fs.FS using fs.Open | |
| 51 | - | file, err := fs.Open(fullPath) | |
| 52 | - | if err != nil { | |
| 53 | - | return nil, fmt.Errorf("failed to open %s: %w", filename, err) | |
| 54 | - | } | |
| 55 | - | file.Close() | |
| 56 | - | ||
| 57 | - | // Parse the main template | |
| 58 | - | tmpl, err := template.New(filename).ParseFS(fs, fullPath) | |
| 59 | - | if err != nil { | |
| 60 | - | return nil, fmt.Errorf("failed to parse %s: %w", filename, err) | |
| 61 | - | } | |
| 62 | - | ||
| 63 | - | // If a fragments path is provided, parse the fragment templates | |
| 64 | - | if len(fragmentsPath) > 0 && fragmentsPath[0] != "" { | |
| 65 | - | fragmentsGlob := path.Join(fragmentsPath[0], "*.gohtml") | |
| 66 | - | _, err = tmpl.ParseFS(fs, fragmentsGlob) | |
| 67 | - | if err != nil { | |
| 68 | - | return nil, fmt.Errorf("failed to parse fragments: %w", err) | |
| 69 | - | } | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | // Return the Renderer instance | |
| 73 | - | return &Renderer{ | |
| 74 | - | Template: tmpl, | |
| 75 | - | BuildVersion: a.Config.Build.BuildVersion, | |
| 76 | - | }, nil | |
| 77 | - | } | |
D internal/templating/render/localization.go +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | package render | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | ||
| 6 | - | "github.com/bnema/gordon/internal/server" | |
| 7 | - | ) | |
| 8 | - | ||
| 9 | - | // GetLocalization function returns the localization map for the given language | |
| 10 | - | func GetLocalization(lang string, a *server.App) (map[string]interface{}, error) { | |
| 11 | - | // 1. Check if the language exists in the pre-parsed map | |
| 12 | - | langData, langExists := a.Strings[lang] | |
| 13 | - | if !langExists { | |
| 14 | - | // Fallback to default language (e.g., "en") if primary is not found | |
| 15 | - | defaultLang := "en" | |
| 16 | - | langData, langExists = a.Strings[defaultLang] | |
| 17 | - | if !langExists { | |
| 18 | - | return nil, fmt.Errorf("default language '%s' not found in localization data", defaultLang) | |
| 19 | - | } | |
| 20 | - | // Use default language if primary was not found | |
| 21 | - | lang = defaultLang | |
| 22 | - | fmt.Printf("Warning: Language '%s' not found, falling back to '%s'\n", lang, defaultLang) | |
| 23 | - | } | |
| 24 | - | ||
| 25 | - | // 2. Assert that the language data is a map | |
| 26 | - | localizationMap, ok := langData.(map[string]interface{}) | |
| 27 | - | if !ok { | |
| 28 | - | return nil, fmt.Errorf("localization data for language '%s' is not in the expected map format", lang) | |
| 29 | - | } | |
| 30 | - | ||
| 31 | - | // 3. Return the localization map for the requested (or fallback) language | |
| 32 | - | return localizationMap, nil | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Helper function to check if a slice contains a string - No longer needed as we check map keys | |
| 36 | - | // func contains(slice []string, str string) bool { ... } | |
D internal/templating/render/templ.go +0 -42
| @@ -1,42 +0,0 @@ | |||
| 1 | - | package render | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "fmt" | |
| 6 | - | "net/http" | |
| 7 | - | ||
| 8 | - | "github.com/a-h/templ" | |
| 9 | - | "github.com/bnema/gordon/internal/server" | |
| 10 | - | "github.com/labstack/echo/v4" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | // Renderer for templ templates | |
| 14 | - | type TemplRenderer struct { | |
| 15 | - | BuildVersion string | |
| 16 | - | LatestVersion string | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // RenderTempl renders a templ component | |
| 20 | - | func (r *TemplRenderer) RenderTempl(ctx echo.Context, component templ.Component) error { | |
| 21 | - | // Set the content type and status code | |
| 22 | - | ctx.Response().Header().Set(echo.HeaderContentType, echo.MIMETextHTML) | |
| 23 | - | ctx.Response().WriteHeader(http.StatusOK) | |
| 24 | - | ||
| 25 | - | // Create a render context | |
| 26 | - | renderCtx := context.WithValue(ctx.Request().Context(), "BuildVersion", r.BuildVersion) | |
| 27 | - | ||
| 28 | - | // Render the component | |
| 29 | - | err := component.Render(renderCtx, ctx.Response().Writer) | |
| 30 | - | if err != nil { | |
| 31 | - | return fmt.Errorf("failed to render templ component: %w", err) | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | return nil | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | // NewTemplRenderer creates a new templ renderer | |
| 38 | - | func NewTemplRenderer(a *server.App) *TemplRenderer { | |
| 39 | - | return &TemplRenderer{ | |
| 40 | - | BuildVersion: a.Config.Build.BuildVersion, | |
| 41 | - | } | |
| 42 | - | }⏎̸ | |
D internal/templating/templ_embed.go +0 -13
| @@ -1,13 +0,0 @@ | |||
| 1 | - | package templating | |
| 2 | - | ||
| 3 | - | // This file contains templ components embeddings | |
| 4 | - | // As we migrate from traditional Go templates to templ, | |
| 5 | - | // we'll add the appropriate code here to handle templ templates. | |
| 6 | - | ||
| 7 | - | import ( | |
| 8 | - | // Import templ generated templates, which will make them available to the application | |
| 9 | - | _ "github.com/bnema/gordon/internal/templating/models/templ/components" | |
| 10 | - | _ "github.com/bnema/gordon/internal/templating/models/templ/layouts" | |
| 11 | - | _ "github.com/bnema/gordon/internal/templating/models/templ/pages/admin" | |
| 12 | - | _ "github.com/bnema/gordon/internal/templating/models/templ/pages/login" | |
| 13 | - | )⏎̸ | |
D internal/webui/embed.go +0 -9
| @@ -1,9 +0,0 @@ | |||
| 1 | - | package webui | |
| 2 | - | ||
| 3 | - | import "embed" | |
| 4 | - | ||
| 5 | - | // PublicFS holds the embedded static file system for the web UI. | |
| 6 | - | // The path "public" is relative to this package directory (internal/webui). | |
| 7 | - | // | |
| 8 | - | //go:embed public | |
| 9 | - | var PublicFS embed.FS | |
D internal/webui/public/404.gohtml +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | <!DOCTYPE html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="UTF-8" /> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 6 | - | <title>Error 404</title> | |
| 7 | - | </head> | |
| 8 | - | <body> | |
| 9 | - | <h1>Error 404</h1> | |
| 10 | - | <p>Page not found</p> | |
| 11 | - | </body> | |
| 12 | - | </html> | |
D internal/webui/public/500.gohtml +0 -12
| @@ -1,12 +0,0 @@ | |||
| 1 | - | <!DOCTYPE html> | |
| 2 | - | <html lang="en"> | |
| 3 | - | <head> | |
| 4 | - | <meta charset="UTF-8" /> | |
| 5 | - | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 6 | - | <title>Error 500</title> | |
| 7 | - | </head> | |
| 8 | - | <body> | |
| 9 | - | <h1>500 Internal Server Error</h1> | |
| 10 | - | <p>Something went really wrong o/</p> | |
| 11 | - | </body> | |
| 12 | - | </html> | |
D internal/webui/public/android-chrome-192x192.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/android-chrome-512x512.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/apple-touch-icon.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/css/custom.css +0 -155
| @@ -1,155 +0,0 @@ | |||
| 1 | - | @import "tailwindcss"; | |
| 2 | - | /* Custom DaisyUI theme - Defined in tailwind.config.js */ | |
| 3 | - | @plugin "daisyui/theme" { | |
| 4 | - | name: "gordon-dark"; | |
| 5 | - | default: true; | |
| 6 | - | prefersdark: true; | |
| 7 | - | color-scheme: "dark"; | |
| 8 | - | --color-base-100: oklch(14% 0 0); | |
| 9 | - | --color-base-200: oklch(21% 0.006 285.885); | |
| 10 | - | --color-base-250: oklch(21% 0.006 285.885); | |
| 11 | - | --color-base-300: oklch(26% 0 0); | |
| 12 | - | --color-base-content: oklch(97% 0.001 106.424); | |
| 13 | - | --color-primary: oklch(44% 0.043 257.281); | |
| 14 | - | --color-primary-content: oklch(98% 0.003 247.858); | |
| 15 | - | --color-secondary: oklch(66% 0.179 58.318); | |
| 16 | - | --color-secondary-content: oklch(98% 0.022 95.277); | |
| 17 | - | --color-accent: oklch(60% 0.118 184.704); | |
| 18 | - | --color-accent-content: oklch(98% 0.014 180.72); | |
| 19 | - | --color-neutral: oklch(42% 0.199 265.638); | |
| 20 | - | --color-neutral-content: oklch(98% 0.001 106.423); | |
| 21 | - | --color-info: oklch(68% 0.169 237.323); | |
| 22 | - | --color-info-content: oklch(97% 0.013 236.62); | |
| 23 | - | --color-success: oklch(69% 0.17 162.48); | |
| 24 | - | --color-success-content: oklch(97% 0.021 166.113); | |
| 25 | - | --color-warning: oklch(70% 0.213 47.604); | |
| 26 | - | --color-warning-content: oklch(98% 0.016 73.684); | |
| 27 | - | --color-error: oklch(64% 0.246 16.439); | |
| 28 | - | --color-error-content: oklch(96% 0.015 12.422); | |
| 29 | - | --radius-selector: 1rem; | |
| 30 | - | --radius-field: 0.5rem; | |
| 31 | - | --radius-box: 1rem; | |
| 32 | - | --size-selector: 0.25rem; | |
| 33 | - | --size-field: 0.21875rem; | |
| 34 | - | --border: 1px; | |
| 35 | - | --depth: 0; | |
| 36 | - | --noise: 0; | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | ||
| 40 | - | /* Import IBM Plex Fonts */ | |
| 41 | - | @font-face { | |
| 42 | - | font-family: "IBMPlexSans-Bold"; | |
| 43 | - | src: url("/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2") format("woff2"); | |
| 44 | - | font-weight: bold; | |
| 45 | - | font-style: normal; | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | @font-face { | |
| 49 | - | font-family: "IBMPlexSans-Regular"; | |
| 50 | - | src: url("/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2") format("woff2"); | |
| 51 | - | font-weight: normal; | |
| 52 | - | font-style: normal; | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | /* Import Jetbrains Mono Nerd Font */ | |
| 56 | - | @font-face { | |
| 57 | - | font-family: "JetBrainsMono-Nerd-Font-Regular"; | |
| 58 | - | src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2") | |
| 59 | - | format("woff2"); | |
| 60 | - | font-weight: normal; | |
| 61 | - | font-style: normal; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | /* Import Jetbrains Mono Nerd Font Bold */ | |
| 65 | - | @font-face { | |
| 66 | - | font-family: "JetBrainsMono-Nerd-Font-Bold"; | |
| 67 | - | src: url("/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2") | |
| 68 | - | format("woff2"); | |
| 69 | - | font-weight: bold; | |
| 70 | - | font-style: normal; | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | @font-face { | |
| 74 | - | font-family: "LilitaOne-Regular"; | |
| 75 | - | src: url("/assets/fonts/LilitaOne/LilitaOne-Regular.ttf") format("truetype"); | |
| 76 | - | font-weight: normal; | |
| 77 | - | font-style: normal; | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | .fade-me-out.htmx-swapping { | |
| 81 | - | opacity: 0; | |
| 82 | - | transition: opacity 1s ease-out; | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | /* View transition */ | |
| 86 | - | @keyframes fade-in { | |
| 87 | - | from { | |
| 88 | - | opacity: 0; | |
| 89 | - | } | |
| 90 | - | } | |
| 91 | - | ||
| 92 | - | @keyframes fade-out { | |
| 93 | - | to { | |
| 94 | - | opacity: 0; | |
| 95 | - | } | |
| 96 | - | } | |
| 97 | - | ||
| 98 | - | @keyframes slide-from-right { | |
| 99 | - | from { | |
| 100 | - | transform: translateX(90px); | |
| 101 | - | } | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | @keyframes slide-to-left { | |
| 105 | - | to { | |
| 106 | - | transform: translateX(-90px); | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | @keyframes wiggle { | |
| 111 | - | ||
| 112 | - | 0%, | |
| 113 | - | 100% { | |
| 114 | - | transform: rotate(-3deg); | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | 50% { | |
| 118 | - | transform: rotate(3deg); | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | #update-available { | |
| 123 | - | animation: wiggle 1s ease-in-out infinite; | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | .slide-it { | |
| 127 | - | view-transition-name: slide-it; | |
| 128 | - | } | |
| 129 | - | ||
| 130 | - | ::view-transition-old(slide-it) { | |
| 131 | - | animation: | |
| 132 | - | 180ms cubic-bezier(0.4, 0, 1, 1) both fade-out, | |
| 133 | - | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-to-left; | |
| 134 | - | } | |
| 135 | - | ::view-transition-new(slide-it) { | |
| 136 | - | animation: | |
| 137 | - | 420ms cubic-bezier(0, 0, 0.2, 1) 90ms both fade-in, | |
| 138 | - | 600ms cubic-bezier(0.4, 0, 0.2, 1) both slide-from-right; | |
| 139 | - | } | |
| 140 | - | ||
| 141 | - | .gordon-title { | |
| 142 | - | font-family: "LilitaOne-Regular", sans-serif; | |
| 143 | - | } | |
| 144 | - | ||
| 145 | - | .IBMPlexSans { | |
| 146 | - | font-family: "IBMPlexSans-Regular", sans-serif; | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | .JetBrainsMonoNF, .iconf{ | |
| 150 | - | font-family: "JetBrainsMono-Nerd-Font-Regular", sans-serif; | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | .JetBrainsMonoBoldNF, .iconfb{ | |
| 154 | - | font-family: "JetBrainsMono-Nerd-Font-Bold", sans-serif; | |
| 155 | - | } | |
D internal/webui/public/assets/css/tailwind.css +0 -1321 collapsed to keep the page light
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Bold.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Light.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Medium.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/IBMPlex/IBMPlexSans-Thin.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Bold.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFont-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/JetBrainsMonoNerd/JetBrainsMonoNerdFontMono-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.ttf +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/fonts/LilitaOne/LilitaOne-Regular.woff2 +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark-white.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark-white.svg +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | <svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>⏎̸ | |
D internal/webui/public/assets/imgs/github/github-mark.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/imgs/github/github-mark.svg +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | <svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>⏎̸ | |
D internal/webui/public/assets/imgs/gordon-mascot-mq-trsp.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/assets/js/custom.js +0 -402
| @@ -1,402 +0,0 @@ | |||
| 1 | - | function initializeVersionCheck() { | |
| 2 | - | // Dummy data for the remote version (as if fetched from a server) | |
| 3 | - | const updateStrTemplate = | |
| 4 | - | "New version %VERSION% available, consider pulling the latest image"; | |
| 5 | - | ||
| 6 | - | const checkVersion = (versionData, currentVersion) => { | |
| 7 | - | const fetchedVersionNumber = | |
| 8 | - | versionData?.amd64?.name.match(/\d+\.\d+\.\d+/)?.[0]; | |
| 9 | - | ||
| 10 | - | if (currentVersion !== fetchedVersionNumber) { | |
| 11 | - | const updateStr = updateStrTemplate.replace( | |
| 12 | - | "%VERSION%", | |
| 13 | - | fetchedVersionNumber, | |
| 14 | - | ); | |
| 15 | - | console.debug("Update available:", updateStr); | |
| 16 | - | const updateElement = document.getElementById("update-available"); | |
| 17 | - | updateElement.title = updateStr; | |
| 18 | - | updateElement.toggleAttribute("hidden", false); | |
| 19 | - | updateElement.classList.remove("hidden"); // Remove the Tailwind `hidden` class | |
| 20 | - | updateElement.classList.add("block"); // Add the Tailwind `block` class | |
| 21 | - | } else { | |
| 22 | - | console.debug( | |
| 23 | - | "No new version. Current version is up-to-date:", | |
| 24 | - | currentVersion, | |
| 25 | - | ); | |
| 26 | - | } | |
| 27 | - | }; | |
| 28 | - | ||
| 29 | - | const fetchVersionInfo = (currentVersion) => { | |
| 30 | - | fetch(`https://gordon-proxy.bamen.dev/version`) | |
| 31 | - | .then((response) => response.json()) | |
| 32 | - | .then((versionData) => checkVersion(versionData, currentVersion)) | |
| 33 | - | .catch((error) => console.error("Error fetching version:", error)); | |
| 34 | - | }; | |
| 35 | - | ||
| 36 | - | const init = () => { | |
| 37 | - | const currentVersionElement = document.getElementById("actual-version"); | |
| 38 | - | if (!currentVersionElement) { | |
| 39 | - | console.debug("Error: #actual-version element not found. Assuming not on admin page."); | |
| 40 | - | return false; // Indicate element not found | |
| 41 | - | } | |
| 42 | - | const currentVersion = currentVersionElement.textContent.trim(); | |
| 43 | - | if (!currentVersion) { | |
| 44 | - | console.debug("Dev mode detected, skipping version check."); | |
| 45 | - | return true; // Element found, but dev mode | |
| 46 | - | } | |
| 47 | - | fetchVersionInfo(currentVersion); | |
| 48 | - | return true; // Indicate element found | |
| 49 | - | }; | |
| 50 | - | ||
| 51 | - | return init(); // Return the result of init | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // Initialize jQuery functionality for the admin dashboard | |
| 55 | - | function initializeAdminDashboard() { | |
| 56 | - | console.debug('Initializing admin dashboard with jQuery'); | |
| 57 | - | ||
| 58 | - | // Check if jQuery is properly loaded | |
| 59 | - | if (typeof $ === 'undefined') { | |
| 60 | - | console.error('jQuery is not defined! Make sure jQuery is loaded before this script.'); | |
| 61 | - | return; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | // Handle edit button clicks to toggle action response visibility | |
| 65 | - | $(document).on('click', '[id^="edit-button-"]', function() { | |
| 66 | - | const id = $(this).attr('id').replace('edit-button-', ''); | |
| 67 | - | const actionResponse = $(`#actionResponse-${id}`); | |
| 68 | - | ||
| 69 | - | console.debug(`Toggle visibility for actionResponse-${id}`); | |
| 70 | - | actionResponse.toggleClass('visible hidden'); | |
| 71 | - | ||
| 72 | - | // Update the active state | |
| 73 | - | const isActive = $(this).data('active') === true; | |
| 74 | - | $(this).data('active', !isActive); | |
| 75 | - | }); | |
| 76 | - | ||
| 77 | - | // Handle upload button click | |
| 78 | - | $(document).on('click', '#upload-button', function() { | |
| 79 | - | console.debug('Upload button clicked'); | |
| 80 | - | $('#upload-image').toggleClass('visible hidden'); | |
| 81 | - | $('.create-container').addClass('hidden'); | |
| 82 | - | }); | |
| 83 | - | ||
| 84 | - | // Handle add button click | |
| 85 | - | $(document).on('click', '[id^="add-button-img-"]', function() { | |
| 86 | - | const id = $(this).attr('id').replace('add-button-img-', ''); | |
| 87 | - | console.debug(`Add button clicked for ${id}`); | |
| 88 | - | $(`#create-container-${id}`).toggleClass('visible hidden'); | |
| 89 | - | $('#upload-image').addClass('hidden'); | |
| 90 | - | ||
| 91 | - | // Add event listener for form submission | |
| 92 | - | $(document).on('submit', `#create-container-form-${id}`, function(e) { | |
| 93 | - | console.debug(`Form submission for container ${id}`); | |
| 94 | - | // Don't prevent default as HTMX will handle the submission | |
| 95 | - | }); | |
| 96 | - | }); | |
| 97 | - | ||
| 98 | - | // Handle upload submit button | |
| 99 | - | $(document).on('click', '#upload-submit-button', function() { | |
| 100 | - | console.debug('Upload submit button clicked'); | |
| 101 | - | $(this).prop('disabled', true); | |
| 102 | - | ||
| 103 | - | // Re-enable the button after the HTMX request completes | |
| 104 | - | document.body.addEventListener('htmx:afterOnLoad', function() { | |
| 105 | - | $('#upload-submit-button').prop('disabled', false); | |
| 106 | - | }, { once: true }); | |
| 107 | - | }); | |
| 108 | - | ||
| 109 | - | // Handle success message without page reload | |
| 110 | - | $(document).on('htmx:afterSwap', function(event) { | |
| 111 | - | if ($(event.detail.target).find('#success').length > 0) { | |
| 112 | - | console.debug('Success message detected'); | |
| 113 | - | ||
| 114 | - | // Instead of reloading the page, refresh the container list | |
| 115 | - | refreshContainerList(); | |
| 116 | - | ||
| 117 | - | // Animate and remove the success message | |
| 118 | - | setTimeout(function() { | |
| 119 | - | $('#success').animate({ opacity: 0 }, 1000, function() { | |
| 120 | - | $(this).remove(); | |
| 121 | - | }); | |
| 122 | - | }, 1000); | |
| 123 | - | } | |
| 124 | - | }); | |
| 125 | - | ||
| 126 | - | // Handle refresh buttons | |
| 127 | - | $(document).on('click', '#refresh-containers-button', function() { | |
| 128 | - | console.debug('Refreshing container list manually'); | |
| 129 | - | refreshContainerList(); | |
| 130 | - | }); | |
| 131 | - | ||
| 132 | - | $(document).on('click', '#refresh-images-button', function() { | |
| 133 | - | console.debug('Refreshing image list manually'); | |
| 134 | - | refreshImageList(); | |
| 135 | - | }); | |
| 136 | - | ||
| 137 | - | // Setup HTMX event listeners to avoid page reloads | |
| 138 | - | setupHtmxEventListeners(); | |
| 139 | - | ||
| 140 | - | // Initialize notification system | |
| 141 | - | initializeNotifications(); | |
| 142 | - | ||
| 143 | - | console.debug("Admin dashboard jQuery functionality initialized"); | |
| 144 | - | } | |
| 145 | - | ||
| 146 | - | // Function to initialize notification system | |
| 147 | - | function initializeNotifications() { | |
| 148 | - | // Create notification container if it doesn't exist | |
| 149 | - | if ($('#notification-container').length === 0) { | |
| 150 | - | $('body').append('<div id="notification-container" class="fixed top-4 right-4 z-50 flex flex-col space-y-2"></div>'); | |
| 151 | - | } | |
| 152 | - | ||
| 153 | - | console.debug("Notification system initialized"); | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // Function to show a notification | |
| 157 | - | function showNotification(message, type = 'success', duration = 3000) { | |
| 158 | - | const notificationId = 'notification-' + Date.now(); | |
| 159 | - | const bgColor = type === 'success' ? 'bg-green-500' : type === 'error' ? 'bg-red-500' : 'bg-blue-500'; | |
| 160 | - | ||
| 161 | - | const notification = $(` | |
| 162 | - | <div id="${notificationId}" class="${bgColor} text-white p-3 rounded-md shadow-md opacity-0 transition-opacity duration-300"> | |
| 163 | - | ${message} | |
| 164 | - | </div> | |
| 165 | - | `); | |
| 166 | - | ||
| 167 | - | $('#notification-container').append(notification); | |
| 168 | - | ||
| 169 | - | // Fade in | |
| 170 | - | setTimeout(() => { | |
| 171 | - | $(`#${notificationId}`).css('opacity', '1'); | |
| 172 | - | }, 10); | |
| 173 | - | ||
| 174 | - | // Fade out and remove after duration | |
| 175 | - | setTimeout(() => { | |
| 176 | - | $(`#${notificationId}`).css('opacity', '0'); | |
| 177 | - | setTimeout(() => { | |
| 178 | - | $(`#${notificationId}`).remove(); | |
| 179 | - | }, 300); | |
| 180 | - | }, duration); | |
| 181 | - | ||
| 182 | - | console.debug(`Notification shown: ${message}`); | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // Function to refresh container list without page reload | |
| 186 | - | function refreshContainerList() { | |
| 187 | - | console.debug("Refreshing container list"); | |
| 188 | - | ||
| 189 | - | // Show loading indicator | |
| 190 | - | const containerManager = $('#container-manager'); | |
| 191 | - | if (containerManager.length === 0) { | |
| 192 | - | console.error("Container manager element not found in DOM"); | |
| 193 | - | return; | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | console.debug("Container manager element found:", containerManager[0]); | |
| 197 | - | console.debug("Container manager classes:", containerManager.attr('class')); | |
| 198 | - | ||
| 199 | - | const originalContent = containerManager.html(); | |
| 200 | - | containerManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading containers...</div>'); | |
| 201 | - | ||
| 202 | - | // Ensure the container is visible | |
| 203 | - | containerManager.removeClass('hidden'); | |
| 204 | - | ||
| 205 | - | $.ajax({ | |
| 206 | - | url: '/htmx/container-manager', | |
| 207 | - | type: 'GET', | |
| 208 | - | success: function(data) { | |
| 209 | - | console.debug("Container data received, length:", data.length); | |
| 210 | - | $('#container-manager').html(data); | |
| 211 | - | ||
| 212 | - | // Force visibility | |
| 213 | - | $('#container-manager').removeClass('hidden'); | |
| 214 | - | ||
| 215 | - | console.debug("Container list refreshed successfully"); | |
| 216 | - | }, | |
| 217 | - | error: function(xhr, status, error) { | |
| 218 | - | console.error("Error refreshing container list:", error); | |
| 219 | - | console.error("Status:", status); | |
| 220 | - | console.error("Response:", xhr.responseText); | |
| 221 | - | // Restore original content on error | |
| 222 | - | containerManager.html(originalContent); | |
| 223 | - | } | |
| 224 | - | }); | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // Function to refresh image list without page reload | |
| 228 | - | function refreshImageList() { | |
| 229 | - | console.debug("Refreshing image list"); | |
| 230 | - | ||
| 231 | - | // Show loading indicator | |
| 232 | - | const imageManager = $('#image-manager'); | |
| 233 | - | if (imageManager.length === 0) { | |
| 234 | - | console.error("Image manager element not found in DOM"); | |
| 235 | - | return; | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | console.debug("Image manager element found:", imageManager[0]); | |
| 239 | - | console.debug("Image manager classes:", imageManager.attr('class')); | |
| 240 | - | ||
| 241 | - | const originalContent = imageManager.html(); | |
| 242 | - | imageManager.html('<div class="text-center p-4"><span class="iconf text-3xl animate-spin inline-block"></span> Loading images...</div>'); | |
| 243 | - | ||
| 244 | - | $.ajax({ | |
| 245 | - | url: '/htmx/image-manager', | |
| 246 | - | type: 'GET', | |
| 247 | - | success: function(data) { | |
| 248 | - | console.debug("Image data received, length:", data.length); | |
| 249 | - | $('#image-manager').html(data); | |
| 250 | - | console.debug("Image list refreshed successfully"); | |
| 251 | - | }, | |
| 252 | - | error: function(xhr, status, error) { | |
| 253 | - | console.error("Error refreshing image list:", error); | |
| 254 | - | console.error("Status:", status); | |
| 255 | - | console.error("Response:", xhr.responseText); | |
| 256 | - | // Restore original content on error | |
| 257 | - | imageManager.html(originalContent); | |
| 258 | - | } | |
| 259 | - | }); | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | // Setup HTMX event listeners to avoid page reloads | |
| 263 | - | function setupHtmxEventListeners() { | |
| 264 | - | // Listen for container actions (start, stop, edit, delete) | |
| 265 | - | document.body.addEventListener('htmx:afterOnLoad', function(event) { | |
| 266 | - | const targetId = event.detail.target.id; | |
| 267 | - | console.debug(`HTMX afterOnLoad event for target: ${targetId}`); | |
| 268 | - | ||
| 269 | - | // Remove the tab visibility fix which was interfering with DaisyUI | |
| 270 | - | // DaisyUI handles tab visibility automatically with the input[type="radio"] approach | |
| 271 | - | ||
| 272 | - | // Check if the target is an action response | |
| 273 | - | if (targetId && targetId.startsWith('actionResponse-')) { | |
| 274 | - | console.debug(`Action completed for ${targetId}`); | |
| 275 | - | ||
| 276 | - | // Show notification instead of reloading | |
| 277 | - | const responseText = $(event.detail.target).text().trim(); | |
| 278 | - | if (responseText.includes('Success')) { | |
| 279 | - | showNotification('Action completed successfully', 'success'); | |
| 280 | - | } else if (responseText) { | |
| 281 | - | showNotification(responseText, 'info'); | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | // Refresh container list after a short delay | |
| 285 | - | setTimeout(refreshContainerList, 500); | |
| 286 | - | } | |
| 287 | - | ||
| 288 | - | // Check if the target is the image manager (after upload) | |
| 289 | - | if (targetId === 'image-manager') { | |
| 290 | - | console.debug('Image manager updated'); | |
| 291 | - | ||
| 292 | - | // Show notification for successful upload | |
| 293 | - | if (event.detail.xhr && event.detail.xhr.responseText && event.detail.xhr.responseText.includes('Success')) { | |
| 294 | - | showNotification('Image uploaded successfully', 'success'); | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | // Hide the upload form after successful upload | |
| 298 | - | $('#upload-image').addClass('hidden'); | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | // Check if this is a container creation form response | |
| 302 | - | if (targetId && targetId.includes('create-container-')) { | |
| 303 | - | console.debug(`Container creation form response for ${targetId}`); | |
| 304 | - | const responseText = $(event.detail.target).text().trim(); | |
| 305 | - | ||
| 306 | - | // If the response contains success message | |
| 307 | - | if (responseText.includes('Success') || responseText.includes('Container created successfully')) { | |
| 308 | - | showNotification('Container created successfully', 'success'); | |
| 309 | - | ||
| 310 | - | // Hide the container creation form | |
| 311 | - | $(event.detail.target).closest('.create-container').addClass('hidden'); | |
| 312 | - | ||
| 313 | - | // Refresh container list to show the new container | |
| 314 | - | setTimeout(refreshContainerList, 500); | |
| 315 | - | } | |
| 316 | - | } | |
| 317 | - | }); | |
| 318 | - | ||
| 319 | - | // Listen for form submissions | |
| 320 | - | $(document).on('submit', 'form[hx-post^="/htmx/create-container/"]', function(event) { | |
| 321 | - | console.debug('Container creation form submitted'); | |
| 322 | - | ||
| 323 | - | // Disable the submit button to prevent double submission | |
| 324 | - | $('#create-container-submit').prop('disabled', true); | |
| 325 | - | ||
| 326 | - | // We don't prevent default because HTMX needs to handle the form submission | |
| 327 | - | }); | |
| 328 | - | ||
| 329 | - | // Listen for container creation | |
| 330 | - | $(document).on('htmx:afterRequest', function(event) { | |
| 331 | - | const path = event.detail.pathInfo.requestPath; | |
| 332 | - | console.debug(`HTMX afterRequest for path: ${path}`); | |
| 333 | - | ||
| 334 | - | // If this was a container creation request | |
| 335 | - | if (path && path.includes('/htmx/create-container/')) { | |
| 336 | - | console.debug('Container creation request completed'); | |
| 337 | - | ||
| 338 | - | // Re-enable the submit button | |
| 339 | - | $('#create-container-submit').prop('disabled', false); | |
| 340 | - | ||
| 341 | - | // Check if the response indicates success | |
| 342 | - | const responseText = event.detail.xhr.responseText; | |
| 343 | - | if (responseText && (responseText.includes('Success') || responseText.includes('Container created successfully'))) { | |
| 344 | - | showNotification('Container created successfully', 'success'); | |
| 345 | - | ||
| 346 | - | // Refresh container list after creation | |
| 347 | - | setTimeout(refreshContainerList, 500); | |
| 348 | - | } else if (responseText && responseText.includes('Error')) { | |
| 349 | - | showNotification('Error creating container: ' + responseText, 'error'); | |
| 350 | - | } | |
| 351 | - | } | |
| 352 | - | ||
| 353 | - | // If this was an image deletion request | |
| 354 | - | if (path && path.includes('/htmx/image-manager/delete/')) { | |
| 355 | - | console.debug('Image deletion request completed'); | |
| 356 | - | ||
| 357 | - | // Show notification | |
| 358 | - | showNotification('Image deleted successfully', 'success'); | |
| 359 | - | ||
| 360 | - | // Refresh image list after deletion | |
| 361 | - | setTimeout(refreshImageList, 500); | |
| 362 | - | } | |
| 363 | - | }); | |
| 364 | - | ||
| 365 | - | // Handle errors | |
| 366 | - | $(document).on('htmx:responseError', function(event) { | |
| 367 | - | console.error('HTMX response error:', event.detail.error); | |
| 368 | - | showNotification('Error: ' + (event.detail.error || 'Unknown error occurred'), 'error'); | |
| 369 | - | }); | |
| 370 | - | ||
| 371 | - | // Add HTMX event listeners for debugging | |
| 372 | - | document.addEventListener('htmx:beforeRequest', function(event) { | |
| 373 | - | console.debug('HTMX before request:', event.detail); | |
| 374 | - | }); | |
| 375 | - | ||
| 376 | - | document.addEventListener('htmx:afterRequest', function(event) { | |
| 377 | - | console.debug('HTMX after request:', event.detail); | |
| 378 | - | }); | |
| 379 | - | ||
| 380 | - | document.addEventListener('htmx:responseError', function(event) { | |
| 381 | - | console.error('HTMX response error:', event.detail); | |
| 382 | - | }); | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | document.addEventListener("DOMContentLoaded", function() { | |
| 386 | - | console.debug('DOM fully loaded'); | |
| 387 | - | ||
| 388 | - | // Check if jQuery is available | |
| 389 | - | if (typeof $ === 'undefined') { | |
| 390 | - | console.error('jQuery is not loaded! Some features may not work properly.'); | |
| 391 | - | } else { | |
| 392 | - | console.debug('jQuery is loaded successfully'); | |
| 393 | - | } | |
| 394 | - | ||
| 395 | - | // Run version check (which also checks if we're on an admin page) | |
| 396 | - | const isAdminPage = initializeVersionCheck(); | |
| 397 | - | ||
| 398 | - | // Only initialize admin dashboard functions if jQuery is loaded AND we are on an admin page | |
| 399 | - | if (typeof $ !== 'undefined' && isAdminPage) { | |
| 400 | - | initializeAdminDashboard(); | |
| 401 | - | } | |
| 402 | - | }); | |
D internal/webui/public/favicon-16x16.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/favicon-32x32.png +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/favicon.ico +0 -0 binary file: no text diff
binary file: no text diff
D internal/webui/public/libs/chart.js +0 -20 too large to show in the browser
too large to show in the browser
D internal/webui/public/libs/daisyui.js +0 -8 too large to show in the browser
too large to show in the browser
D internal/webui/public/libs/htmx-debug.js +0 -11
| @@ -1,11 +0,0 @@ | |||
| 1 | - | htmx.defineExtension('debug', { | |
| 2 | - | onEvent: function (name, evt) { | |
| 3 | - | if (console.debug) { | |
| 4 | - | console.debug(name, evt); | |
| 5 | - | } else if (console) { | |
| 6 | - | console.log("DEBUG:", name, evt); | |
| 7 | - | } else { | |
| 8 | - | throw "NO CONSOLE SUPPORTED" | |
| 9 | - | } | |
| 10 | - | } | |
| 11 | - | }); | |
D internal/webui/public/libs/htmx-response-targets.js +0 -135
| @@ -1,135 +0,0 @@ | |||
| 1 | - | (function(){ | |
| 2 | - | ||
| 3 | - | if (htmx.version && !htmx.version.startsWith("1.")) { | |
| 4 | - | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + | |
| 5 | - | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") | |
| 6 | - | } | |
| 7 | - | ||
| 8 | - | /** @type {import("../htmx").HtmxInternalApi} */ | |
| 9 | - | var api; | |
| 10 | - | ||
| 11 | - | var attrPrefix = 'hx-target-'; | |
| 12 | - | ||
| 13 | - | // IE11 doesn't support string.startsWith | |
| 14 | - | function startsWith(str, prefix) { | |
| 15 | - | return str.substring(0, prefix.length) === prefix | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | /** | |
| 19 | - | * @param {HTMLElement} elt | |
| 20 | - | * @param {number} respCode | |
| 21 | - | * @returns {HTMLElement | null} | |
| 22 | - | */ | |
| 23 | - | function getRespCodeTarget(elt, respCodeNumber) { | |
| 24 | - | if (!elt || !respCodeNumber) return null; | |
| 25 | - | ||
| 26 | - | var respCode = respCodeNumber.toString(); | |
| 27 | - | ||
| 28 | - | // '*' is the original syntax, as the obvious character for a wildcard. | |
| 29 | - | // The 'x' alternative was added for maximum compatibility with HTML | |
| 30 | - | // templating engines, due to ambiguity around which characters are | |
| 31 | - | // supported in HTML attributes. | |
| 32 | - | // | |
| 33 | - | // Start with the most specific possible attribute and generalize from | |
| 34 | - | // there. | |
| 35 | - | var attrPossibilities = [ | |
| 36 | - | respCode, | |
| 37 | - | ||
| 38 | - | respCode.substr(0, 2) + '*', | |
| 39 | - | respCode.substr(0, 2) + 'x', | |
| 40 | - | ||
| 41 | - | respCode.substr(0, 1) + '*', | |
| 42 | - | respCode.substr(0, 1) + 'x', | |
| 43 | - | respCode.substr(0, 1) + '**', | |
| 44 | - | respCode.substr(0, 1) + 'xx', | |
| 45 | - | ||
| 46 | - | '*', | |
| 47 | - | 'x', | |
| 48 | - | '***', | |
| 49 | - | 'xxx', | |
| 50 | - | ]; | |
| 51 | - | if (startsWith(respCode, '4') || startsWith(respCode, '5')) { | |
| 52 | - | attrPossibilities.push('error'); | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | for (var i = 0; i < attrPossibilities.length; i++) { | |
| 56 | - | var attr = attrPrefix + attrPossibilities[i]; | |
| 57 | - | var attrValue = api.getClosestAttributeValue(elt, attr); | |
| 58 | - | if (attrValue) { | |
| 59 | - | if (attrValue === "this") { | |
| 60 | - | return api.findThisElement(elt, attr); | |
| 61 | - | } else { | |
| 62 | - | return api.querySelectorExt(elt, attrValue); | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | } | |
| 66 | - | ||
| 67 | - | return null; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | /** @param {Event} evt */ | |
| 71 | - | function handleErrorFlag(evt) { | |
| 72 | - | if (evt.detail.isError) { | |
| 73 | - | if (htmx.config.responseTargetUnsetsError) { | |
| 74 | - | evt.detail.isError = false; | |
| 75 | - | } | |
| 76 | - | } else if (htmx.config.responseTargetSetsError) { | |
| 77 | - | evt.detail.isError = true; | |
| 78 | - | } | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | htmx.defineExtension('response-targets', { | |
| 82 | - | ||
| 83 | - | /** @param {import("../htmx").HtmxInternalApi} apiRef */ | |
| 84 | - | init: function (apiRef) { | |
| 85 | - | api = apiRef; | |
| 86 | - | ||
| 87 | - | if (htmx.config.responseTargetUnsetsError === undefined) { | |
| 88 | - | htmx.config.responseTargetUnsetsError = true; | |
| 89 | - | } | |
| 90 | - | if (htmx.config.responseTargetSetsError === undefined) { | |
| 91 | - | htmx.config.responseTargetSetsError = false; | |
| 92 | - | } | |
| 93 | - | if (htmx.config.responseTargetPrefersExisting === undefined) { | |
| 94 | - | htmx.config.responseTargetPrefersExisting = false; | |
| 95 | - | } | |
| 96 | - | if (htmx.config.responseTargetPrefersRetargetHeader === undefined) { | |
| 97 | - | htmx.config.responseTargetPrefersRetargetHeader = true; | |
| 98 | - | } | |
| 99 | - | }, | |
| 100 | - | ||
| 101 | - | /** | |
| 102 | - | * @param {string} name | |
| 103 | - | * @param {Event} evt | |
| 104 | - | */ | |
| 105 | - | onEvent: function (name, evt) { | |
| 106 | - | if (name === "htmx:beforeSwap" && | |
| 107 | - | evt.detail.xhr && | |
| 108 | - | evt.detail.xhr.status !== 200) { | |
| 109 | - | if (evt.detail.target) { | |
| 110 | - | if (htmx.config.responseTargetPrefersExisting) { | |
| 111 | - | evt.detail.shouldSwap = true; | |
| 112 | - | handleErrorFlag(evt); | |
| 113 | - | return true; | |
| 114 | - | } | |
| 115 | - | if (htmx.config.responseTargetPrefersRetargetHeader && | |
| 116 | - | evt.detail.xhr.getAllResponseHeaders().match(/HX-Retarget:/i)) { | |
| 117 | - | evt.detail.shouldSwap = true; | |
| 118 | - | handleErrorFlag(evt); | |
| 119 | - | return true; | |
| 120 | - | } | |
| 121 | - | } | |
| 122 | - | if (!evt.detail.requestConfig) { | |
| 123 | - | return true; | |
| 124 | - | } | |
| 125 | - | var target = getRespCodeTarget(evt.detail.requestConfig.elt, evt.detail.xhr.status); | |
| 126 | - | if (target) { | |
| 127 | - | handleErrorFlag(evt); | |
| 128 | - | evt.detail.shouldSwap = true; | |
| 129 | - | evt.detail.target = target; | |
| 130 | - | } | |
| 131 | - | return true; | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | }); | |
| 135 | - | })(); | |
D internal/webui/public/libs/htmx-sse.js +0 -374
| @@ -1,374 +0,0 @@ | |||
| 1 | - | /* | |
| 2 | - | Server Sent Events Extension | |
| 3 | - | ============================ | |
| 4 | - | This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions. | |
| 5 | - | ||
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | (function() { | |
| 9 | - | ||
| 10 | - | if (htmx.version && !htmx.version.startsWith("1.")) { | |
| 11 | - | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + | |
| 12 | - | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") | |
| 13 | - | } | |
| 14 | - | ||
| 15 | - | /** @type {import("../htmx").HtmxInternalApi} */ | |
| 16 | - | var api; | |
| 17 | - | ||
| 18 | - | htmx.defineExtension("sse", { | |
| 19 | - | ||
| 20 | - | /** | |
| 21 | - | * Init saves the provided reference to the internal HTMX API. | |
| 22 | - | * | |
| 23 | - | * @param {import("../htmx").HtmxInternalApi} api | |
| 24 | - | * @returns void | |
| 25 | - | */ | |
| 26 | - | init: function(apiRef) { | |
| 27 | - | // store a reference to the internal API. | |
| 28 | - | api = apiRef; | |
| 29 | - | ||
| 30 | - | // set a function in the public API for creating new EventSource objects | |
| 31 | - | if (htmx.createEventSource == undefined) { | |
| 32 | - | htmx.createEventSource = createEventSource; | |
| 33 | - | } | |
| 34 | - | }, | |
| 35 | - | ||
| 36 | - | /** | |
| 37 | - | * onEvent handles all events passed to this extension. | |
| 38 | - | * | |
| 39 | - | * @param {string} name | |
| 40 | - | * @param {Event} evt | |
| 41 | - | * @returns void | |
| 42 | - | */ | |
| 43 | - | onEvent: function(name, evt) { | |
| 44 | - | ||
| 45 | - | var parent = evt.target || evt.detail.elt; | |
| 46 | - | switch (name) { | |
| 47 | - | ||
| 48 | - | case "htmx:beforeCleanupElement": | |
| 49 | - | var internalData = api.getInternalData(parent) | |
| 50 | - | // Try to remove remove an EventSource when elements are removed | |
| 51 | - | if (internalData.sseEventSource) { | |
| 52 | - | internalData.sseEventSource.close(); | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | return; | |
| 56 | - | ||
| 57 | - | // Try to create EventSources when elements are processed | |
| 58 | - | case "htmx:afterProcessNode": | |
| 59 | - | ensureEventSourceOnElement(parent); | |
| 60 | - | } | |
| 61 | - | } | |
| 62 | - | }); | |
| 63 | - | ||
| 64 | - | /////////////////////////////////////////////// | |
| 65 | - | // HELPER FUNCTIONS | |
| 66 | - | /////////////////////////////////////////////// | |
| 67 | - | ||
| 68 | - | ||
| 69 | - | /** | |
| 70 | - | * createEventSource is the default method for creating new EventSource objects. | |
| 71 | - | * it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed. | |
| 72 | - | * | |
| 73 | - | * @param {string} url | |
| 74 | - | * @returns EventSource | |
| 75 | - | */ | |
| 76 | - | function createEventSource(url) { | |
| 77 | - | return new EventSource(url, { withCredentials: true }); | |
| 78 | - | } | |
| 79 | - | ||
| 80 | - | function splitOnWhitespace(trigger) { | |
| 81 | - | return trigger.trim().split(/\s+/); | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | function getLegacySSEURL(elt) { | |
| 85 | - | var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); | |
| 86 | - | if (legacySSEValue) { | |
| 87 | - | var values = splitOnWhitespace(legacySSEValue); | |
| 88 | - | for (var i = 0; i < values.length; i++) { | |
| 89 | - | var value = values[i].split(/:(.+)/); | |
| 90 | - | if (value[0] === "connect") { | |
| 91 | - | return value[1]; | |
| 92 | - | } | |
| 93 | - | } | |
| 94 | - | } | |
| 95 | - | } | |
| 96 | - | ||
| 97 | - | function getLegacySSESwaps(elt) { | |
| 98 | - | var legacySSEValue = api.getAttributeValue(elt, "hx-sse"); | |
| 99 | - | var returnArr = []; | |
| 100 | - | if (legacySSEValue != null) { | |
| 101 | - | var values = splitOnWhitespace(legacySSEValue); | |
| 102 | - | for (var i = 0; i < values.length; i++) { | |
| 103 | - | var value = values[i].split(/:(.+)/); | |
| 104 | - | if (value[0] === "swap") { | |
| 105 | - | returnArr.push(value[1]); | |
| 106 | - | } | |
| 107 | - | } | |
| 108 | - | } | |
| 109 | - | return returnArr; | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | /** | |
| 113 | - | * registerSSE looks for attributes that can contain sse events, right | |
| 114 | - | * now hx-trigger and sse-swap and adds listeners based on these attributes too | |
| 115 | - | * the closest event source | |
| 116 | - | * | |
| 117 | - | * @param {HTMLElement} elt | |
| 118 | - | */ | |
| 119 | - | function registerSSE(elt) { | |
| 120 | - | // Add message handlers for every `sse-swap` attribute | |
| 121 | - | queryAttributeOnThisOrChildren(elt, "sse-swap").forEach(function (child) { | |
| 122 | - | // Find closest existing event source | |
| 123 | - | var sourceElement = api.getClosestMatch(child, hasEventSource); | |
| 124 | - | if (sourceElement == null) { | |
| 125 | - | // api.triggerErrorEvent(elt, "htmx:noSSESourceError") | |
| 126 | - | return null; // no eventsource in parentage, orphaned element | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | // Set internalData and source | |
| 130 | - | var internalData = api.getInternalData(sourceElement); | |
| 131 | - | var source = internalData.sseEventSource; | |
| 132 | - | ||
| 133 | - | var sseSwapAttr = api.getAttributeValue(child, "sse-swap"); | |
| 134 | - | if (sseSwapAttr) { | |
| 135 | - | var sseEventNames = sseSwapAttr.split(","); | |
| 136 | - | } else { | |
| 137 | - | var sseEventNames = getLegacySSESwaps(child); | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | for (var i = 0; i < sseEventNames.length; i++) { | |
| 141 | - | var sseEventName = sseEventNames[i].trim(); | |
| 142 | - | var listener = function(event) { | |
| 143 | - | ||
| 144 | - | // If the source is missing then close SSE | |
| 145 | - | if (maybeCloseSSESource(sourceElement)) { | |
| 146 | - | return; | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | // If the body no longer contains the element, remove the listener | |
| 150 | - | if (!api.bodyContains(child)) { | |
| 151 | - | source.removeEventListener(sseEventName, listener); | |
| 152 | - | return; | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // swap the response into the DOM and trigger a notification | |
| 156 | - | if(!api.triggerEvent(elt, "htmx:sseBeforeMessage", event)) { | |
| 157 | - | return; | |
| 158 | - | } | |
| 159 | - | swap(child, event.data); | |
| 160 | - | api.triggerEvent(elt, "htmx:sseMessage", event); | |
| 161 | - | }; | |
| 162 | - | ||
| 163 | - | // Register the new listener | |
| 164 | - | api.getInternalData(child).sseEventListener = listener; | |
| 165 | - | source.addEventListener(sseEventName, listener); | |
| 166 | - | } | |
| 167 | - | }); | |
| 168 | - | ||
| 169 | - | // Add message handlers for every `hx-trigger="sse:*"` attribute | |
| 170 | - | queryAttributeOnThisOrChildren(elt, "hx-trigger").forEach(function(child) { | |
| 171 | - | // Find closest existing event source | |
| 172 | - | var sourceElement = api.getClosestMatch(child, hasEventSource); | |
| 173 | - | if (sourceElement == null) { | |
| 174 | - | // api.triggerErrorEvent(elt, "htmx:noSSESourceError") | |
| 175 | - | return null; // no eventsource in parentage, orphaned element | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | // Set internalData and source | |
| 179 | - | var internalData = api.getInternalData(sourceElement); | |
| 180 | - | var source = internalData.sseEventSource; | |
| 181 | - | ||
| 182 | - | var sseEventName = api.getAttributeValue(child, "hx-trigger"); | |
| 183 | - | if (sseEventName == null) { | |
| 184 | - | return; | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | // Only process hx-triggers for events with the "sse:" prefix | |
| 188 | - | if (sseEventName.slice(0, 4) != "sse:") { | |
| 189 | - | return; | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // remove the sse: prefix from here on out | |
| 193 | - | sseEventName = sseEventName.substr(4); | |
| 194 | - | ||
| 195 | - | var listener = function() { | |
| 196 | - | if (maybeCloseSSESource(sourceElement)) { | |
| 197 | - | return | |
| 198 | - | } | |
| 199 | - | ||
| 200 | - | if (!api.bodyContains(child)) { | |
| 201 | - | source.removeEventListener(sseEventName, listener); | |
| 202 | - | } | |
| 203 | - | } | |
| 204 | - | }); | |
| 205 | - | } | |
| 206 | - | ||
| 207 | - | /** | |
| 208 | - | * ensureEventSourceOnElement creates a new EventSource connection on the provided element. | |
| 209 | - | * If a usable EventSource already exists, then it is returned. If not, then a new EventSource | |
| 210 | - | * is created and stored in the element's internalData. | |
| 211 | - | * @param {HTMLElement} elt | |
| 212 | - | * @param {number} retryCount | |
| 213 | - | * @returns {EventSource | null} | |
| 214 | - | */ | |
| 215 | - | function ensureEventSourceOnElement(elt, retryCount) { | |
| 216 | - | ||
| 217 | - | if (elt == null) { | |
| 218 | - | return null; | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | // handle extension source creation attribute | |
| 222 | - | queryAttributeOnThisOrChildren(elt, "sse-connect").forEach(function(child) { | |
| 223 | - | var sseURL = api.getAttributeValue(child, "sse-connect"); | |
| 224 | - | if (sseURL == null) { | |
| 225 | - | return; | |
| 226 | - | } | |
| 227 | - | ||
| 228 | - | ensureEventSource(child, sseURL, retryCount); | |
| 229 | - | }); | |
| 230 | - | ||
| 231 | - | // handle legacy sse, remove for HTMX2 | |
| 232 | - | queryAttributeOnThisOrChildren(elt, "hx-sse").forEach(function(child) { | |
| 233 | - | var sseURL = getLegacySSEURL(child); | |
| 234 | - | if (sseURL == null) { | |
| 235 | - | return; | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | ensureEventSource(child, sseURL, retryCount); | |
| 239 | - | }); | |
| 240 | - | ||
| 241 | - | registerSSE(elt); | |
| 242 | - | } | |
| 243 | - | ||
| 244 | - | function ensureEventSource(elt, url, retryCount) { | |
| 245 | - | var source = htmx.createEventSource(url); | |
| 246 | - | ||
| 247 | - | source.onerror = function(err) { | |
| 248 | - | ||
| 249 | - | // Log an error event | |
| 250 | - | api.triggerErrorEvent(elt, "htmx:sseError", { error: err, source: source }); | |
| 251 | - | ||
| 252 | - | // If parent no longer exists in the document, then clean up this EventSource | |
| 253 | - | if (maybeCloseSSESource(elt)) { | |
| 254 | - | return; | |
| 255 | - | } | |
| 256 | - | ||
| 257 | - | // Otherwise, try to reconnect the EventSource | |
| 258 | - | if (source.readyState === EventSource.CLOSED) { | |
| 259 | - | retryCount = retryCount || 0; | |
| 260 | - | var timeout = Math.random() * (2 ^ retryCount) * 500; | |
| 261 | - | window.setTimeout(function() { | |
| 262 | - | ensureEventSourceOnElement(elt, Math.min(7, retryCount + 1)); | |
| 263 | - | }, timeout); | |
| 264 | - | } | |
| 265 | - | }; | |
| 266 | - | ||
| 267 | - | source.onopen = function(evt) { | |
| 268 | - | api.triggerEvent(elt, "htmx:sseOpen", { source: source }); | |
| 269 | - | } | |
| 270 | - | ||
| 271 | - | api.getInternalData(elt).sseEventSource = source; | |
| 272 | - | } | |
| 273 | - | ||
| 274 | - | /** | |
| 275 | - | * maybeCloseSSESource confirms that the parent element still exists. | |
| 276 | - | * If not, then any associated SSE source is closed and the function returns true. | |
| 277 | - | * | |
| 278 | - | * @param {HTMLElement} elt | |
| 279 | - | * @returns boolean | |
| 280 | - | */ | |
| 281 | - | function maybeCloseSSESource(elt) { | |
| 282 | - | if (!api.bodyContains(elt)) { | |
| 283 | - | var source = api.getInternalData(elt).sseEventSource; | |
| 284 | - | if (source != undefined) { | |
| 285 | - | source.close(); | |
| 286 | - | // source = null | |
| 287 | - | return true; | |
| 288 | - | } | |
| 289 | - | } | |
| 290 | - | return false; | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | /** | |
| 294 | - | * queryAttributeOnThisOrChildren returns all nodes that contain the requested attributeName, INCLUDING THE PROVIDED ROOT ELEMENT. | |
| 295 | - | * | |
| 296 | - | * @param {HTMLElement} elt | |
| 297 | - | * @param {string} attributeName | |
| 298 | - | */ | |
| 299 | - | function queryAttributeOnThisOrChildren(elt, attributeName) { | |
| 300 | - | ||
| 301 | - | var result = []; | |
| 302 | - | ||
| 303 | - | // If the parent element also contains the requested attribute, then add it to the results too. | |
| 304 | - | if (api.hasAttribute(elt, attributeName)) { | |
| 305 | - | result.push(elt); | |
| 306 | - | } | |
| 307 | - | ||
| 308 | - | // Search all child nodes that match the requested attribute | |
| 309 | - | elt.querySelectorAll("[" + attributeName + "], [data-" + attributeName + "]").forEach(function(node) { | |
| 310 | - | result.push(node); | |
| 311 | - | }); | |
| 312 | - | ||
| 313 | - | return result; | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | /** | |
| 317 | - | * @param {HTMLElement} elt | |
| 318 | - | * @param {string} content | |
| 319 | - | */ | |
| 320 | - | function swap(elt, content) { | |
| 321 | - | ||
| 322 | - | api.withExtensions(elt, function(extension) { | |
| 323 | - | content = extension.transformResponse(content, null, elt); | |
| 324 | - | }); | |
| 325 | - | ||
| 326 | - | var swapSpec = api.getSwapSpecification(elt); | |
| 327 | - | var target = api.getTarget(elt); | |
| 328 | - | var settleInfo = api.makeSettleInfo(elt); | |
| 329 | - | ||
| 330 | - | api.selectAndSwap(swapSpec.swapStyle, target, elt, content, settleInfo); | |
| 331 | - | ||
| 332 | - | settleInfo.elts.forEach(function(elt) { | |
| 333 | - | if (elt.classList) { | |
| 334 | - | elt.classList.add(htmx.config.settlingClass); | |
| 335 | - | } | |
| 336 | - | api.triggerEvent(elt, 'htmx:beforeSettle'); | |
| 337 | - | }); | |
| 338 | - | ||
| 339 | - | // Handle settle tasks (with delay if requested) | |
| 340 | - | if (swapSpec.settleDelay > 0) { | |
| 341 | - | setTimeout(doSettle(settleInfo), swapSpec.settleDelay); | |
| 342 | - | } else { | |
| 343 | - | doSettle(settleInfo)(); | |
| 344 | - | } | |
| 345 | - | } | |
| 346 | - | ||
| 347 | - | /** | |
| 348 | - | * doSettle mirrors much of the functionality in htmx that | |
| 349 | - | * settles elements after their content has been swapped. | |
| 350 | - | * TODO: this should be published by htmx, and not duplicated here | |
| 351 | - | * @param {import("../htmx").HtmxSettleInfo} settleInfo | |
| 352 | - | * @returns () => void | |
| 353 | - | */ | |
| 354 | - | function doSettle(settleInfo) { | |
| 355 | - | ||
| 356 | - | return function() { | |
| 357 | - | settleInfo.tasks.forEach(function(task) { | |
| 358 | - | task.call(); | |
| 359 | - | }); | |
| 360 | - | ||
| 361 | - | settleInfo.elts.forEach(function(elt) { | |
| 362 | - | if (elt.classList) { | |
| 363 | - | elt.classList.remove(htmx.config.settlingClass); | |
| 364 | - | } | |
| 365 | - | api.triggerEvent(elt, 'htmx:afterSettle'); | |
| 366 | - | }); | |
| 367 | - | } | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | function hasEventSource(node) { | |
| 371 | - | return api.getInternalData(node).sseEventSource != null; | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | })(); | |
D internal/webui/public/libs/htmx.min.js +0 -1 generated file: collapsed
D internal/webui/public/libs/hyperscript.min.js +0 -1 generated file: collapsed
D internal/webui/public/libs/jquery.min.js +0 -2 generated file: collapsed
D internal/webui/public/site.webmanifest +0 -1
| @@ -1,1 +0,0 @@ | |||
| 1 | - | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}⏎̸ | |
M main.go +6 -8
| @@ -1,15 +1,13 @@ | |||
| 1 | 1 | package main | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "github.com/bnema/gordon/cmd" | |
| 5 | - | ) | |
| 4 | + | "os" | |
| 6 | 5 | ||
| 7 | - | var ( | |
| 8 | - | version string | |
| 9 | - | commit string | |
| 10 | - | date string | |
| 6 | + | "gordon/cmd" | |
| 11 | 7 | ) | |
| 12 | 8 | ||
| 13 | 9 | func main() { | |
| 14 | - | cmd.ExecuteCLI(version, commit, date) | |
| 15 | - | } | |
| 10 | + | if err := cmd.Execute(); err != nil { | |
| 11 | + | os.Exit(1) | |
| 12 | + | } | |
| 13 | + | }⏎̸ | |
D node_modules/.bin/cssesc +0 -1 generated file: collapsed
D node_modules/.bin/glob +0 -1 generated file: collapsed
D node_modules/.bin/jiti +0 -1 generated file: collapsed
D node_modules/.bin/nanoid +0 -1 generated file: collapsed
D node_modules/.bin/node-which +0 -1 generated file: collapsed
D node_modules/.bin/prettier +0 -1 generated file: collapsed
D node_modules/.bin/resolve +0 -1 generated file: collapsed
D node_modules/.bin/sucrase +0 -1 generated file: collapsed
D node_modules/.bin/sucrase-node +0 -1 generated file: collapsed
D node_modules/.bin/tailwind +0 -1 generated file: collapsed
D node_modules/.bin/yaml +0 -1 generated file: collapsed
D node_modules/.svelte2tsx-language-server-files/svelte-native-jsx.d.ts +0 -32 generated file: collapsed
D node_modules/.svelte2tsx-language-server-files/svelte-shims-v4.d.ts +0 -287 generated file: collapsed
D node_modules/@alloc/quick-lru/index.d.ts +0 -128 generated file: collapsed
D node_modules/@alloc/quick-lru/index.js +0 -263 generated file: collapsed
D node_modules/@alloc/quick-lru/license +0 -9 generated file: collapsed
D node_modules/@alloc/quick-lru/package.json +0 -43 generated file: collapsed
D node_modules/@alloc/quick-lru/readme.md +0 -139 generated file: collapsed
D node_modules/@isaacs/cliui/README.md +0 -143 generated file: collapsed
D node_modules/@isaacs/cliui/build/index.cjs +0 -317 generated file: collapsed
D node_modules/@isaacs/cliui/build/index.d.cts +0 -43 generated file: collapsed
D node_modules/@isaacs/cliui/build/lib/index.js +0 -302 generated file: collapsed
D node_modules/@isaacs/cliui/index.mjs +0 -14 generated file: collapsed
D node_modules/@isaacs/cliui/package.json +0 -86 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/README.md +0 -227 generated file: collapsed
D node_modules/@jridgewell/gen-mapping/package.json +0 -76 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/README.md +0 -40 generated file: collapsed
D node_modules/@jridgewell/resolve-uri/package.json +0 -69 generated file: collapsed
D node_modules/@jridgewell/set-array/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/set-array/README.md +0 -37 generated file: collapsed
D node_modules/@jridgewell/set-array/package.json +0 -65 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/LICENSE +0 -21 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/README.md +0 -264 generated file: collapsed
D node_modules/@jridgewell/sourcemap-codec/package.json +0 -75 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/LICENSE +0 -19 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/README.md +0 -257 generated file: collapsed
D node_modules/@jridgewell/trace-mapping/package.json +0 -77 generated file: collapsed
D node_modules/@nodelib/fs.scandir/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.scandir/README.md +0 -171 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/adapters/fs.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/adapters/fs.js +0 -19 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/constants.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/constants.js +0 -17 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/index.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/index.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/async.d.ts +0 -7 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/async.js +0 -104 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/common.d.ts +0 -1 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/common.js +0 -13 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/sync.d.ts +0 -5 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/providers/sync.js +0 -54 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/settings.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/settings.js +0 -24 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/types/index.d.ts +0 -20 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/fs.d.ts +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/fs.js +0 -19 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/index.d.ts +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.scandir/out/utils/index.js +0 -5 generated file: collapsed
D node_modules/@nodelib/fs.scandir/package.json +0 -44 generated file: collapsed
D node_modules/@nodelib/fs.stat/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.stat/README.md +0 -126 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/adapters/fs.d.ts +0 -13 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/adapters/fs.js +0 -17 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/index.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/index.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/async.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/async.js +0 -36 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/sync.d.ts +0 -3 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/providers/sync.js +0 -23 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/settings.d.ts +0 -16 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/settings.js +0 -16 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/types/index.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.stat/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.stat/package.json +0 -37 generated file: collapsed
D node_modules/@nodelib/fs.walk/LICENSE +0 -21 generated file: collapsed
D node_modules/@nodelib/fs.walk/README.md +0 -215 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/index.d.ts +0 -14 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/index.js +0 -34 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/async.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/async.js +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/index.d.ts +0 -4 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/index.js +0 -9 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/stream.d.ts +0 -12 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/stream.js +0 -34 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/sync.d.ts +0 -10 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/providers/sync.js +0 -14 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/async.d.ts +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/async.js +0 -97 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/common.d.ts +0 -7 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/common.js +0 -31 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/reader.d.ts +0 -6 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/reader.js +0 -11 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/sync.d.ts +0 -15 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/readers/sync.js +0 -59 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/settings.d.ts +0 -30 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/settings.js +0 -26 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/types/index.d.ts +0 -8 generated file: collapsed
D node_modules/@nodelib/fs.walk/out/types/index.js +0 -2 generated file: collapsed
D node_modules/@nodelib/fs.walk/package.json +0 -44 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/LICENSE +0 -21 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/README.md +0 -1 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/package.json +0 -33 generated file: collapsed
D node_modules/@parcel/watcher-linux-x64-glibc/watcher.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/@parcel/watcher-linux-x64-musl/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/README.md +0 -1 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/package.json +0 -33 collapsed to keep the page light
D node_modules/@parcel/watcher-linux-x64-musl/watcher.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/@parcel/watcher/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@parcel/watcher/README.md +0 -135 collapsed to keep the page light
D node_modules/@parcel/watcher/binding.gyp +0 -93 collapsed to keep the page light
D node_modules/@parcel/watcher/index.d.ts +0 -49 collapsed to keep the page light
D node_modules/@parcel/watcher/index.js +0 -41 collapsed to keep the page light
D node_modules/@parcel/watcher/index.js.flow +0 -48 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/.bin/detect-libc +0 -1 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/.npmignore +0 -7 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/LICENSE +0 -201 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/README.md +0 -78 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/bin/detect-libc.js +0 -18 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/lib/detect-libc.js +0 -92 collapsed to keep the page light
D node_modules/@parcel/watcher/node_modules/detect-libc/package.json +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/package.json +0 -88 collapsed to keep the page light
D node_modules/@parcel/watcher/scripts/build-from-source.js +0 -13 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Backend.cc +0 -182 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Backend.hh +0 -37 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Debounce.cc +0 -113 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Debounce.hh +0 -49 collapsed to keep the page light
D node_modules/@parcel/watcher/src/DirTree.cc +0 -152 collapsed to keep the page light
D node_modules/@parcel/watcher/src/DirTree.hh +0 -50 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Event.hh +0 -109 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Glob.cc +0 -22 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Glob.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/PromiseRunner.hh +0 -101 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Signal.hh +0 -46 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Watcher.cc +0 -237 collapsed to keep the page light
D node_modules/@parcel/watcher/src/Watcher.hh +0 -73 collapsed to keep the page light
D node_modules/@parcel/watcher/src/binding.cc +0 -268 collapsed to keep the page light
D node_modules/@parcel/watcher/src/kqueue/KqueueBackend.cc +0 -306 collapsed to keep the page light
D node_modules/@parcel/watcher/src/kqueue/KqueueBackend.hh +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/src/linux/InotifyBackend.cc +0 -232 collapsed to keep the page light
D node_modules/@parcel/watcher/src/linux/InotifyBackend.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/macos/FSEventsBackend.cc +0 -338 collapsed to keep the page light
D node_modules/@parcel/watcher/src/macos/FSEventsBackend.hh +0 -20 collapsed to keep the page light
D node_modules/@parcel/watcher/src/shared/BruteForceBackend.cc +0 -41 collapsed to keep the page light
D node_modules/@parcel/watcher/src/shared/BruteForceBackend.hh +0 -25 collapsed to keep the page light
D node_modules/@parcel/watcher/src/unix/fts.cc +0 -50 collapsed to keep the page light
D node_modules/@parcel/watcher/src/unix/legacy.cc +0 -77 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/WasmBackend.cc +0 -132 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/WasmBackend.hh +0 -34 collapsed to keep the page light
D node_modules/@parcel/watcher/src/wasm/include.h +0 -74 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/BSER.cc +0 -302 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/BSER.hh +0 -69 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/IPC.hh +0 -175 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/WatchmanBackend.cc +0 -338 collapsed to keep the page light
D node_modules/@parcel/watcher/src/watchman/WatchmanBackend.hh +0 -35 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/WindowsBackend.cc +0 -282 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/WindowsBackend.hh +0 -18 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/win_utils.cc +0 -44 collapsed to keep the page light
D node_modules/@parcel/watcher/src/windows/win_utils.hh +0 -11 collapsed to keep the page light
D node_modules/@parcel/watcher/wrapper.js +0 -77 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/.editorconfig +0 -14 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/CHANGELOG.md +0 -147 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/LICENSE +0 -201 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/README.md +0 -413 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/is-default-value.js +0 -25 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/limit-long-syntax.js +0 -35 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/negate.js +0 -43 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/no-repeated-options.js +0 -31 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/ordered-options.mjs +0 -41 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/examples/simple-hard-coded.js +0 -26 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/index.js +0 -396 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/errors.js +0 -47 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/primordials.js +0 -393 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/util.js +0 -14 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/internal/validators.js +0 -89 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/package.json +0 -36 collapsed to keep the page light
D node_modules/@pkgjs/parseargs/utils.js +0 -198 collapsed to keep the page light
D node_modules/@tailwindcss/cli/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/cli/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/cli/package.json +0 -41 collapsed to keep the page light
D node_modules/@tailwindcss/node/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/node/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/.bin/jiti +0 -1 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/README.md +0 -235 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-cli.mjs +0 -34 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-hooks.mjs +0 -117 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-native.mjs +0 -121 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-register.d.mts +0 -1 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti-register.mjs +0 -4 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.cjs +0 -23 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.d.cts +0 -8 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.d.mts +0 -8 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/jiti.mjs +0 -22 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/lib/types.d.ts +0 -260 collapsed to keep the page light
D node_modules/@tailwindcss/node/node_modules/jiti/package.json +0 -134 collapsed to keep the page light
D node_modules/@tailwindcss/node/package.json +0 -44 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/README.md +0 -3 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/package.json +0 -30 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-gnu/tailwindcss-oxide.linux-x64-gnu.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/@tailwindcss/oxide-linux-x64-musl/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/README.md +0 -3 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/package.json +0 -30 collapsed to keep the page light
D node_modules/@tailwindcss/oxide-linux-x64-musl/tailwindcss-oxide.linux-x64-musl.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/@tailwindcss/oxide/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/index.d.ts +0 -37 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/index.js +0 -315 collapsed to keep the page light
D node_modules/@tailwindcss/oxide/package.json +0 -63 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/LICENSE +0 -21 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/README.md +0 -40 collapsed to keep the page light
D node_modules/@tailwindcss/postcss/package.json +0 -47 collapsed to keep the page light
D node_modules/ansi-regex/index.d.ts +0 -33 collapsed to keep the page light
D node_modules/ansi-regex/index.js +0 -10 collapsed to keep the page light
D node_modules/ansi-regex/license +0 -9 collapsed to keep the page light
D node_modules/ansi-regex/package.json +0 -61 collapsed to keep the page light
D node_modules/ansi-regex/readme.md +0 -60 collapsed to keep the page light
D node_modules/ansi-styles/index.d.ts +0 -236 collapsed to keep the page light
D node_modules/ansi-styles/index.js +0 -223 collapsed to keep the page light
D node_modules/ansi-styles/license +0 -9 collapsed to keep the page light
D node_modules/ansi-styles/package.json +0 -54 collapsed to keep the page light
D node_modules/ansi-styles/readme.md +0 -173 collapsed to keep the page light
D node_modules/any-promise/.jshintrc +0 -4 collapsed to keep the page light
D node_modules/any-promise/.npmignore +0 -7 collapsed to keep the page light
D node_modules/any-promise/LICENSE +0 -19 collapsed to keep the page light
D node_modules/any-promise/README.md +0 -161 collapsed to keep the page light
D node_modules/any-promise/implementation.d.ts +0 -3 collapsed to keep the page light
D node_modules/any-promise/implementation.js +0 -1 collapsed to keep the page light
D node_modules/any-promise/index.d.ts +0 -73 collapsed to keep the page light
D node_modules/any-promise/index.js +0 -1 collapsed to keep the page light
D node_modules/any-promise/loader.js +0 -78 collapsed to keep the page light
D node_modules/any-promise/optional.js +0 -6 collapsed to keep the page light
D node_modules/any-promise/package.json +0 -45 collapsed to keep the page light
D node_modules/any-promise/register-shim.js +0 -18 collapsed to keep the page light
D node_modules/any-promise/register.d.ts +0 -17 collapsed to keep the page light
D node_modules/any-promise/register.js +0 -94 collapsed to keep the page light
D node_modules/any-promise/register/bluebird.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/bluebird.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/es6-promise.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/es6-promise.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/lie.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/lie.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/native-promise-only.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/native-promise-only.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/pinkie.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/pinkie.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/promise.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/promise.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/q.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/q.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/rsvp.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/rsvp.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/vow.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/vow.js +0 -2 collapsed to keep the page light
D node_modules/any-promise/register/when.d.ts +0 -1 collapsed to keep the page light
D node_modules/any-promise/register/when.js +0 -2 collapsed to keep the page light
D node_modules/anymatch/LICENSE +0 -15 collapsed to keep the page light
D node_modules/anymatch/README.md +0 -87 collapsed to keep the page light
D node_modules/anymatch/index.d.ts +0 -20 collapsed to keep the page light
D node_modules/anymatch/index.js +0 -104 collapsed to keep the page light
D node_modules/anymatch/package.json +0 -48 collapsed to keep the page light
D node_modules/arg/LICENSE.md +0 -21 collapsed to keep the page light
D node_modules/arg/README.md +0 -317 collapsed to keep the page light
D node_modules/arg/index.d.ts +0 -44 collapsed to keep the page light
D node_modules/arg/index.js +0 -195 collapsed to keep the page light
D node_modules/arg/package.json +0 -28 collapsed to keep the page light
D node_modules/balanced-match/.github/FUNDING.yml +0 -2 collapsed to keep the page light
D node_modules/balanced-match/LICENSE.md +0 -21 collapsed to keep the page light
D node_modules/balanced-match/README.md +0 -97 collapsed to keep the page light
D node_modules/balanced-match/index.js +0 -62 collapsed to keep the page light
D node_modules/balanced-match/package.json +0 -48 collapsed to keep the page light
D node_modules/binary-extensions/binary-extensions.json +0 -263 collapsed to keep the page light
D node_modules/binary-extensions/binary-extensions.json.d.ts +0 -3 collapsed to keep the page light
D node_modules/binary-extensions/index.d.ts +0 -14 collapsed to keep the page light
D node_modules/binary-extensions/index.js +0 -1 collapsed to keep the page light
D node_modules/binary-extensions/license +0 -10 collapsed to keep the page light
D node_modules/binary-extensions/package.json +0 -40 collapsed to keep the page light
D node_modules/binary-extensions/readme.md +0 -25 collapsed to keep the page light
D node_modules/brace-expansion/.github/FUNDING.yml +0 -2 collapsed to keep the page light
D node_modules/brace-expansion/LICENSE +0 -21 collapsed to keep the page light
D node_modules/brace-expansion/README.md +0 -135 collapsed to keep the page light
D node_modules/brace-expansion/index.js +0 -203 collapsed to keep the page light
D node_modules/brace-expansion/package.json +0 -46 collapsed to keep the page light
D node_modules/braces/LICENSE +0 -21 collapsed to keep the page light
D node_modules/braces/README.md +0 -586 collapsed to keep the page light
D node_modules/braces/index.js +0 -170 collapsed to keep the page light
D node_modules/braces/lib/compile.js +0 -60 collapsed to keep the page light
D node_modules/braces/lib/constants.js +0 -57 collapsed to keep the page light
D node_modules/braces/lib/expand.js +0 -113 collapsed to keep the page light
D node_modules/braces/lib/parse.js +0 -331 collapsed to keep the page light
D node_modules/braces/lib/stringify.js +0 -32 collapsed to keep the page light
D node_modules/braces/lib/utils.js +0 -122 collapsed to keep the page light
D node_modules/braces/package.json +0 -77 collapsed to keep the page light
D node_modules/camelcase-css/README.md +0 -27 collapsed to keep the page light
D node_modules/camelcase-css/index-es5.js +0 -24 collapsed to keep the page light
D node_modules/camelcase-css/index.js +0 -30 collapsed to keep the page light
D node_modules/camelcase-css/license +0 -21 collapsed to keep the page light
D node_modules/camelcase-css/package.json +0 -34 collapsed to keep the page light
D node_modules/caniuse-lite/LICENSE +0 -395 collapsed to keep the page light
D node_modules/caniuse-lite/README.md +0 -6 collapsed to keep the page light
D node_modules/caniuse-lite/package.json +0 -34 collapsed to keep the page light
D node_modules/chokidar/LICENSE +0 -21 collapsed to keep the page light
D node_modules/chokidar/README.md +0 -308 collapsed to keep the page light
D node_modules/chokidar/index.js +0 -973 collapsed to keep the page light
D node_modules/chokidar/lib/constants.js +0 -66 collapsed to keep the page light
D node_modules/chokidar/lib/fsevents-handler.js +0 -526 collapsed to keep the page light
D node_modules/chokidar/lib/nodefs-handler.js +0 -654 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/CHANGELOG.md +0 -110 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/README.md +0 -137 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/index.js +0 -42 collapsed to keep the page light
D node_modules/chokidar/node_modules/glob-parent/package.json +0 -48 collapsed to keep the page light
D node_modules/chokidar/package.json +0 -70 collapsed to keep the page light
D node_modules/chokidar/types/index.d.ts +0 -192 collapsed to keep the page light
D node_modules/color-convert/CHANGELOG.md +0 -54 collapsed to keep the page light
D node_modules/color-convert/LICENSE +0 -21 collapsed to keep the page light
D node_modules/color-convert/README.md +0 -68 collapsed to keep the page light
D node_modules/color-convert/conversions.js +0 -839 collapsed to keep the page light
D node_modules/color-convert/index.js +0 -81 collapsed to keep the page light
D node_modules/color-convert/package.json +0 -48 collapsed to keep the page light
D node_modules/color-convert/route.js +0 -97 collapsed to keep the page light
D node_modules/color-name/LICENSE +0 -8 collapsed to keep the page light
D node_modules/color-name/README.md +0 -11 collapsed to keep the page light
D node_modules/color-name/index.js +0 -152 collapsed to keep the page light
D node_modules/color-name/package.json +0 -28 collapsed to keep the page light
D node_modules/commander/CHANGELOG.md +0 -436 collapsed to keep the page light
D node_modules/commander/LICENSE +0 -22 collapsed to keep the page light
D node_modules/commander/Readme.md +0 -713 collapsed to keep the page light
D node_modules/commander/index.js +0 -1649 collapsed to keep the page light
D node_modules/commander/package.json +0 -41 collapsed to keep the page light
D node_modules/commander/typings/index.d.ts +0 -311 collapsed to keep the page light
D node_modules/cross-spawn/CHANGELOG.md +0 -130 collapsed to keep the page light
D node_modules/cross-spawn/LICENSE +0 -21 collapsed to keep the page light
D node_modules/cross-spawn/README.md +0 -96 collapsed to keep the page light
D node_modules/cross-spawn/index.js +0 -39 collapsed to keep the page light
D node_modules/cross-spawn/lib/enoent.js +0 -59 collapsed to keep the page light
D node_modules/cross-spawn/lib/parse.js +0 -91 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/escape.js +0 -45 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/readShebang.js +0 -23 collapsed to keep the page light
D node_modules/cross-spawn/lib/util/resolveCommand.js +0 -52 collapsed to keep the page light
D node_modules/cross-spawn/package.json +0 -73 collapsed to keep the page light
D node_modules/cssesc/README.md +0 -201 collapsed to keep the page light
D node_modules/cssesc/bin/cssesc +0 -116 collapsed to keep the page light
D node_modules/cssesc/cssesc.js +0 -110 collapsed to keep the page light
D node_modules/cssesc/man/cssesc.1 +0 -70 collapsed to keep the page light
D node_modules/cssesc/package.json +0 -51 collapsed to keep the page light
D node_modules/daisyui/LICENSE +0 -21 collapsed to keep the page light
D node_modules/daisyui/README.md +0 -37 collapsed to keep the page light
D node_modules/daisyui/base/properties.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/properties/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/properties/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/reset.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootcolor/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootscrollgutter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/rootscrolllock/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/scrollbar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/svg.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/base/svg/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/base/svg/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/chunks.css +0 -73 collapsed to keep the page light
D node_modules/daisyui/colors/properties-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/properties.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/responsive-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/responsive.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/states-extended.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/colors/states.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/alert.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/alert/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/alert/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/avatar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/avatar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/avatar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/badge.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/badge/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/badge/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/breadcrumbs/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/button.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/button/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/button/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/calendar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/calendar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/calendar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/card.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/card/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/card/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/carousel.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/carousel/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/carousel/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/chat.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/chat/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/chat/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/checkbox.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/checkbox/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/checkbox/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/collapse.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/collapse/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/collapse/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/countdown.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/countdown/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/countdown/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/diff.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/diff/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/diff/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/divider.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/divider/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/divider/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dock.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dock/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/dock/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/drawer.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/drawer/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/drawer/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dropdown.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/dropdown/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/dropdown/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fieldset.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fieldset/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/fieldset/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fileinput.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/fileinput/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/fileinput/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/filter.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/filter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/filter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/footer.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/footer/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/footer/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/hero.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/hero/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/hero/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/indicator.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/indicator/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/indicator/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/input.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/input/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/input/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/kbd.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/kbd/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/kbd/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/label.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/label/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/label/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/link.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/link/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/link/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/list.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/list/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/list/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/loading.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/loading/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/loading/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mask.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mask/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/mask/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/menu.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/menu/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/menu/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mockup.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/mockup/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/mockup/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/modal.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/modal/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/modal/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/navbar.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/navbar/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/navbar/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/progress.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/progress/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/progress/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/radialprogress/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radio.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/radio/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/radio/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/range.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/range/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/range/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/rating.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/rating/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/rating/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/select.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/select/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/select/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/skeleton.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/skeleton/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/skeleton/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stack.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stack/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/stack/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stat.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/stat/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/stat/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/status.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/status/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/status/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/steps.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/steps/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/steps/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/swap.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/swap/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/swap/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tab.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tab/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/tab/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/table.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/table/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/table/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/textarea.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/textarea/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/textarea/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/timeline.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/timeline/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/timeline/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toast.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toast/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/toast/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toggle.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/toggle/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/toggle/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tooltip.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/tooltip/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/tooltip/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/validator.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/components/validator/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/components/validator/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/daisyui.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/functions/addPrefix.js +0 -156 collapsed to keep the page light
D node_modules/daisyui/functions/plugin.js +0 -11 collapsed to keep the page light
D node_modules/daisyui/functions/pluginOptionsHandler.js +0 -86 collapsed to keep the page light
D node_modules/daisyui/functions/themeOrder.js +0 -37 collapsed to keep the page light
D node_modules/daisyui/functions/variables.js +0 -29 collapsed to keep the page light
D node_modules/daisyui/imports.js +0 -68 collapsed to keep the page light
D node_modules/daisyui/index.js +0 -51 collapsed to keep the page light
D node_modules/daisyui/package.json +0 -72 collapsed to keep the page light
D node_modules/daisyui/theme/abyss.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/abyss/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/abyss/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/acid.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/acid/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/acid/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/aqua.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/aqua/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/aqua/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/autumn.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/autumn/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/autumn/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/black.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/black/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/black/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/bumblebee/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/business.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/business/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/business/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/caramellatte/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cmyk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/coffee.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/coffee/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/coffee/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/corporate.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/corporate/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/corporate/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cupcake/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/cyberpunk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dark.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/dark/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dark/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dim.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/dim/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dim/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/dracula.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/dracula/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/dracula/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/emerald.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/emerald/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/emerald/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/fantasy/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/forest.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/forest/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/forest/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/garden.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/garden/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/garden/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/halloween.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/halloween/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/halloween/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/index.js +0 -48 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/lemonade/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/light.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/light/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/light/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/lofi.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/lofi/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/lofi/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/luxury.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/luxury/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/luxury/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/night.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/night/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/night/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/nord.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/nord/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/nord/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/object.d.ts +0 -74 collapsed to keep the page light
D node_modules/daisyui/theme/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/pastel.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/pastel/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/pastel/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/retro.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/retro/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/retro/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/silk.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/silk/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/silk/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/sunset.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/sunset/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/sunset/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/synthwave/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/valentine.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/valentine/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/valentine/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/winter.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/winter/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/winter/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe.css +0 -31 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/theme/wireframe/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/themes.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/glass.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/glass/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/glass/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/join.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/join/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/join/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/radius.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/radius/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/radius/object.js +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/typography.css +0 -1 collapsed to keep the page light
D node_modules/daisyui/utilities/typography/index.js +0 -7 collapsed to keep the page light
D node_modules/daisyui/utilities/typography/object.js +0 -1 collapsed to keep the page light
D node_modules/detect-libc/LICENSE +0 -201 collapsed to keep the page light
D node_modules/detect-libc/README.md +0 -163 collapsed to keep the page light
D node_modules/detect-libc/index.d.ts +0 -14 collapsed to keep the page light
D node_modules/detect-libc/lib/detect-libc.js +0 -267 collapsed to keep the page light
D node_modules/detect-libc/lib/filesystem.js +0 -41 collapsed to keep the page light
D node_modules/detect-libc/lib/process.js +0 -24 collapsed to keep the page light
D node_modules/detect-libc/package.json +0 -40 collapsed to keep the page light
D node_modules/didyoumean/LICENSE +0 -14 collapsed to keep the page light
D node_modules/didyoumean/README.md +0 -134 collapsed to keep the page light
D node_modules/didyoumean/didYouMean-1.2.1.js +0 -274 collapsed to keep the page light
D node_modules/didyoumean/didYouMean-1.2.1.min.js +0 -17 collapsed to keep the page light
D node_modules/didyoumean/package.json +0 -27 collapsed to keep the page light
D node_modules/dlv/README.md +0 -76 collapsed to keep the page light
D node_modules/dlv/index.js +0 -7 collapsed to keep the page light
D node_modules/dlv/package.json +0 -30 collapsed to keep the page light
D node_modules/dotenv/CHANGELOG.md +0 -488 collapsed to keep the page light
D node_modules/dotenv/LICENSE +0 -23 collapsed to keep the page light
D node_modules/dotenv/README-es.md +0 -448 collapsed to keep the page light
D node_modules/dotenv/README.md +0 -651 collapsed to keep the page light
D node_modules/dotenv/config.d.ts +0 -1 collapsed to keep the page light
D node_modules/dotenv/config.js +0 -9 collapsed to keep the page light
D node_modules/dotenv/lib/cli-options.js +0 -11 collapsed to keep the page light
D node_modules/dotenv/lib/env-options.js +0 -24 collapsed to keep the page light
D node_modules/dotenv/lib/main.d.ts +0 -153 collapsed to keep the page light
D node_modules/dotenv/lib/main.js +0 -361 collapsed to keep the page light
D node_modules/dotenv/package.json +0 -61 collapsed to keep the page light
D node_modules/eastasianwidth/README.md +0 -32 collapsed to keep the page light
D node_modules/eastasianwidth/eastasianwidth.js +0 -311 collapsed to keep the page light
D node_modules/eastasianwidth/package.json +0 -18 collapsed to keep the page light
D node_modules/emoji-regex/README.md +0 -137 collapsed to keep the page light
D node_modules/emoji-regex/RGI_Emoji.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/RGI_Emoji.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/RGI_Emoji.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/RGI_Emoji.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/index.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/index.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/es2015/text.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/es2015/text.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/index.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/index.js +0 -6 collapsed to keep the page light
D node_modules/emoji-regex/package.json +0 -52 collapsed to keep the page light
D node_modules/emoji-regex/text.d.ts +0 -5 collapsed to keep the page light
D node_modules/emoji-regex/text.js +0 -6 collapsed to keep the page light
D node_modules/enhanced-resolve/LICENSE +0 -20 collapsed to keep the page light
D node_modules/enhanced-resolve/README.md +0 -183 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AliasFieldPlugin.js +0 -108 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AliasPlugin.js +0 -183 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/AppendPlugin.js +0 -49 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/CachedInputFileSystem.js +0 -664 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/CloneBasenamePlugin.js +0 -53 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ConditionalPlugin.js +0 -59 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DescriptionFilePlugin.js +0 -98 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DescriptionFileUtils.js +0 -201 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/DirectoryExistsPlugin.js +0 -63 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ExportsFieldPlugin.js +0 -200 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ExtensionAliasPlugin.js +0 -101 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/FileExistsPlugin.js +0 -58 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ImportsFieldPlugin.js +0 -223 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/JoinRequestPartPlugin.js +0 -75 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/JoinRequestPlugin.js +0 -45 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/LogInfoPlugin.js +0 -54 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/MainFieldPlugin.js +0 -90 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInHierachicDirectoriesPlugin.js +0 -9 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInHierarchicalDirectoriesPlugin.js +0 -87 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ModulesInRootPlugin.js +0 -49 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/NextPlugin.js +0 -33 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ParsePlugin.js +0 -76 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/PnpPlugin.js +0 -133 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/Resolver.js +0 -800 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ResolverFactory.js +0 -728 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/RestrictionsPlugin.js +0 -70 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/ResultPlugin.js +0 -42 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/RootsPlugin.js +0 -69 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SelfReferencePlugin.js +0 -82 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SymlinkPlugin.js +0 -100 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/SyncAsyncFileSystemDecorator.js +0 -220 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/TryNextPlugin.js +0 -41 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/UnsafeCachePlugin.js +0 -112 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/UseFilePlugin.js +0 -55 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/createInnerContext.js +0 -46 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/forEachBail.js +0 -50 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/getInnerRequest.js +0 -36 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/getPaths.js +0 -45 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/index.js +0 -203 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/entrypoints.js +0 -573 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/identifier.js +0 -69 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/module-browser.js +0 -8 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/path.js +0 -203 collapsed to keep the page light
D node_modules/enhanced-resolve/lib/util/process-browser.js +0 -22 collapsed to keep the page light
D node_modules/enhanced-resolve/package.json +0 -72 collapsed to keep the page light
D node_modules/enhanced-resolve/types.d.ts +0 -1135 collapsed to keep the page light
D node_modules/fast-glob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/fast-glob/README.md +0 -830 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/CHANGELOG.md +0 -110 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/README.md +0 -137 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/index.js +0 -42 collapsed to keep the page light
D node_modules/fast-glob/node_modules/glob-parent/package.json +0 -48 collapsed to keep the page light
D node_modules/fast-glob/out/index.d.ts +0 -40 collapsed to keep the page light
D node_modules/fast-glob/out/index.js +0 -102 collapsed to keep the page light
D node_modules/fast-glob/out/managers/tasks.d.ts +0 -22 collapsed to keep the page light
D node_modules/fast-glob/out/managers/tasks.js +0 -110 collapsed to keep the page light
D node_modules/fast-glob/out/providers/async.d.ts +0 -9 collapsed to keep the page light
D node_modules/fast-glob/out/providers/async.js +0 -23 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/deep.d.ts +0 -16 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/deep.js +0 -62 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/entry.d.ts +0 -16 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/entry.js +0 -63 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/error.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/providers/filters/error.js +0 -15 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/matcher.d.ts +0 -33 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/matcher.js +0 -45 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/partial.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/providers/matchers/partial.js +0 -38 collapsed to keep the page light
D node_modules/fast-glob/out/providers/provider.d.ts +0 -19 collapsed to keep the page light
D node_modules/fast-glob/out/providers/provider.js +0 -48 collapsed to keep the page light
D node_modules/fast-glob/out/providers/stream.d.ts +0 -11 collapsed to keep the page light
D node_modules/fast-glob/out/providers/stream.js +0 -31 collapsed to keep the page light
D node_modules/fast-glob/out/providers/sync.d.ts +0 -9 collapsed to keep the page light
D node_modules/fast-glob/out/providers/sync.js +0 -23 collapsed to keep the page light
D node_modules/fast-glob/out/providers/transformers/entry.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/providers/transformers/entry.js +0 -26 collapsed to keep the page light
D node_modules/fast-glob/out/readers/async.d.ts +0 -10 collapsed to keep the page light
D node_modules/fast-glob/out/readers/async.js +0 -35 collapsed to keep the page light
D node_modules/fast-glob/out/readers/reader.d.ts +0 -15 collapsed to keep the page light
D node_modules/fast-glob/out/readers/reader.js +0 -33 collapsed to keep the page light
D node_modules/fast-glob/out/readers/stream.d.ts +0 -14 collapsed to keep the page light
D node_modules/fast-glob/out/readers/stream.js +0 -55 collapsed to keep the page light
D node_modules/fast-glob/out/readers/sync.d.ts +0 -12 collapsed to keep the page light
D node_modules/fast-glob/out/readers/sync.js +0 -43 collapsed to keep the page light
D node_modules/fast-glob/out/settings.d.ts +0 -164 collapsed to keep the page light
D node_modules/fast-glob/out/settings.js +0 -59 collapsed to keep the page light
D node_modules/fast-glob/out/types/index.d.ts +0 -31 collapsed to keep the page light
D node_modules/fast-glob/out/types/index.js +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/array.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/array.js +0 -22 collapsed to keep the page light
D node_modules/fast-glob/out/utils/errno.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/errno.js +0 -7 collapsed to keep the page light
D node_modules/fast-glob/out/utils/fs.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/utils/fs.js +0 -19 collapsed to keep the page light
D node_modules/fast-glob/out/utils/index.d.ts +0 -8 collapsed to keep the page light
D node_modules/fast-glob/out/utils/index.js +0 -17 collapsed to keep the page light
D node_modules/fast-glob/out/utils/path.d.ts +0 -13 collapsed to keep the page light
D node_modules/fast-glob/out/utils/path.js +0 -68 collapsed to keep the page light
D node_modules/fast-glob/out/utils/pattern.d.ts +0 -47 collapsed to keep the page light
D node_modules/fast-glob/out/utils/pattern.js +0 -188 collapsed to keep the page light
D node_modules/fast-glob/out/utils/stream.d.ts +0 -4 collapsed to keep the page light
D node_modules/fast-glob/out/utils/stream.js +0 -17 collapsed to keep the page light
D node_modules/fast-glob/out/utils/string.d.ts +0 -2 collapsed to keep the page light
D node_modules/fast-glob/out/utils/string.js +0 -11 collapsed to keep the page light
D node_modules/fast-glob/package.json +0 -81 collapsed to keep the page light
D node_modules/fastq/.github/dependabot.yml +0 -11 collapsed to keep the page light
D node_modules/fastq/.github/workflows/ci.yml +0 -75 collapsed to keep the page light
D node_modules/fastq/LICENSE +0 -13 collapsed to keep the page light
D node_modules/fastq/README.md +0 -306 collapsed to keep the page light
D node_modules/fastq/bench.js +0 -66 collapsed to keep the page light
D node_modules/fastq/example.js +0 -14 collapsed to keep the page light
D node_modules/fastq/example.mjs +0 -11 collapsed to keep the page light
D node_modules/fastq/index.d.ts +0 -38 collapsed to keep the page light
D node_modules/fastq/package.json +0 -53 collapsed to keep the page light
D node_modules/fastq/queue.js +0 -311 collapsed to keep the page light
D node_modules/fastq/test/example.ts +0 -83 collapsed to keep the page light
D node_modules/fastq/test/promise.js +0 -248 collapsed to keep the page light
D node_modules/fastq/test/test.js +0 -642 collapsed to keep the page light
D node_modules/fastq/test/tsconfig.json +0 -11 collapsed to keep the page light
D node_modules/fill-range/LICENSE +0 -21 collapsed to keep the page light
D node_modules/fill-range/README.md +0 -237 collapsed to keep the page light
D node_modules/fill-range/index.js +0 -248 collapsed to keep the page light
D node_modules/fill-range/package.json +0 -74 collapsed to keep the page light
D node_modules/foreground-child/LICENSE +0 -15 collapsed to keep the page light
D node_modules/foreground-child/README.md +0 -128 collapsed to keep the page light
D node_modules/foreground-child/package.json +0 -111 collapsed to keep the page light
D node_modules/function-bind/.eslintrc +0 -21 collapsed to keep the page light
D node_modules/function-bind/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/function-bind/.github/SECURITY.md +0 -3 collapsed to keep the page light
D node_modules/function-bind/.nycrc +0 -13 collapsed to keep the page light
D node_modules/function-bind/CHANGELOG.md +0 -136 collapsed to keep the page light
D node_modules/function-bind/LICENSE +0 -20 collapsed to keep the page light
D node_modules/function-bind/README.md +0 -46 collapsed to keep the page light
D node_modules/function-bind/implementation.js +0 -84 collapsed to keep the page light
D node_modules/function-bind/index.js +0 -5 collapsed to keep the page light
D node_modules/function-bind/package.json +0 -87 collapsed to keep the page light
D node_modules/function-bind/test/.eslintrc +0 -9 collapsed to keep the page light
D node_modules/function-bind/test/index.js +0 -252 collapsed to keep the page light
D node_modules/glob-parent/LICENSE +0 -15 collapsed to keep the page light
D node_modules/glob-parent/README.md +0 -134 collapsed to keep the page light
D node_modules/glob-parent/index.js +0 -75 collapsed to keep the page light
D node_modules/glob-parent/package.json +0 -54 collapsed to keep the page light
D node_modules/glob/LICENSE +0 -15 collapsed to keep the page light
D node_modules/glob/README.md +0 -1265 collapsed to keep the page light
D node_modules/glob/package.json +0 -99 collapsed to keep the page light
D node_modules/graceful-fs/LICENSE +0 -15 collapsed to keep the page light
D node_modules/graceful-fs/README.md +0 -143 collapsed to keep the page light
D node_modules/graceful-fs/clone.js +0 -23 collapsed to keep the page light
D node_modules/graceful-fs/graceful-fs.js +0 -448 collapsed to keep the page light
D node_modules/graceful-fs/legacy-streams.js +0 -118 collapsed to keep the page light
D node_modules/graceful-fs/package.json +0 -53 collapsed to keep the page light
D node_modules/graceful-fs/polyfills.js +0 -355 collapsed to keep the page light
D node_modules/hasown/.eslintrc +0 -5 collapsed to keep the page light
D node_modules/hasown/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/hasown/.nycrc +0 -13 collapsed to keep the page light
D node_modules/hasown/CHANGELOG.md +0 -40 collapsed to keep the page light
D node_modules/hasown/LICENSE +0 -21 collapsed to keep the page light
D node_modules/hasown/README.md +0 -40 collapsed to keep the page light
D node_modules/hasown/index.d.ts +0 -3 collapsed to keep the page light
D node_modules/hasown/index.js +0 -8 collapsed to keep the page light
D node_modules/hasown/package.json +0 -92 collapsed to keep the page light
D node_modules/hasown/tsconfig.json +0 -6 collapsed to keep the page light
D node_modules/is-binary-path/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/is-binary-path/index.js +0 -7 collapsed to keep the page light
D node_modules/is-binary-path/license +0 -9 collapsed to keep the page light
D node_modules/is-binary-path/package.json +0 -40 collapsed to keep the page light
D node_modules/is-binary-path/readme.md +0 -34 collapsed to keep the page light
D node_modules/is-core-module/.eslintrc +0 -18 collapsed to keep the page light
D node_modules/is-core-module/.nycrc +0 -9 collapsed to keep the page light
D node_modules/is-core-module/CHANGELOG.md +0 -205 collapsed to keep the page light
D node_modules/is-core-module/LICENSE +0 -20 collapsed to keep the page light
D node_modules/is-core-module/README.md +0 -40 collapsed to keep the page light
D node_modules/is-core-module/core.json +0 -161 collapsed to keep the page light
D node_modules/is-core-module/index.js +0 -69 collapsed to keep the page light
D node_modules/is-core-module/package.json +0 -76 collapsed to keep the page light
D node_modules/is-core-module/test/index.js +0 -154 collapsed to keep the page light
D node_modules/is-extglob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-extglob/README.md +0 -107 collapsed to keep the page light
D node_modules/is-extglob/index.js +0 -20 collapsed to keep the page light
D node_modules/is-extglob/package.json +0 -69 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/index.d.ts +0 -17 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/index.js +0 -50 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/license +0 -9 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/package.json +0 -42 collapsed to keep the page light
D node_modules/is-fullwidth-code-point/readme.md +0 -39 collapsed to keep the page light
D node_modules/is-glob/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-glob/README.md +0 -206 collapsed to keep the page light
D node_modules/is-glob/index.js +0 -150 collapsed to keep the page light
D node_modules/is-glob/package.json +0 -81 collapsed to keep the page light
D node_modules/is-number/LICENSE +0 -21 collapsed to keep the page light
D node_modules/is-number/README.md +0 -187 collapsed to keep the page light
D node_modules/is-number/index.js +0 -18 collapsed to keep the page light
D node_modules/is-number/package.json +0 -82 collapsed to keep the page light
D node_modules/isexe/.npmignore +0 -2 collapsed to keep the page light
D node_modules/isexe/LICENSE +0 -15 collapsed to keep the page light
D node_modules/isexe/README.md +0 -51 collapsed to keep the page light
D node_modules/isexe/index.js +0 -57 collapsed to keep the page light
D node_modules/isexe/mode.js +0 -41 collapsed to keep the page light
D node_modules/isexe/package.json +0 -31 collapsed to keep the page light
D node_modules/isexe/test/basic.js +0 -221 collapsed to keep the page light
D node_modules/isexe/windows.js +0 -42 collapsed to keep the page light
D node_modules/jackspeak/LICENSE.md +0 -55 collapsed to keep the page light
D node_modules/jackspeak/README.md +0 -357 collapsed to keep the page light
D node_modules/jackspeak/package.json +0 -95 collapsed to keep the page light
D node_modules/jiti/LICENSE +0 -21 collapsed to keep the page light
D node_modules/jiti/README.md +0 -235 collapsed to keep the page light
D node_modules/jiti/lib/jiti-cli.mjs +0 -34 collapsed to keep the page light
D node_modules/jiti/lib/jiti-hooks.mjs +0 -117 collapsed to keep the page light
D node_modules/jiti/lib/jiti-native.mjs +0 -121 collapsed to keep the page light
D node_modules/jiti/lib/jiti-register.d.mts +0 -1 collapsed to keep the page light
D node_modules/jiti/lib/jiti-register.mjs +0 -4 collapsed to keep the page light
D node_modules/jiti/lib/jiti.cjs +0 -23 collapsed to keep the page light
D node_modules/jiti/lib/jiti.d.cts +0 -8 collapsed to keep the page light
D node_modules/jiti/lib/jiti.d.mts +0 -8 collapsed to keep the page light
D node_modules/jiti/lib/jiti.mjs +0 -22 collapsed to keep the page light
D node_modules/jiti/lib/types.d.ts +0 -260 collapsed to keep the page light
D node_modules/jiti/package.json +0 -134 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/README.md +0 -1 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-gnu/lightningcss.linux-x64-gnu.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/lightningcss-linux-x64-gnu/package.json +0 -37 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/README.md +0 -1 collapsed to keep the page light
D node_modules/lightningcss-linux-x64-musl/lightningcss.linux-x64-musl.node +0 -0 binary file: no text diff
binary file: no text diff
D node_modules/lightningcss-linux-x64-musl/package.json +0 -37 collapsed to keep the page light
D node_modules/lightningcss/LICENSE +0 -373 collapsed to keep the page light
D node_modules/lightningcss/README.md +0 -105 collapsed to keep the page light
D node_modules/lightningcss/node/ast.d.ts +0 -9716 collapsed to keep the page light
D node_modules/lightningcss/node/ast.js.flow +0 -10346 collapsed to keep the page light
D node_modules/lightningcss/node/browserslistToTargets.js +0 -48 collapsed to keep the page light
D node_modules/lightningcss/node/composeVisitors.js +0 -442 collapsed to keep the page light
D node_modules/lightningcss/node/flags.js +0 -28 collapsed to keep the page light
D node_modules/lightningcss/node/index.d.ts +0 -477 collapsed to keep the page light
D node_modules/lightningcss/node/index.js +0 -28 collapsed to keep the page light
D node_modules/lightningcss/node/index.js.flow +0 -824 collapsed to keep the page light
D node_modules/lightningcss/node/index.mjs +0 -4 collapsed to keep the page light
D node_modules/lightningcss/node/targets.d.ts +0 -40 collapsed to keep the page light
D node_modules/lightningcss/node/targets.js.flow +0 -39 collapsed to keep the page light
D node_modules/lightningcss/package.json +0 -109 collapsed to keep the page light
D node_modules/lilconfig/LICENSE +0 -21 collapsed to keep the page light
D node_modules/lilconfig/package.json +0 -42 collapsed to keep the page light
D node_modules/lilconfig/readme.md +0 -98 collapsed to keep the page light
D node_modules/lilconfig/src/index.d.ts +0 -54 collapsed to keep the page light
D node_modules/lilconfig/src/index.js +0 -460 collapsed to keep the page light
D node_modules/lines-and-columns/LICENSE +0 -21 collapsed to keep the page light
D node_modules/lines-and-columns/README.md +0 -33 collapsed to keep the page light
D node_modules/lines-and-columns/build/index.d.ts +0 -13 collapsed to keep the page light
D node_modules/lines-and-columns/build/index.js +0 -62 collapsed to keep the page light
D node_modules/lines-and-columns/package.json +0 -49 collapsed to keep the page light
D node_modules/lru-cache/LICENSE +0 -15 collapsed to keep the page light
D node_modules/lru-cache/README.md +0 -331 collapsed to keep the page light
D node_modules/lru-cache/package.json +0 -116 collapsed to keep the page light
D node_modules/merge2/LICENSE +0 -21 collapsed to keep the page light
D node_modules/merge2/README.md +0 -144 collapsed to keep the page light
D node_modules/merge2/index.js +0 -144 collapsed to keep the page light
D node_modules/merge2/package.json +0 -43 collapsed to keep the page light
D node_modules/micromatch/LICENSE +0 -21 collapsed to keep the page light
D node_modules/micromatch/README.md +0 -1024 collapsed to keep the page light
D node_modules/micromatch/index.js +0 -474 collapsed to keep the page light
D node_modules/micromatch/package.json +0 -119 collapsed to keep the page light
D node_modules/minimatch/LICENSE +0 -15 collapsed to keep the page light
D node_modules/minimatch/README.md +0 -454 collapsed to keep the page light
D node_modules/minimatch/package.json +0 -82 collapsed to keep the page light
D node_modules/minipass/LICENSE +0 -15 collapsed to keep the page light
D node_modules/minipass/README.md +0 -825 collapsed to keep the page light
D node_modules/minipass/package.json +0 -82 collapsed to keep the page light
D node_modules/mri/index.d.ts +0 -21 collapsed to keep the page light
D node_modules/mri/lib/index.js +0 -119 collapsed to keep the page light
D node_modules/mri/lib/index.mjs +0 -119 collapsed to keep the page light
D node_modules/mri/license.md +0 -21 collapsed to keep the page light
D node_modules/mri/package.json +0 -43 collapsed to keep the page light
D node_modules/mri/readme.md +0 -166 collapsed to keep the page light
D node_modules/mz/HISTORY.md +0 -66 collapsed to keep the page light
D node_modules/mz/LICENSE +0 -22 collapsed to keep the page light
D node_modules/mz/README.md +0 -106 collapsed to keep the page light
D node_modules/mz/child_process.js +0 -8 collapsed to keep the page light
D node_modules/mz/crypto.js +0 -9 collapsed to keep the page light
D node_modules/mz/dns.js +0 -16 collapsed to keep the page light
D node_modules/mz/fs.js +0 -62 collapsed to keep the page light
D node_modules/mz/index.js +0 -8 collapsed to keep the page light
D node_modules/mz/package.json +0 -44 collapsed to keep the page light
D node_modules/mz/readline.js +0 -64 collapsed to keep the page light
D node_modules/mz/zlib.js +0 -13 collapsed to keep the page light
D node_modules/nanoid/LICENSE +0 -20 collapsed to keep the page light
D node_modules/nanoid/README.md +0 -554 collapsed to keep the page light
D node_modules/nanoid/async/index.browser.cjs +0 -69 collapsed to keep the page light
D node_modules/nanoid/async/index.browser.js +0 -69 collapsed to keep the page light
D node_modules/nanoid/async/index.cjs +0 -71 collapsed to keep the page light
D node_modules/nanoid/async/index.d.ts +0 -56 collapsed to keep the page light
D node_modules/nanoid/async/index.js +0 -71 collapsed to keep the page light
D node_modules/nanoid/async/index.native.js +0 -57 collapsed to keep the page light
D node_modules/nanoid/async/package.json +0 -12 collapsed to keep the page light
D node_modules/nanoid/bin/nanoid.cjs +0 -55 collapsed to keep the page light
D node_modules/nanoid/index.browser.cjs +0 -72 collapsed to keep the page light
D node_modules/nanoid/index.browser.js +0 -72 collapsed to keep the page light
D node_modules/nanoid/index.cjs +0 -85 collapsed to keep the page light
D node_modules/nanoid/index.d.cts +0 -91 collapsed to keep the page light
D node_modules/nanoid/index.d.ts +0 -91 collapsed to keep the page light
D node_modules/nanoid/index.js +0 -85 collapsed to keep the page light
D node_modules/nanoid/nanoid.js +0 -1 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.cjs +0 -34 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.d.ts +0 -33 collapsed to keep the page light
D node_modules/nanoid/non-secure/index.js +0 -34 collapsed to keep the page light
D node_modules/nanoid/non-secure/package.json +0 -6 collapsed to keep the page light
D node_modules/nanoid/package.json +0 -88 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/index.cjs +0 -7 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/index.js +0 -7 collapsed to keep the page light
D node_modules/nanoid/url-alphabet/package.json +0 -6 collapsed to keep the page light
D node_modules/node-addon-api/LICENSE.md +0 -9 collapsed to keep the page light
D node_modules/node-addon-api/README.md +0 -319 collapsed to keep the page light
D node_modules/node-addon-api/common.gypi +0 -20 collapsed to keep the page light
D node_modules/node-addon-api/except.gypi +0 -25 collapsed to keep the page light
D node_modules/node-addon-api/index.js +0 -12 collapsed to keep the page light
D node_modules/node-addon-api/napi-inl.deprecated.h +0 -186 collapsed to keep the page light
D node_modules/node-addon-api/napi-inl.h +0 -6607 collapsed to keep the page light
D node_modules/node-addon-api/napi.h +0 -3201 collapsed to keep the page light
D node_modules/node-addon-api/node_addon_api.gyp +0 -32 collapsed to keep the page light
D node_modules/node-addon-api/node_api.gyp +0 -9 collapsed to keep the page light
D node_modules/node-addon-api/noexcept.gypi +0 -26 collapsed to keep the page light
D node_modules/node-addon-api/nothing.c +0 -0
no textual change.
D node_modules/node-addon-api/package-support.json +0 -21 collapsed to keep the page light
D node_modules/node-addon-api/package.json +0 -480 collapsed to keep the page light
D node_modules/node-addon-api/tools/README.md +0 -73 collapsed to keep the page light
D node_modules/node-addon-api/tools/check-napi.js +0 -99 collapsed to keep the page light
D node_modules/node-addon-api/tools/clang-format.js +0 -71 collapsed to keep the page light
D node_modules/node-addon-api/tools/conversion.js +0 -301 collapsed to keep the page light
D node_modules/node-addon-api/tools/eslint-format.js +0 -79 collapsed to keep the page light
D node_modules/normalize-path/LICENSE +0 -21 collapsed to keep the page light
D node_modules/normalize-path/README.md +0 -127 collapsed to keep the page light
D node_modules/normalize-path/index.js +0 -35
| @@ -1,35 +0,0 @@ | |||
| 1 | - | /*! | |
| 2 | - | * normalize-path <https://github.com/jonschlinkert/normalize-path> | |
| 3 | - | * | |
| 4 | - | * Copyright (c) 2014-2018, Jon Schlinkert. | |
| 5 | - | * Released under the MIT License. | |
| 6 | - | */ | |
| 7 | - | ||
| 8 | - | module.exports = function(path, stripTrailing) { | |
| 9 | - | if (typeof path !== 'string') { | |
| 10 | - | throw new TypeError('expected path to be a string'); | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | if (path === '\\' || path === '/') return '/'; | |
| 14 | - | ||
| 15 | - | var len = path.length; | |
| 16 | - | if (len <= 1) return path; | |
| 17 | - | ||
| 18 | - | // ensure that win32 namespaces has two leading slashes, so that the path is | |
| 19 | - | // handled properly by the win32 version of path.parse() after being normalized | |
| 20 | - | // https://msdn.microsoft.com/library/windows/desktop/aa365247(v=vs.85).aspx#namespaces | |
| 21 | - | var prefix = ''; | |
| 22 | - | if (len > 4 && path[3] === '\\') { | |
| 23 | - | var ch = path[2]; | |
| 24 | - | if ((ch === '?' || ch === '.') && path.slice(0, 2) === '\\\\') { | |
| 25 | - | path = path.slice(2); | |
| 26 | - | prefix = '//'; | |
| 27 | - | } | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | var segs = path.split(/[/\\]+/); | |
| 31 | - | if (stripTrailing !== false && segs[segs.length - 1] === '') { | |
| 32 | - | segs.pop(); | |
| 33 | - | } | |
| 34 | - | return prefix + segs.join('/'); | |
| 35 | - | }; | |
D node_modules/normalize-path/package.json +0 -77 collapsed to keep the page light
D node_modules/object-assign/index.js +0 -90 collapsed to keep the page light
D node_modules/object-assign/license +0 -21 collapsed to keep the page light
D node_modules/object-assign/package.json +0 -42 collapsed to keep the page light
D node_modules/object-assign/readme.md +0 -61 collapsed to keep the page light
D node_modules/object-hash/LICENSE +0 -22 collapsed to keep the page light
D node_modules/object-hash/index.js +0 -453 collapsed to keep the page light
D node_modules/object-hash/package.json +0 -53 collapsed to keep the page light
D node_modules/object-hash/readme.markdown +0 -198 collapsed to keep the page light
D node_modules/package-json-from-dist/LICENSE.md +0 -63 collapsed to keep the page light
D node_modules/package-json-from-dist/README.md +0 -110 collapsed to keep the page light
D node_modules/package-json-from-dist/package.json +0 -68 collapsed to keep the page light
D node_modules/path-key/index.d.ts +0 -40 collapsed to keep the page light
D node_modules/path-key/index.js +0 -16 collapsed to keep the page light
D node_modules/path-key/license +0 -9 collapsed to keep the page light
D node_modules/path-key/package.json +0 -39 collapsed to keep the page light
D node_modules/path-key/readme.md +0 -61 collapsed to keep the page light
D node_modules/path-parse/LICENSE +0 -21 collapsed to keep the page light
D node_modules/path-parse/README.md +0 -42 collapsed to keep the page light
D node_modules/path-parse/index.js +0 -75 collapsed to keep the page light
D node_modules/path-parse/package.json +0 -33 collapsed to keep the page light
D node_modules/path-scurry/LICENSE.md +0 -55 collapsed to keep the page light
D node_modules/path-scurry/README.md +0 -636 collapsed to keep the page light
D node_modules/path-scurry/package.json +0 -89 collapsed to keep the page light
D node_modules/picocolors/LICENSE +0 -15 collapsed to keep the page light
D node_modules/picocolors/README.md +0 -21 collapsed to keep the page light
D node_modules/picocolors/package.json +0 -25 collapsed to keep the page light
D node_modules/picocolors/picocolors.browser.js +0 -4 collapsed to keep the page light
D node_modules/picocolors/picocolors.d.ts +0 -5 collapsed to keep the page light
D node_modules/picocolors/picocolors.js +0 -75 collapsed to keep the page light
D node_modules/picocolors/types.d.ts +0 -51 collapsed to keep the page light
D node_modules/picomatch/CHANGELOG.md +0 -136 collapsed to keep the page light
D node_modules/picomatch/LICENSE +0 -21 collapsed to keep the page light
D node_modules/picomatch/README.md +0 -708 collapsed to keep the page light
D node_modules/picomatch/index.js +0 -3 collapsed to keep the page light
D node_modules/picomatch/lib/constants.js +0 -179 collapsed to keep the page light
D node_modules/picomatch/lib/parse.js +0 -1091 collapsed to keep the page light
D node_modules/picomatch/lib/picomatch.js +0 -342 collapsed to keep the page light
D node_modules/picomatch/lib/scan.js +0 -391 collapsed to keep the page light
D node_modules/picomatch/lib/utils.js +0 -64 collapsed to keep the page light
D node_modules/picomatch/package.json +0 -81 collapsed to keep the page light
D node_modules/pify/index.js +0 -68 collapsed to keep the page light
D node_modules/pify/license +0 -21 collapsed to keep the page light
D node_modules/pify/package.json +0 -48 collapsed to keep the page light
D node_modules/pify/readme.md +0 -119 collapsed to keep the page light
D node_modules/pirates/LICENSE +0 -21 collapsed to keep the page light
D node_modules/pirates/README.md +0 -69 collapsed to keep the page light
D node_modules/pirates/index.d.ts +0 -82 collapsed to keep the page light
D node_modules/pirates/lib/index.js +0 -139 collapsed to keep the page light
D node_modules/pirates/package.json +0 -74 collapsed to keep the page light
D node_modules/postcss-import/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-import/README.md +0 -244 collapsed to keep the page light
D node_modules/postcss-import/index.js +0 -420 collapsed to keep the page light
D node_modules/postcss-import/lib/assign-layer-names.js +0 -17 collapsed to keep the page light
D node_modules/postcss-import/lib/data-url.js +0 -17 collapsed to keep the page light
D node_modules/postcss-import/lib/join-layer.js +0 -9 collapsed to keep the page light
D node_modules/postcss-import/lib/join-media.js +0 -28 collapsed to keep the page light
D node_modules/postcss-import/lib/load-content.js +0 -12 collapsed to keep the page light
D node_modules/postcss-import/lib/parse-statements.js +0 -172 collapsed to keep the page light
D node_modules/postcss-import/lib/process-content.js +0 -59 collapsed to keep the page light
D node_modules/postcss-import/lib/resolve-id.js +0 -42 collapsed to keep the page light
D node_modules/postcss-import/package.json +0 -65 collapsed to keep the page light
D node_modules/postcss-js/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-js/README.md +0 -22 collapsed to keep the page light
D node_modules/postcss-js/async.js +0 -15 collapsed to keep the page light
D node_modules/postcss-js/index.js +0 -11 collapsed to keep the page light
D node_modules/postcss-js/index.mjs +0 -8 collapsed to keep the page light
D node_modules/postcss-js/objectifier.js +0 -85 collapsed to keep the page light
D node_modules/postcss-js/package.json +0 -42 collapsed to keep the page light
D node_modules/postcss-js/parser.js +0 -104 collapsed to keep the page light
D node_modules/postcss-js/process-result.js +0 -11 collapsed to keep the page light
D node_modules/postcss-js/sync.js +0 -12 collapsed to keep the page light
D node_modules/postcss-load-config/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-load-config/README.md +0 -466 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/LICENSE +0 -21 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/package.json +0 -42 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/readme.md +0 -98 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/src/index.d.ts +0 -54 collapsed to keep the page light
D node_modules/postcss-load-config/node_modules/lilconfig/src/index.js +0 -457 collapsed to keep the page light
D node_modules/postcss-load-config/package.json +0 -54 collapsed to keep the page light
D node_modules/postcss-load-config/src/index.d.ts +0 -65 collapsed to keep the page light
D node_modules/postcss-load-config/src/index.js +0 -185 collapsed to keep the page light
D node_modules/postcss-load-config/src/options.js +0 -47 collapsed to keep the page light
D node_modules/postcss-load-config/src/plugins.js +0 -85 collapsed to keep the page light
D node_modules/postcss-load-config/src/req.js +0 -10 collapsed to keep the page light
D node_modules/postcss-nested/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss-nested/README.md +0 -85 collapsed to keep the page light
D node_modules/postcss-nested/index.d.ts +0 -41 collapsed to keep the page light
D node_modules/postcss-nested/index.js +0 -361 collapsed to keep the page light
D node_modules/postcss-nested/package.json +0 -34 collapsed to keep the page light
D node_modules/postcss-selector-parser/API.md +0 -872 collapsed to keep the page light
D node_modules/postcss-selector-parser/CHANGELOG.md +0 -549 collapsed to keep the page light
D node_modules/postcss-selector-parser/LICENSE-MIT +0 -22 collapsed to keep the page light
D node_modules/postcss-selector-parser/README.md +0 -49 collapsed to keep the page light
D node_modules/postcss-selector-parser/package.json +0 -80 collapsed to keep the page light
D node_modules/postcss-selector-parser/postcss-selector-parser.d.ts +0 -555 collapsed to keep the page light
D node_modules/postcss-value-parser/LICENSE +0 -22 collapsed to keep the page light
D node_modules/postcss-value-parser/README.md +0 -263 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/index.d.ts +0 -177 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/index.js +0 -28 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/parse.js +0 -321 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/stringify.js +0 -48 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/unit.js +0 -120 collapsed to keep the page light
D node_modules/postcss-value-parser/lib/walk.js +0 -22 collapsed to keep the page light
D node_modules/postcss-value-parser/package.json +0 -58 collapsed to keep the page light
D node_modules/postcss/LICENSE +0 -20 collapsed to keep the page light
D node_modules/postcss/README.md +0 -28 collapsed to keep the page light
D node_modules/postcss/lib/at-rule.d.ts +0 -140 collapsed to keep the page light
D node_modules/postcss/lib/at-rule.js +0 -25 collapsed to keep the page light
D node_modules/postcss/lib/comment.d.ts +0 -68 collapsed to keep the page light
D node_modules/postcss/lib/comment.js +0 -13 collapsed to keep the page light
D node_modules/postcss/lib/container.d.ts +0 -480 collapsed to keep the page light
D node_modules/postcss/lib/container.js +0 -447 collapsed to keep the page light
D node_modules/postcss/lib/css-syntax-error.d.ts +0 -248 collapsed to keep the page light
D node_modules/postcss/lib/css-syntax-error.js +0 -133 collapsed to keep the page light
D node_modules/postcss/lib/declaration.d.ts +0 -151 collapsed to keep the page light
D node_modules/postcss/lib/declaration.js +0 -24 collapsed to keep the page light
D node_modules/postcss/lib/document.d.ts +0 -69 collapsed to keep the page light
D node_modules/postcss/lib/document.js +0 -33 collapsed to keep the page light
D node_modules/postcss/lib/fromJSON.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/fromJSON.js +0 -54 collapsed to keep the page light
D node_modules/postcss/lib/input.d.ts +0 -206 collapsed to keep the page light
D node_modules/postcss/lib/input.js +0 -251 collapsed to keep the page light
D node_modules/postcss/lib/lazy-result.d.ts +0 -190 collapsed to keep the page light
D node_modules/postcss/lib/lazy-result.js +0 -550 collapsed to keep the page light
D node_modules/postcss/lib/list.d.ts +0 -60 collapsed to keep the page light
D node_modules/postcss/lib/list.js +0 -58 collapsed to keep the page light
D node_modules/postcss/lib/map-generator.js +0 -368 collapsed to keep the page light
D node_modules/postcss/lib/no-work-result.d.ts +0 -46 collapsed to keep the page light
D node_modules/postcss/lib/no-work-result.js +0 -138 collapsed to keep the page light
D node_modules/postcss/lib/node.d.ts +0 -541 collapsed to keep the page light
D node_modules/postcss/lib/node.js +0 -434 collapsed to keep the page light
D node_modules/postcss/lib/parse.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/parse.js +0 -42 collapsed to keep the page light
D node_modules/postcss/lib/parser.js +0 -611 collapsed to keep the page light
D node_modules/postcss/lib/postcss.d.mts +0 -69 collapsed to keep the page light
D node_modules/postcss/lib/postcss.d.ts +0 -458 collapsed to keep the page light
D node_modules/postcss/lib/postcss.js +0 -101 collapsed to keep the page light
D node_modules/postcss/lib/postcss.mjs +0 -30 collapsed to keep the page light
D node_modules/postcss/lib/previous-map.d.ts +0 -81 collapsed to keep the page light
D node_modules/postcss/lib/previous-map.js +0 -144 collapsed to keep the page light
D node_modules/postcss/lib/processor.d.ts +0 -115 collapsed to keep the page light
D node_modules/postcss/lib/processor.js +0 -67 collapsed to keep the page light
D node_modules/postcss/lib/result.d.ts +0 -205 collapsed to keep the page light
D node_modules/postcss/lib/result.js +0 -42 collapsed to keep the page light
D node_modules/postcss/lib/root.d.ts +0 -87 collapsed to keep the page light
D node_modules/postcss/lib/root.js +0 -61 collapsed to keep the page light
D node_modules/postcss/lib/rule.d.ts +0 -126 collapsed to keep the page light
D node_modules/postcss/lib/rule.js +0 -27 collapsed to keep the page light
D node_modules/postcss/lib/stringifier.d.ts +0 -46 collapsed to keep the page light
D node_modules/postcss/lib/stringifier.js +0 -353 collapsed to keep the page light
D node_modules/postcss/lib/stringify.d.ts +0 -9 collapsed to keep the page light
D node_modules/postcss/lib/stringify.js +0 -11 collapsed to keep the page light
D node_modules/postcss/lib/symbols.js +0 -5 collapsed to keep the page light
D node_modules/postcss/lib/terminal-highlight.js +0 -70 collapsed to keep the page light
D node_modules/postcss/lib/tokenize.js +0 -266 collapsed to keep the page light
D node_modules/postcss/lib/warn-once.js +0 -13 collapsed to keep the page light
D node_modules/postcss/lib/warning.d.ts +0 -147 collapsed to keep the page light
D node_modules/postcss/lib/warning.js +0 -37 collapsed to keep the page light
D node_modules/postcss/package.json +0 -88 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/LICENSE +0 -21 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/README.md +0 -174 collapsed to keep the page light
D node_modules/prettier-plugin-tailwindcss/package.json +0 -144 collapsed to keep the page light
D node_modules/prettier/LICENSE +0 -7 collapsed to keep the page light
D node_modules/prettier/README.md +0 -108 collapsed to keep the page light
D node_modules/prettier/THIRD-PARTY-NOTICES.md +0 -4416 collapsed to keep the page light
D node_modules/prettier/bin/prettier.cjs +0 -68 collapsed to keep the page light
D node_modules/prettier/doc.d.ts +0 -254 collapsed to keep the page light
D node_modules/prettier/doc.js +0 -1261 collapsed to keep the page light
D node_modules/prettier/doc.mjs +0 -1233 collapsed to keep the page light
D node_modules/prettier/index.cjs +0 -682 collapsed to keep the page light
D node_modules/prettier/index.d.ts +0 -956 collapsed to keep the page light
D node_modules/prettier/index.mjs +0 -22242 collapsed to keep the page light
D node_modules/prettier/internal/cli.mjs +0 -4873 collapsed to keep the page light
D node_modules/prettier/package.json +0 -200 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.d.ts +0 -6 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.js +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/acorn.mjs +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/angular.d.ts +0 -8 collapsed to keep the page light
D node_modules/prettier/plugins/angular.js +0 -2 collapsed to keep the page light
D node_modules/prettier/plugins/angular.mjs +0 -2 collapsed to keep the page light
D node_modules/prettier/plugins/babel.d.ts +0 -18 collapsed to keep the page light
D node_modules/prettier/plugins/babel.js +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/babel.mjs +0 -15 collapsed to keep the page light
D node_modules/prettier/plugins/estree.d.ts +0 -1 collapsed to keep the page light
D node_modules/prettier/plugins/estree.js +0 -36 collapsed to keep the page light
D node_modules/prettier/plugins/estree.mjs +0 -36 collapsed to keep the page light
D node_modules/prettier/plugins/flow.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/flow.js +0 -19 collapsed to keep the page light
D node_modules/prettier/plugins/flow.mjs +0 -19 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.js +0 -30 collapsed to keep the page light
D node_modules/prettier/plugins/glimmer.mjs +0 -30 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.js +0 -29 collapsed to keep the page light
D node_modules/prettier/plugins/graphql.mjs +0 -29 collapsed to keep the page light
D node_modules/prettier/plugins/html.d.ts +0 -8 collapsed to keep the page light
D node_modules/prettier/plugins/html.js +0 -22 collapsed to keep the page light
D node_modules/prettier/plugins/html.mjs +0 -22 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.d.ts +0 -7 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.js +0 -63 collapsed to keep the page light
D node_modules/prettier/plugins/markdown.mjs +0 -63 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.js +0 -4 collapsed to keep the page light
D node_modules/prettier/plugins/meriyah.mjs +0 -4 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.d.ts +0 -7 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.js +0 -54 collapsed to keep the page light
D node_modules/prettier/plugins/postcss.mjs +0 -54 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.js +0 -20 collapsed to keep the page light
D node_modules/prettier/plugins/typescript.mjs +0 -20 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.d.ts +0 -5 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.js +0 -161 collapsed to keep the page light
D node_modules/prettier/plugins/yaml.mjs +0 -161 collapsed to keep the page light
D node_modules/prettier/standalone.d.ts +0 -33 collapsed to keep the page light
D node_modules/prettier/standalone.js +0 -39 collapsed to keep the page light
D node_modules/prettier/standalone.mjs +0 -39 collapsed to keep the page light
D node_modules/queue-microtask/LICENSE +0 -20 collapsed to keep the page light
D node_modules/queue-microtask/README.md +0 -90 collapsed to keep the page light
D node_modules/queue-microtask/index.d.ts +0 -2 collapsed to keep the page light
D node_modules/queue-microtask/index.js +0 -9 collapsed to keep the page light
D node_modules/queue-microtask/package.json +0 -55 collapsed to keep the page light
D node_modules/read-cache/LICENSE +0 -20 collapsed to keep the page light
D node_modules/read-cache/README.md +0 -46 collapsed to keep the page light
D node_modules/read-cache/index.js +0 -78 collapsed to keep the page light
D node_modules/read-cache/package.json +0 -34 collapsed to keep the page light
D node_modules/readdirp/LICENSE +0 -21 collapsed to keep the page light
D node_modules/readdirp/README.md +0 -122 collapsed to keep the page light
D node_modules/readdirp/index.d.ts +0 -43 collapsed to keep the page light
D node_modules/readdirp/index.js +0 -287 collapsed to keep the page light
D node_modules/readdirp/package.json +0 -122 collapsed to keep the page light
D node_modules/resolve/.editorconfig +0 -37 collapsed to keep the page light
D node_modules/resolve/.eslintrc +0 -65 collapsed to keep the page light
D node_modules/resolve/.github/FUNDING.yml +0 -12 collapsed to keep the page light
D node_modules/resolve/LICENSE +0 -21 collapsed to keep the page light
D node_modules/resolve/SECURITY.md +0 -3 collapsed to keep the page light
D node_modules/resolve/async.js +0 -3 collapsed to keep the page light
D node_modules/resolve/bin/resolve +0 -50 collapsed to keep the page light
D node_modules/resolve/example/async.js +0 -5 collapsed to keep the page light
D node_modules/resolve/example/sync.js +0 -3 collapsed to keep the page light
D node_modules/resolve/index.js +0 -6 collapsed to keep the page light
D node_modules/resolve/lib/async.js +0 -329 collapsed to keep the page light
D node_modules/resolve/lib/caller.js +0 -8 collapsed to keep the page light
D node_modules/resolve/lib/core.js +0 -12 collapsed to keep the page light
D node_modules/resolve/lib/core.json +0 -158 collapsed to keep the page light
D node_modules/resolve/lib/homedir.js +0 -24 collapsed to keep the page light
D node_modules/resolve/lib/is-core.js +0 -5 collapsed to keep the page light
D node_modules/resolve/lib/node-modules-paths.js +0 -42 collapsed to keep the page light
D node_modules/resolve/lib/normalize-options.js +0 -10 collapsed to keep the page light
D node_modules/resolve/lib/sync.js +0 -208 collapsed to keep the page light
D node_modules/resolve/package.json +0 -72 collapsed to keep the page light
D node_modules/resolve/readme.markdown +0 -301 collapsed to keep the page light
D node_modules/resolve/sync.js +0 -3 collapsed to keep the page light
D node_modules/resolve/test/core.js +0 -88 collapsed to keep the page light
D node_modules/resolve/test/dotdot.js +0 -29 collapsed to keep the page light
D node_modules/resolve/test/dotdot/abc/index.js +0 -2 collapsed to keep the page light
D node_modules/resolve/test/dotdot/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/faulty_basedir.js +0 -29 collapsed to keep the page light
D node_modules/resolve/test/filter.js +0 -34 collapsed to keep the page light
D node_modules/resolve/test/filter_sync.js +0 -33 collapsed to keep the page light
D node_modules/resolve/test/home_paths.js +0 -127 collapsed to keep the page light
D node_modules/resolve/test/home_paths_sync.js +0 -114 collapsed to keep the page light
D node_modules/resolve/test/mock.js +0 -315 collapsed to keep the page light
D node_modules/resolve/test/mock_sync.js +0 -214 collapsed to keep the page light
D node_modules/resolve/test/module_dir.js +0 -56 collapsed to keep the page light
D node_modules/resolve/test/module_dir/xmodules/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/ymodules/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/zmodules/bbb/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/module_dir/zmodules/bbb/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/node-modules-paths.js +0 -143 collapsed to keep the page light
D node_modules/resolve/test/node_path.js +0 -70 collapsed to keep the page light
D node_modules/resolve/test/node_path/x/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/x/ccc/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/y/bbb/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/node_path/y/ccc/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/nonstring.js +0 -9 collapsed to keep the page light
D node_modules/resolve/test/pathfilter.js +0 -75 collapsed to keep the page light
D node_modules/resolve/test/pathfilter/deep_ref/main.js +0 -0
no textual change.
D node_modules/resolve/test/precedence.js +0 -23 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/aaa/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/bbb.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/precedence/bbb/main.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver.js +0 -597 collapsed to keep the page light
D node_modules/resolve/test/resolver/baz/doom.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/baz/package.json +0 -4 collapsed to keep the page light
D node_modules/resolve/test/resolver/baz/quux.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/browser_field/a.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/browser_field/b.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/browser_field/package.json +0 -5 collapsed to keep the page light
D node_modules/resolve/test/resolver/cup.coffee +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_main/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_slash_main/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/dot_slash_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/false_main/index.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/false_main/package.json +0 -4 collapsed to keep the page light
D node_modules/resolve/test/resolver/foo.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/incorrect_main/index.js +0 -2 collapsed to keep the page light
D node_modules/resolve/test/resolver/incorrect_main/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/invalid_main/package.json +0 -7 collapsed to keep the page light
D node_modules/resolve/test/resolver/mug.coffee +0 -0
no textual change.
D node_modules/resolve/test/resolver/mug.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/multirepo/lerna.json +0 -6 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/package.json +0 -20 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-a/index.js +0 -35 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-a/package.json +0 -14 collapsed to keep the page light
D node_modules/resolve/test/resolver/multirepo/packages/package-b/index.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/multirepo/packages/package-b/package.json +0 -14 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/async.js +0 -26 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/package.json +0 -15 collapsed to keep the page light
D node_modules/resolve/test/resolver/nested_symlinks/mylib/sync.js +0 -12 collapsed to keep the page light
D node_modules/resolve/test/resolver/other_path/lib/other-lib.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/other_path/root.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/quux/foo/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/same_names/foo.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/same_names/foo/index.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/symlinked/_/node_modules/foo.js +0 -0
no textual change.
D node_modules/resolve/test/resolver/symlinked/_/symlink_target/.gitkeep +0 -0
no textual change.
D node_modules/resolve/test/resolver/symlinked/package/bar.js +0 -1 collapsed to keep the page light
D node_modules/resolve/test/resolver/symlinked/package/package.json +0 -3 collapsed to keep the page light
D node_modules/resolve/test/resolver/without_basedir/main.js +0 -5 collapsed to keep the page light
D node_modules/resolve/test/resolver_sync.js +0 -730 collapsed to keep the page light
D node_modules/resolve/test/shadowed_core.js +0 -54 collapsed to keep the page light
D node_modules/resolve/test/shadowed_core/node_modules/util/index.js +0 -0
no textual change.