feat: Implement built-in reverse proxy with domain routing and TLS management
This comprehensive update replaces Traefik with a native Gordon reverse proxy, adding: - Automatic domain routing for containers - Let's Encrypt certificate management - HTTP to HTTPS redirects - Simplified configuration - Database support for proxy routes and certificates - Enhanced container deployment with automatic proxy configuration Key changes: - Added proxy package for reverse proxy functionality - Updated configuration to use ReverseProxy instead of Traefik - Introduced new database tables for domains, certificates, and proxy routes - Modified deployment process to automatically configure proxy routes - Updated CLI and web UI to reflect new proxy system - Improved Docker/container initialization and detection
- commit
- a22ca16f39cbbadb289c9b9d7a044c2010e99412
- author
- bamen · · 2025-03-09 07:41 UTC
- parent
- 1358ff2
- signature
- unsigned
30 files · +2130 -277
M README.md +8 -4
| @@ -11,8 +11,6 @@ | |||
| 11 | 11 | ||
| 12 | 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 | 13 | ||
| 14 | - | Optionally, Gordon can coordinate domain routing through Traefik (which needs to be pre-installed). This allows you to quickly deploy your applications to your desired domain or subdomain. | |
| 15 | - | ||
| 16 | 14 | ## **Key Features** | |
| 17 | 15 | ||
| 18 | 16 | - **Ease of use**: | |
| @@ -28,7 +26,7 @@ Optionally, Gordon can coordinate domain routing through Traefik (which needs to | |||
| 28 | 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. | |
| 29 | 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). | |
| 30 | 28 | - **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client. | |
| 31 | - | - **Automated Routing**: Collaborates with Traefik to automatically route your application to your desired domain or subdomain. | |
| 29 | + | - **Automated Routing**: Gordon automatically routes your application to your desired domain or subdomain, with no manual configuration needed. | |
| 32 | 30 | - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities. | |
| 33 | 31 | ||
| 34 | 32 | ## **Why ?** | |
| @@ -39,7 +37,7 @@ I needed a simple tool that would automate the process of quickly spin up my web | |||
| 39 | 37 | ||
| 40 | 38 | For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/) | |
| 41 | 39 | ||
| 42 | - | - [Setting up Gordon’s Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon%E2%80%99s-Backend) | |
| 40 | + | - [Setting up Gordon's Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Backend) | |
| 43 | 41 | - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client) | |
| 44 | 42 | - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment) | |
| 45 | 43 | ||
| @@ -75,3 +73,9 @@ Have suggestions? Feel free to open an issue! | |||
| 75 | 73 | ## **License** | |
| 76 | 74 | ||
| 77 | 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. | |
| @@ -11,8 +11,6 @@ | |||||
| 11 | 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. | 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 | 13 | ||||
| 14 | - | Optionally, Gordon can coordinate domain routing through Traefik (which needs to be pre-installed). This allows you to quickly deploy your applications to your desired domain or subdomain. | |||
| 15 | - | ||||
| 16 | ## **Key Features** | 14 | ## **Key Features** | ||
| 17 | 15 | ||||
| 18 | - **Ease of use**: | 16 | - **Ease of use**: | ||
| @@ -28,7 +26,7 @@ Optionally, Gordon can coordinate domain routing through Traefik (which needs to | |||||
| 28 | - **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. | 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. | ||
| 29 | - **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). | 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). | ||
| 30 | - **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client. | 28 | - **Simple authentication**: Use GitHub OAuth for the web UI and OAuth 2.0 device authorization grant (device flow) for the CLI client. | ||
| 31 | - | - **Automated Routing**: Collaborates with Traefik to automatically route your application to your desired domain or subdomain. | 29 | + | - **Automated Routing**: Gordon automatically routes your application to your desired domain or subdomain, with no manual configuration needed. |
| 32 | - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities. | 30 | - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities. | ||
| 33 | 31 | ||||
| 34 | ## **Why ?** | 32 | ## **Why ?** | ||
| @@ -39,7 +37,7 @@ I needed a simple tool that would automate the process of quickly spin up my web | |||||
| 39 | 37 | ||||
| 40 | For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/) | 38 | For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/) | ||
| 41 | 39 | ||||
| 42 | - | - [Setting up Gordon’s Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon%E2%80%99s-Backend) | 40 | + | - [Setting up Gordon's Backend](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Backend) |
| 43 | - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client) | 41 | - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client) | ||
| 44 | - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment) | 42 | - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment) | ||
| 45 | 43 | ||||
| @@ -75,3 +73,9 @@ Have suggestions? Feel free to open an issue! | |||||
| 75 | ## **License** | 73 | ## **License** | ||
| 76 | 74 | ||||
| 77 | Gordon is licensed under the GPL-3.0 license. Please see the LICENSE file for more details. | 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. | |||
M cmd/gordon.go +1 -2
| @@ -26,6 +26,7 @@ func InitializeCommands(client *cli.App, server *server.App) { | |||
| 26 | 26 | rootCmd.AddCommand(cmd.NewUpdateCommand(client)) | |
| 27 | 27 | rootCmd.AddCommand(cmd.NewPushCommand(client)) | |
| 28 | 28 | rootCmd.AddCommand(cmd.NewVersionCommand(client)) | |
| 29 | + | rootCmd.AddCommand(cmd.NewProxyCommand(server)) | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | func Execute(client *cli.App, server *server.App) { | |
| @@ -52,8 +53,6 @@ func ExecuteCLI(build, commit, date string) { | |||
| 52 | 53 | fmt.Println("Error initializing app:", err) | |
| 53 | 54 | } | |
| 54 | 55 | ||
| 55 | - | common.DockerInit(&s.Config.ContainerEngine) | |
| 56 | - | ||
| 57 | 56 | // Start periodic version checking in the background | |
| 58 | 57 | go common.CheckVersionPeriodically(versionInfo, 3*time.Hour) | |
| 59 | 58 | ||
| @@ -26,6 +26,7 @@ func InitializeCommands(client *cli.App, server *server.App) { | |||||
| 26 | rootCmd.AddCommand(cmd.NewUpdateCommand(client)) | 26 | rootCmd.AddCommand(cmd.NewUpdateCommand(client)) | ||
| 27 | rootCmd.AddCommand(cmd.NewPushCommand(client)) | 27 | rootCmd.AddCommand(cmd.NewPushCommand(client)) | ||
| 28 | rootCmd.AddCommand(cmd.NewVersionCommand(client)) | 28 | rootCmd.AddCommand(cmd.NewVersionCommand(client)) | ||
| 29 | + | rootCmd.AddCommand(cmd.NewProxyCommand(server)) | |||
| 29 | } | 30 | } | ||
| 30 | 31 | ||||
| 31 | func Execute(client *cli.App, server *server.App) { | 32 | func Execute(client *cli.App, server *server.App) { | ||
| @@ -52,8 +53,6 @@ func ExecuteCLI(build, commit, date string) { | |||||
| 52 | fmt.Println("Error initializing app:", err) | 53 | fmt.Println("Error initializing app:", err) | ||
| 53 | } | 54 | } | ||
| 54 | 55 | ||||
| 55 | - | common.DockerInit(&s.Config.ContainerEngine) | |||
| 56 | - | ||||
| 57 | // Start periodic version checking in the background | 56 | // Start periodic version checking in the background | ||
| 58 | go common.CheckVersionPeriodically(versionInfo, 3*time.Hour) | 57 | go common.CheckVersionPeriodically(versionInfo, 3*time.Hour) | ||
| 59 | 58 | ||||
A docker-compose.yml.example +112 -0
| @@ -0,0 +1,112 @@ | |||
| 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/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |
| 11 | + | # For Docker: | |
| 12 | + | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | |
| 13 | + | # For Podman (uncomment and comment the Docker line above): | |
| 14 | + | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock # For Podman | |
| 15 | + | ports: | |
| 16 | + | - "8080:8080" # HTTP API (Gordon main server) | |
| 17 | + | - "443:443" # HTTPS for reverse proxy | |
| 18 | + | - "80:80" # HTTP for reverse proxy | |
| 19 | + | environment: | |
| 20 | + | # General Configuration | |
| 21 | + | - GORDON_STORAGE_DIR=/data | |
| 22 | + | - GORDON_TOKEN=your_secure_token | |
| 23 | + | ||
| 24 | + | # HTTP Configuration | |
| 25 | + | - GORDON_HTTP_PORT=8080 # IMPORTANT: API port must be different from proxy HTTP port | |
| 26 | + | - GORDON_HTTP_DOMAIN=example.com | |
| 27 | + | - GORDON_HTTP_SUBDOMAIN=gordon | |
| 28 | + | - GORDON_HTTP_HTTPS=true | |
| 29 | + | ||
| 30 | + | # Container Engine Configuration - Docker | |
| 31 | + | - GORDON_CONTAINER_SOCK=/var/run/docker.sock | |
| 32 | + | - GORDON_CONTAINER_NETWORK=gordon | |
| 33 | + | ||
| 34 | + | # Container Engine Configuration - Podman (Method 1: Auto-detection) | |
| 35 | + | # Just specify the Podman socket path and Gordon will auto-detect Podman: | |
| 36 | + | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |
| 37 | + | ||
| 38 | + | # Container Engine Configuration - Podman (Method 2: Explicit Configuration) | |
| 39 | + | # - GORDON_CONTAINER_PODMAN=true | |
| 40 | + | # - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock | |
| 41 | + | # - GORDON_CONTAINER_SOCK=/run/podman/podman.sock | |
| 42 | + | ||
| 43 | + | # Reverse Proxy Configuration | |
| 44 | + | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 45 | + | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 46 | + | - GORDON_PROXY_CERT_DIR=/certs | |
| 47 | + | - GORDON_PROXY_EMAIL=admin@example.com | |
| 48 | + | ||
| 49 | + | # Session Secret for security | |
| 50 | + | - SESSION_SECRET=replace_with_secure_random_string | |
| 51 | + | ||
| 52 | + | # Runtime Environment | |
| 53 | + | - RUN_ENV=prod | |
| 54 | + | restart: always | |
| 55 | + | networks: | |
| 56 | + | - gordon | |
| 57 | + | ||
| 58 | + | networks: | |
| 59 | + | gordon: | |
| 60 | + | external: false | |
| 61 | + | ||
| 62 | + | # ------------------------------------- | |
| 63 | + | # Example for Podman Users (podman-compose) | |
| 64 | + | # ------------------------------------- | |
| 65 | + | # version: '3' | |
| 66 | + | # | |
| 67 | + | # services: | |
| 68 | + | # gordon: | |
| 69 | + | # image: ghcr.io/bnema/gordon:latest | |
| 70 | + | # container_name: gordon | |
| 71 | + | # volumes: | |
| 72 | + | # - ./data:/data | |
| 73 | + | # - ./data/config.yml:/config.yml | |
| 74 | + | # - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |
| 75 | + | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock | |
| 76 | + | # ports: | |
| 77 | + | # - "8080:8080" # HTTP API (Gordon main server) | |
| 78 | + | # - "443:443" # HTTPS for reverse proxy | |
| 79 | + | # - "80:80" # HTTP for reverse proxy | |
| 80 | + | # environment: | |
| 81 | + | # # General Configuration | |
| 82 | + | # - GORDON_STORAGE_DIR=/data | |
| 83 | + | # - GORDON_TOKEN=your_secure_token | |
| 84 | + | # | |
| 85 | + | # # HTTP Configuration | |
| 86 | + | # - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port | |
| 87 | + | # - GORDON_HTTP_DOMAIN=example.com | |
| 88 | + | # - GORDON_HTTP_SUBDOMAIN=gordon | |
| 89 | + | # - GORDON_HTTP_HTTPS=true | |
| 90 | + | # | |
| 91 | + | # # Container Engine Configuration | |
| 92 | + | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |
| 93 | + | # - GORDON_CONTAINER_NETWORK=gordon | |
| 94 | + | # | |
| 95 | + | # # Reverse Proxy Configuration | |
| 96 | + | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |
| 97 | + | # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |
| 98 | + | # - GORDON_PROXY_CERT_DIR=/certs | |
| 99 | + | # - GORDON_PROXY_EMAIL=admin@example.com | |
| 100 | + | # | |
| 101 | + | # # Session Secret for security | |
| 102 | + | # - SESSION_SECRET=replace_with_secure_random_string | |
| 103 | + | # | |
| 104 | + | # # Runtime Environment | |
| 105 | + | # - RUN_ENV=prod | |
| 106 | + | # restart: always | |
| 107 | + | # networks: | |
| 108 | + | # - gordon | |
| 109 | + | # | |
| 110 | + | # networks: | |
| 111 | + | # gordon: | |
| 112 | + | # external: false ⏎̸ | |
| @@ -0,0 +1,112 @@ | |||||
| 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/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |||
| 11 | + | # For Docker: | |||
| 12 | + | - /var/run/docker.sock:/var/run/docker.sock # Required for Docker management | |||
| 13 | + | # For Podman (uncomment and comment the Docker line above): | |||
| 14 | + | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock # For Podman | |||
| 15 | + | ports: | |||
| 16 | + | - "8080:8080" # HTTP API (Gordon main server) | |||
| 17 | + | - "443:443" # HTTPS for reverse proxy | |||
| 18 | + | - "80:80" # HTTP for reverse proxy | |||
| 19 | + | environment: | |||
| 20 | + | # General Configuration | |||
| 21 | + | - GORDON_STORAGE_DIR=/data | |||
| 22 | + | - GORDON_TOKEN=your_secure_token | |||
| 23 | + | ||||
| 24 | + | # HTTP Configuration | |||
| 25 | + | - GORDON_HTTP_PORT=8080 # IMPORTANT: API port must be different from proxy HTTP port | |||
| 26 | + | - GORDON_HTTP_DOMAIN=example.com | |||
| 27 | + | - GORDON_HTTP_SUBDOMAIN=gordon | |||
| 28 | + | - GORDON_HTTP_HTTPS=true | |||
| 29 | + | ||||
| 30 | + | # Container Engine Configuration - Docker | |||
| 31 | + | - GORDON_CONTAINER_SOCK=/var/run/docker.sock | |||
| 32 | + | - GORDON_CONTAINER_NETWORK=gordon | |||
| 33 | + | ||||
| 34 | + | # Container Engine Configuration - Podman (Method 1: Auto-detection) | |||
| 35 | + | # Just specify the Podman socket path and Gordon will auto-detect Podman: | |||
| 36 | + | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |||
| 37 | + | ||||
| 38 | + | # Container Engine Configuration - Podman (Method 2: Explicit Configuration) | |||
| 39 | + | # - GORDON_CONTAINER_PODMAN=true | |||
| 40 | + | # - GORDON_CONTAINER_PODMAN_SOCK=/run/podman/podman.sock | |||
| 41 | + | # - GORDON_CONTAINER_SOCK=/run/podman/podman.sock | |||
| 42 | + | ||||
| 43 | + | # Reverse Proxy Configuration | |||
| 44 | + | - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |||
| 45 | + | - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |||
| 46 | + | - GORDON_PROXY_CERT_DIR=/certs | |||
| 47 | + | - GORDON_PROXY_EMAIL=admin@example.com | |||
| 48 | + | ||||
| 49 | + | # Session Secret for security | |||
| 50 | + | - SESSION_SECRET=replace_with_secure_random_string | |||
| 51 | + | ||||
| 52 | + | # Runtime Environment | |||
| 53 | + | - RUN_ENV=prod | |||
| 54 | + | restart: always | |||
| 55 | + | networks: | |||
| 56 | + | - gordon | |||
| 57 | + | ||||
| 58 | + | networks: | |||
| 59 | + | gordon: | |||
| 60 | + | external: false | |||
| 61 | + | ||||
| 62 | + | # ------------------------------------- | |||
| 63 | + | # Example for Podman Users (podman-compose) | |||
| 64 | + | # ------------------------------------- | |||
| 65 | + | # version: '3' | |||
| 66 | + | # | |||
| 67 | + | # services: | |||
| 68 | + | # gordon: | |||
| 69 | + | # image: ghcr.io/bnema/gordon:latest | |||
| 70 | + | # container_name: gordon | |||
| 71 | + | # volumes: | |||
| 72 | + | # - ./data:/data | |||
| 73 | + | # - ./data/config.yml:/config.yml | |||
| 74 | + | # - ./data/certs:/certs # Mount certs directory to persist Let's Encrypt certificates | |||
| 75 | + | # - /run/user/1000/podman/podman.sock:/var/run/docker.sock | |||
| 76 | + | # ports: | |||
| 77 | + | # - "8080:8080" # HTTP API (Gordon main server) | |||
| 78 | + | # - "443:443" # HTTPS for reverse proxy | |||
| 79 | + | # - "80:80" # HTTP for reverse proxy | |||
| 80 | + | # environment: | |||
| 81 | + | # # General Configuration | |||
| 82 | + | # - GORDON_STORAGE_DIR=/data | |||
| 83 | + | # - GORDON_TOKEN=your_secure_token | |||
| 84 | + | # | |||
| 85 | + | # # HTTP Configuration | |||
| 86 | + | # - GORDON_HTTP_PORT=8080 # API port must be different from proxy HTTP port | |||
| 87 | + | # - GORDON_HTTP_DOMAIN=example.com | |||
| 88 | + | # - GORDON_HTTP_SUBDOMAIN=gordon | |||
| 89 | + | # - GORDON_HTTP_HTTPS=true | |||
| 90 | + | # | |||
| 91 | + | # # Container Engine Configuration | |||
| 92 | + | # - GORDON_CONTAINER_SOCK=/run/user/1000/podman/podman.sock | |||
| 93 | + | # - GORDON_CONTAINER_NETWORK=gordon | |||
| 94 | + | # | |||
| 95 | + | # # Reverse Proxy Configuration | |||
| 96 | + | # - GORDON_PROXY_PORT=443 # HTTPS port for reverse proxy | |||
| 97 | + | # - GORDON_PROXY_HTTP_PORT=80 # HTTP port for reverse proxy | |||
| 98 | + | # - GORDON_PROXY_CERT_DIR=/certs | |||
| 99 | + | # - GORDON_PROXY_EMAIL=admin@example.com | |||
| 100 | + | # | |||
| 101 | + | # # Session Secret for security | |||
| 102 | + | # - SESSION_SECRET=replace_with_secure_random_string | |||
| 103 | + | # | |||
| 104 | + | # # Runtime Environment | |||
| 105 | + | # - RUN_ENV=prod | |||
| 106 | + | # restart: always | |||
| 107 | + | # networks: | |||
| 108 | + | # - gordon | |||
| 109 | + | # | |||
| 110 | + | # networks: | |||
| 111 | + | # gordon: | |||
| 112 | + | # external: false ⏎̸ | |||
A docs/CONTAINER_CONFIG.md +202 -0
| @@ -0,0 +1,202 @@ | |||
| 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 | + | ||
| 187 | + | ### Other Configuration | |
| 188 | + | ||
| 189 | + | | Environment Variable | Description | Default | | |
| 190 | + | |---------------------|-------------|---------| | |
| 191 | + | | `SESSION_SECRET` | Secret for session encryption | Required | | |
| 192 | + | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | |
| 193 | + | ||
| 194 | + | ## Secrets Management | |
| 195 | + | ||
| 196 | + | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | |
| 197 | + | ||
| 198 | + | ## Persistence | |
| 199 | + | ||
| 200 | + | To ensure your data persists between container restarts, mount a volume to the `/data` directory or the path specified in `GORDON_STORAGE_DIR`: | |
| 201 | + | ||
| 202 | + | ```⏎̸ | |
| @@ -0,0 +1,202 @@ | |||||
| 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 | + | ||||
| 187 | + | ### Other Configuration | |||
| 188 | + | ||||
| 189 | + | | Environment Variable | Description | Default | | |||
| 190 | + | |---------------------|-------------|---------| | |||
| 191 | + | | `SESSION_SECRET` | Secret for session encryption | Required | | |||
| 192 | + | | `RUN_ENV` | Runtime environment (`dev` or `prod`) | `prod` | | |||
| 193 | + | ||||
| 194 | + | ## Secrets Management | |||
| 195 | + | ||||
| 196 | + | For sensitive values like `GORDON_TOKEN` and `SESSION_SECRET`, consider using Docker secrets or a proper secrets management system in production deployments. | |||
| 197 | + | ||||
| 198 | + | ## Persistence | |||
| 199 | + | ||||
| 200 | + | To ensure your data persists between container restarts, mount a volume to the `/data` directory or the path specified in `GORDON_STORAGE_DIR`: | |||
| 201 | + | ||||
| 202 | + | ```⏎̸ | |||
M go.mod +9 -10
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | module github.com/bnema/gordon | |
| 2 | 2 | ||
| 3 | - | go 1.23 | |
| 3 | + | go 1.24.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | |
| @@ -15,7 +15,7 @@ require ( | |||
| 15 | 15 | github.com/gorilla/sessions v1.4.0 | |
| 16 | 16 | github.com/joho/godotenv v1.5.1 | |
| 17 | 17 | github.com/labstack/echo-contrib v0.17.1 | |
| 18 | - | github.com/labstack/echo/v4 v4.12.0 | |
| 18 | + | github.com/labstack/echo/v4 v4.13.3 | |
| 19 | 19 | github.com/microcosm-cc/bluemonday v1.0.27 | |
| 20 | 20 | github.com/pocketbase/pocketbase v0.22.19 | |
| 21 | 21 | github.com/spf13/cobra v1.8.1 | |
| @@ -60,8 +60,8 @@ require ( | |||
| 60 | 60 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | |
| 61 | 61 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | |
| 62 | 62 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct | |
| 63 | - | golang.org/x/sync v0.8.0 // indirect | |
| 64 | - | golang.org/x/term v0.23.0 // indirect | |
| 63 | + | golang.org/x/sync v0.12.0 // indirect | |
| 64 | + | golang.org/x/term v0.30.0 // indirect | |
| 65 | 65 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | |
| 66 | 66 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | |
| 67 | 67 | modernc.org/libc v1.59.9 // indirect | |
| @@ -77,7 +77,6 @@ require ( | |||
| 77 | 77 | github.com/aymerick/douceur v0.2.0 // indirect | |
| 78 | 78 | github.com/docker/go-units v0.5.0 // indirect | |
| 79 | 79 | github.com/gogo/protobuf v1.3.2 // indirect | |
| 80 | - | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect | |
| 81 | 80 | github.com/google/uuid v1.6.0 // direct | |
| 82 | 81 | github.com/gorilla/context v1.1.2 // indirect | |
| 83 | 82 | github.com/gorilla/css v1.0.1 // indirect | |
| @@ -93,11 +92,11 @@ require ( | |||
| 93 | 92 | github.com/rogpeppe/go-internal v1.11.0 // indirect | |
| 94 | 93 | github.com/valyala/bytebufferpool v1.0.0 // indirect | |
| 95 | 94 | github.com/valyala/fasttemplate v1.2.2 // indirect | |
| 96 | - | golang.org/x/crypto v0.26.0 // indirect | |
| 97 | - | golang.org/x/net v0.28.0 // indirect | |
| 98 | - | golang.org/x/sys v0.24.0 // indirect | |
| 99 | - | golang.org/x/text v0.17.0 // indirect | |
| 100 | - | golang.org/x/time v0.6.0 // indirect | |
| 95 | + | golang.org/x/crypto v0.36.0 | |
| 96 | + | golang.org/x/net v0.33.0 // indirect | |
| 97 | + | golang.org/x/sys v0.31.0 // indirect | |
| 98 | + | golang.org/x/text v0.23.0 // indirect | |
| 99 | + | golang.org/x/time v0.8.0 // indirect | |
| 101 | 100 | golang.org/x/tools v0.24.0 // indirect | |
| 102 | 101 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | |
| 103 | 102 | gotest.tools/v3 v3.5.0 // indirect | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | module github.com/bnema/gordon | 1 | module github.com/bnema/gordon | ||
| 2 | 2 | ||||
| 3 | - | go 1.23 | 3 | + | go 1.24.1 |
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | 6 | github.com/AlecAivazis/survey/v2 v2.3.7 | ||
| @@ -15,7 +15,7 @@ require ( | |||||
| 15 | github.com/gorilla/sessions v1.4.0 | 15 | github.com/gorilla/sessions v1.4.0 | ||
| 16 | github.com/joho/godotenv v1.5.1 | 16 | github.com/joho/godotenv v1.5.1 | ||
| 17 | github.com/labstack/echo-contrib v0.17.1 | 17 | github.com/labstack/echo-contrib v0.17.1 | ||
| 18 | - | github.com/labstack/echo/v4 v4.12.0 | 18 | + | github.com/labstack/echo/v4 v4.13.3 |
| 19 | github.com/microcosm-cc/bluemonday v1.0.27 | 19 | github.com/microcosm-cc/bluemonday v1.0.27 | ||
| 20 | github.com/pocketbase/pocketbase v0.22.19 | 20 | github.com/pocketbase/pocketbase v0.22.19 | ||
| 21 | github.com/spf13/cobra v1.8.1 | 21 | github.com/spf13/cobra v1.8.1 | ||
| @@ -60,8 +60,8 @@ require ( | |||||
| 60 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | 60 | go.opentelemetry.io/otel/sdk v1.21.0 // indirect | ||
| 61 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | 61 | go.opentelemetry.io/otel/trace v1.29.0 // indirect | ||
| 62 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct | 62 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct | ||
| 63 | - | golang.org/x/sync v0.8.0 // indirect | 63 | + | golang.org/x/sync v0.12.0 // indirect |
| 64 | - | golang.org/x/term v0.23.0 // indirect | 64 | + | golang.org/x/term v0.30.0 // indirect |
| 65 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | 65 | google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect | ||
| 66 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | 66 | modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect | ||
| 67 | modernc.org/libc v1.59.9 // indirect | 67 | modernc.org/libc v1.59.9 // indirect | ||
| @@ -77,7 +77,6 @@ require ( | |||||
| 77 | github.com/aymerick/douceur v0.2.0 // indirect | 77 | github.com/aymerick/douceur v0.2.0 // indirect | ||
| 78 | github.com/docker/go-units v0.5.0 // indirect | 78 | github.com/docker/go-units v0.5.0 // indirect | ||
| 79 | github.com/gogo/protobuf v1.3.2 // indirect | 79 | github.com/gogo/protobuf v1.3.2 // indirect | ||
| 80 | - | github.com/golang-jwt/jwt v3.2.2+incompatible // indirect | |||
| 81 | github.com/google/uuid v1.6.0 // direct | 80 | github.com/google/uuid v1.6.0 // direct | ||
| 82 | github.com/gorilla/context v1.1.2 // indirect | 81 | github.com/gorilla/context v1.1.2 // indirect | ||
| 83 | github.com/gorilla/css v1.0.1 // indirect | 82 | github.com/gorilla/css v1.0.1 // indirect | ||
| @@ -93,11 +92,11 @@ require ( | |||||
| 93 | github.com/rogpeppe/go-internal v1.11.0 // indirect | 92 | github.com/rogpeppe/go-internal v1.11.0 // indirect | ||
| 94 | github.com/valyala/bytebufferpool v1.0.0 // indirect | 93 | github.com/valyala/bytebufferpool v1.0.0 // indirect | ||
| 95 | github.com/valyala/fasttemplate v1.2.2 // indirect | 94 | github.com/valyala/fasttemplate v1.2.2 // indirect | ||
| 96 | - | golang.org/x/crypto v0.26.0 // indirect | 95 | + | golang.org/x/crypto v0.36.0 |
| 97 | - | golang.org/x/net v0.28.0 // indirect | 96 | + | golang.org/x/net v0.33.0 // indirect |
| 98 | - | golang.org/x/sys v0.24.0 // indirect | 97 | + | golang.org/x/sys v0.31.0 // indirect |
| 99 | - | golang.org/x/text v0.17.0 // indirect | 98 | + | golang.org/x/text v0.23.0 // indirect |
| 100 | - | golang.org/x/time v0.6.0 // indirect | 99 | + | golang.org/x/time v0.8.0 // indirect |
| 101 | golang.org/x/tools v0.24.0 // indirect | 100 | golang.org/x/tools v0.24.0 // indirect | ||
| 102 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | 101 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect | ||
| 103 | gotest.tools/v3 v3.5.0 // indirect | 102 | gotest.tools/v3 v3.5.0 // indirect | ||
M go.sum +18 -20 generated file: collapsed
M internal/cli/cmd/deploy.go +2 -2
| @@ -65,8 +65,8 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||
| 65 | 65 | }, | |
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container and Traefik entry point") | |
| 69 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | |
| 68 | + | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") | |
| 69 | + | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy") | |
| 70 | 70 | ||
| 71 | 71 | return deployCmd | |
| 72 | 72 | } | |
| @@ -65,8 +65,8 @@ func NewDeployCommand(a *cli.App) *cobra.Command { | |||||
| 65 | }, | 65 | }, | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Port mapping for the container and Traefik entry point") | 68 | + | deployCmd.Flags().StringVarP(&port, "port", "p", "", "Container port for the proxy to route traffic to") |
| 69 | - | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for Traefik") | 69 | + | deployCmd.Flags().StringVarP(&targetDomain, "target", "t", "", "Target domain for the proxy") |
| 70 | 70 | ||||
| 71 | return deployCmd | 71 | return deployCmd | ||
| 72 | } | 72 | } | ||
A internal/cli/cmd/proxy.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package cmd | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/gordon/internal/proxy" | |
| 5 | + | "github.com/bnema/gordon/internal/server" | |
| 6 | + | "github.com/charmbracelet/log" | |
| 7 | + | "github.com/spf13/cobra" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | // NewProxyCommand creates a new proxy command | |
| 11 | + | func NewProxyCommand(a *server.App) *cobra.Command { | |
| 12 | + | var ( | |
| 13 | + | domain string | |
| 14 | + | containerID string | |
| 15 | + | containerIP string | |
| 16 | + | containerPort string | |
| 17 | + | protocol string | |
| 18 | + | path string | |
| 19 | + | remove bool | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | proxyCmd := &cobra.Command{ | |
| 23 | + | Use: "proxy", | |
| 24 | + | Short: "Manage the reverse proxy routes", | |
| 25 | + | Long: "Add, remove, or list proxy routes for domains to containers", | |
| 26 | + | Run: func(cmd *cobra.Command, args []string) { | |
| 27 | + | // Initialize the database if not already initialized | |
| 28 | + | _, err := server.InitializeDB(a) | |
| 29 | + | if err != nil { | |
| 30 | + | log.Fatal("Failed to initialize database:", err) | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Create a new proxy instance | |
| 34 | + | p, err := proxy.NewProxy(a) | |
| 35 | + | if err != nil { | |
| 36 | + | log.Fatal("Failed to create proxy:", err) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // If remove flag is set, remove the route | |
| 40 | + | if remove { | |
| 41 | + | if domain == "" { | |
| 42 | + | log.Fatal("Domain is required for removing a route") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if err := p.RemoveRoute(domain); err != nil { | |
| 46 | + | log.Fatal("Failed to remove route:", err) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | log.Info("Route removed successfully", "domain", domain) | |
| 50 | + | return | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // If domain is set, add a new route | |
| 54 | + | if domain != "" { | |
| 55 | + | // Validate required fields | |
| 56 | + | if containerID == "" || containerIP == "" || containerPort == "" { | |
| 57 | + | log.Fatal("Container ID, IP, and port are required for adding a route") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // Set defaults for optional fields | |
| 61 | + | if protocol == "" { | |
| 62 | + | protocol = "http" | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | if path == "" { | |
| 66 | + | path = "/" | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Add the route | |
| 70 | + | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | |
| 71 | + | log.Fatal("Failed to add route:", err) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | log.Info("Route added successfully", | |
| 75 | + | "domain", domain, | |
| 76 | + | "containerIP", containerIP, | |
| 77 | + | "containerPort", containerPort, | |
| 78 | + | ) | |
| 79 | + | return | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // If no flags are set, list all routes | |
| 83 | + | if err := p.Reload(); err != nil { | |
| 84 | + | log.Fatal("Failed to load routes:", err) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | // List routes from the database | |
| 88 | + | rows, err := a.DB.Query(` | |
| 89 | + | SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.active | |
| 90 | + | FROM proxy_route pr | |
| 91 | + | JOIN domain d ON pr.domain_id = d.id | |
| 92 | + | `) | |
| 93 | + | if err != nil { | |
| 94 | + | log.Fatal("Failed to query routes:", err) | |
| 95 | + | } | |
| 96 | + | defer rows.Close() | |
| 97 | + | ||
| 98 | + | var count int | |
| 99 | + | for rows.Next() { | |
| 100 | + | var domain, containerID, containerIP, containerPort string | |
| 101 | + | var active bool | |
| 102 | + | if err := rows.Scan(&domain, &containerID, &containerIP, &containerPort, &active); err != nil { | |
| 103 | + | log.Fatal("Failed to scan row:", err) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | log.Info("Route", | |
| 107 | + | "domain", domain, | |
| 108 | + | "containerIP", containerIP, | |
| 109 | + | "containerPort", containerPort, | |
| 110 | + | "active", active, | |
| 111 | + | ) | |
| 112 | + | count++ | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | log.Info("Proxy routes loaded", "count", count) | |
| 116 | + | }, | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // Add flags | |
| 120 | + | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name for the route") | |
| 121 | + | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | |
| 122 | + | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | |
| 123 | + | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | |
| 124 | + | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | |
| 125 | + | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | |
| 126 | + | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | |
| 127 | + | ||
| 128 | + | return proxyCmd | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | package cmd | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/gordon/internal/proxy" | |||
| 5 | + | "github.com/bnema/gordon/internal/server" | |||
| 6 | + | "github.com/charmbracelet/log" | |||
| 7 | + | "github.com/spf13/cobra" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | // NewProxyCommand creates a new proxy command | |||
| 11 | + | func NewProxyCommand(a *server.App) *cobra.Command { | |||
| 12 | + | var ( | |||
| 13 | + | domain string | |||
| 14 | + | containerID string | |||
| 15 | + | containerIP string | |||
| 16 | + | containerPort string | |||
| 17 | + | protocol string | |||
| 18 | + | path string | |||
| 19 | + | remove bool | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | proxyCmd := &cobra.Command{ | |||
| 23 | + | Use: "proxy", | |||
| 24 | + | Short: "Manage the reverse proxy routes", | |||
| 25 | + | Long: "Add, remove, or list proxy routes for domains to containers", | |||
| 26 | + | Run: func(cmd *cobra.Command, args []string) { | |||
| 27 | + | // Initialize the database if not already initialized | |||
| 28 | + | _, err := server.InitializeDB(a) | |||
| 29 | + | if err != nil { | |||
| 30 | + | log.Fatal("Failed to initialize database:", err) | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Create a new proxy instance | |||
| 34 | + | p, err := proxy.NewProxy(a) | |||
| 35 | + | if err != nil { | |||
| 36 | + | log.Fatal("Failed to create proxy:", err) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // If remove flag is set, remove the route | |||
| 40 | + | if remove { | |||
| 41 | + | if domain == "" { | |||
| 42 | + | log.Fatal("Domain is required for removing a route") | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if err := p.RemoveRoute(domain); err != nil { | |||
| 46 | + | log.Fatal("Failed to remove route:", err) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | log.Info("Route removed successfully", "domain", domain) | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // If domain is set, add a new route | |||
| 54 | + | if domain != "" { | |||
| 55 | + | // Validate required fields | |||
| 56 | + | if containerID == "" || containerIP == "" || containerPort == "" { | |||
| 57 | + | log.Fatal("Container ID, IP, and port are required for adding a route") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | // Set defaults for optional fields | |||
| 61 | + | if protocol == "" { | |||
| 62 | + | protocol = "http" | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | if path == "" { | |||
| 66 | + | path = "/" | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Add the route | |||
| 70 | + | if err := p.AddRoute(domain, containerID, containerIP, containerPort, protocol, path); err != nil { | |||
| 71 | + | log.Fatal("Failed to add route:", err) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | log.Info("Route added successfully", | |||
| 75 | + | "domain", domain, | |||
| 76 | + | "containerIP", containerIP, | |||
| 77 | + | "containerPort", containerPort, | |||
| 78 | + | ) | |||
| 79 | + | return | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | // If no flags are set, list all routes | |||
| 83 | + | if err := p.Reload(); err != nil { | |||
| 84 | + | log.Fatal("Failed to load routes:", err) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | // List routes from the database | |||
| 88 | + | rows, err := a.DB.Query(` | |||
| 89 | + | SELECT d.name, pr.container_id, pr.container_ip, pr.container_port, pr.active | |||
| 90 | + | FROM proxy_route pr | |||
| 91 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 92 | + | `) | |||
| 93 | + | if err != nil { | |||
| 94 | + | log.Fatal("Failed to query routes:", err) | |||
| 95 | + | } | |||
| 96 | + | defer rows.Close() | |||
| 97 | + | ||||
| 98 | + | var count int | |||
| 99 | + | for rows.Next() { | |||
| 100 | + | var domain, containerID, containerIP, containerPort string | |||
| 101 | + | var active bool | |||
| 102 | + | if err := rows.Scan(&domain, &containerID, &containerIP, &containerPort, &active); err != nil { | |||
| 103 | + | log.Fatal("Failed to scan row:", err) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | log.Info("Route", | |||
| 107 | + | "domain", domain, | |||
| 108 | + | "containerIP", containerIP, | |||
| 109 | + | "containerPort", containerPort, | |||
| 110 | + | "active", active, | |||
| 111 | + | ) | |||
| 112 | + | count++ | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | log.Info("Proxy routes loaded", "count", count) | |||
| 116 | + | }, | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Add flags | |||
| 120 | + | proxyCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain name for the route") | |||
| 121 | + | proxyCmd.Flags().StringVarP(&containerID, "container", "c", "", "Container ID") | |||
| 122 | + | proxyCmd.Flags().StringVarP(&containerIP, "ip", "i", "", "Container IP address") | |||
| 123 | + | proxyCmd.Flags().StringVarP(&containerPort, "port", "p", "", "Container port") | |||
| 124 | + | proxyCmd.Flags().StringVarP(&protocol, "protocol", "t", "http", "Protocol (http or https)") | |||
| 125 | + | proxyCmd.Flags().StringVarP(&path, "path", "a", "/", "Path prefix to route") | |||
| 126 | + | proxyCmd.Flags().BoolVarP(&remove, "remove", "r", false, "Remove the route for the specified domain") | |||
| 127 | + | ||||
| 128 | + | return proxyCmd | |||
| 129 | + | } | |||
M internal/cli/cmd/serve.go +12 -2
| @@ -3,7 +3,10 @@ | |||
| 3 | 3 | package cmd | |
| 4 | 4 | ||
| 5 | 5 | import ( | |
| 6 | + | "fmt" | |
| 7 | + | ||
| 6 | 8 | "github.com/bnema/gordon/internal/cli/handler" | |
| 9 | + | "github.com/bnema/gordon/internal/common" | |
| 7 | 10 | "github.com/bnema/gordon/internal/server" | |
| 8 | 11 | "github.com/bnema/gordon/pkg/docker" | |
| 9 | 12 | "github.com/spf13/cobra" | |
| @@ -14,9 +17,10 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||
| 14 | 17 | defaultport := "1323" | |
| 15 | 18 | var port string | |
| 16 | 19 | ||
| 17 | - | // if the server is running in a docker container, the default port is 80 | |
| 20 | + | // if the server is running in a docker container, the default port is 8080 | |
| 21 | + | // (changed from 80 to avoid conflict with the reverse proxy) | |
| 18 | 22 | if docker.IsRunningInContainer() { | |
| 19 | - | defaultport = "80" | |
| 23 | + | defaultport = "8080" | |
| 20 | 24 | } | |
| 21 | 25 | ||
| 22 | 26 | // if no flags -p or --port are specified, the default port is used | |
| @@ -25,6 +29,12 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||
| 25 | 29 | Short: "Start a new Gordon server instance", | |
| 26 | 30 | Args: cobra.NoArgs, | |
| 27 | 31 | Run: func(cmd *cobra.Command, args []string) { | |
| 32 | + | // Initialize Docker client here, before starting the server | |
| 33 | + | if err := common.DockerInit(&a.Config.ContainerEngine); err != nil { | |
| 34 | + | fmt.Printf("Warning: %s\n", err) | |
| 35 | + | fmt.Println("Continuing with Docker/Podman functionality disabled") | |
| 36 | + | } | |
| 37 | + | ||
| 28 | 38 | // handler.StartServer will use the value of port, which will be the default | |
| 29 | 39 | // value if no flag is specified | |
| 30 | 40 | a.Config.Http.Port = port | |
| @@ -3,7 +3,10 @@ | |||||
| 3 | package cmd | 3 | package cmd | ||
| 4 | 4 | ||||
| 5 | import ( | 5 | import ( | ||
| 6 | + | "fmt" | |||
| 7 | + | ||||
| 6 | "github.com/bnema/gordon/internal/cli/handler" | 8 | "github.com/bnema/gordon/internal/cli/handler" | ||
| 9 | + | "github.com/bnema/gordon/internal/common" | |||
| 7 | "github.com/bnema/gordon/internal/server" | 10 | "github.com/bnema/gordon/internal/server" | ||
| 8 | "github.com/bnema/gordon/pkg/docker" | 11 | "github.com/bnema/gordon/pkg/docker" | ||
| 9 | "github.com/spf13/cobra" | 12 | "github.com/spf13/cobra" | ||
| @@ -14,9 +17,10 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 14 | defaultport := "1323" | 17 | defaultport := "1323" | ||
| 15 | var port string | 18 | var port string | ||
| 16 | 19 | ||||
| 17 | - | // if the server is running in a docker container, the default port is 80 | 20 | + | // if the server is running in a docker container, the default port is 8080 |
| 21 | + | // (changed from 80 to avoid conflict with the reverse proxy) | |||
| 18 | if docker.IsRunningInContainer() { | 22 | if docker.IsRunningInContainer() { | ||
| 19 | - | defaultport = "80" | 23 | + | defaultport = "8080" |
| 20 | } | 24 | } | ||
| 21 | 25 | ||||
| 22 | // if no flags -p or --port are specified, the default port is used | 26 | // if no flags -p or --port are specified, the default port is used | ||
| @@ -25,6 +29,12 @@ func NewServeCommand(a *server.App) *cobra.Command { | |||||
| 25 | Short: "Start a new Gordon server instance", | 29 | Short: "Start a new Gordon server instance", | ||
| 26 | Args: cobra.NoArgs, | 30 | Args: cobra.NoArgs, | ||
| 27 | Run: func(cmd *cobra.Command, args []string) { | 31 | Run: func(cmd *cobra.Command, args []string) { | ||
| 32 | + | // Initialize Docker client here, before starting the server | |||
| 33 | + | if err := common.DockerInit(&a.Config.ContainerEngine); err != nil { | |||
| 34 | + | fmt.Printf("Warning: %s\n", err) | |||
| 35 | + | fmt.Println("Continuing with Docker/Podman functionality disabled") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 28 | // handler.StartServer will use the value of port, which will be the default | 38 | // handler.StartServer will use the value of port, which will be the default | ||
| 29 | // value if no flag is specified | 39 | // value if no flag is specified | ||
| 30 | a.Config.Http.Port = port | 40 | a.Config.Http.Port = port | ||
M internal/cli/handler/start_server.go +16 -1
| @@ -29,13 +29,28 @@ func StartServer(a *server.App, port string) error { | |||
| 29 | 29 | log.Print(err) | |
| 30 | 30 | } | |
| 31 | 31 | ||
| 32 | + | // Initialize and start the reverse proxy | |
| 33 | + | p, err := httpserve.InitializeProxy(a) | |
| 34 | + | if err != nil { | |
| 35 | + | log.Error("Failed to initialize reverse proxy:", err) | |
| 36 | + | // Continue even if proxy fails, as the main server should still work | |
| 37 | + | } | |
| 38 | + | ||
| 32 | 39 | // Setup a channel to capture termination signals | |
| 33 | 40 | sigs := make(chan os.Signal, 1) | |
| 34 | 41 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | |
| 35 | 42 | ||
| 36 | 43 | go func() { | |
| 37 | 44 | sig := <-sigs | |
| 38 | - | log.Info(fmt.Println("Received signal:", sig)) | |
| 45 | + | log.Info("Received signal:", sig) | |
| 46 | + | ||
| 47 | + | // Stop the proxy if it was started | |
| 48 | + | if p != nil { | |
| 49 | + | if err := p.Stop(); err != nil { | |
| 50 | + | log.Error("Failed to stop reverse proxy:", err) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 39 | 54 | os.Exit(0) | |
| 40 | 55 | }() | |
| 41 | 56 | ||
| @@ -29,13 +29,28 @@ func StartServer(a *server.App, port string) error { | |||||
| 29 | log.Print(err) | 29 | log.Print(err) | ||
| 30 | } | 30 | } | ||
| 31 | 31 | ||||
| 32 | + | // Initialize and start the reverse proxy | |||
| 33 | + | p, err := httpserve.InitializeProxy(a) | |||
| 34 | + | if err != nil { | |||
| 35 | + | log.Error("Failed to initialize reverse proxy:", err) | |||
| 36 | + | // Continue even if proxy fails, as the main server should still work | |||
| 37 | + | } | |||
| 38 | + | ||||
| 32 | // Setup a channel to capture termination signals | 39 | // Setup a channel to capture termination signals | ||
| 33 | sigs := make(chan os.Signal, 1) | 40 | sigs := make(chan os.Signal, 1) | ||
| 34 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | 41 | signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) | ||
| 35 | 42 | ||||
| 36 | go func() { | 43 | go func() { | ||
| 37 | sig := <-sigs | 44 | sig := <-sigs | ||
| 38 | - | log.Info(fmt.Println("Received signal:", sig)) | 45 | + | log.Info("Received signal:", sig) |
| 46 | + | ||||
| 47 | + | // Stop the proxy if it was started | |||
| 48 | + | if p != nil { | |||
| 49 | + | if err := p.Stop(); err != nil { | |||
| 50 | + | log.Error("Failed to stop reverse proxy:", err) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 39 | os.Exit(0) | 54 | os.Exit(0) | ||
| 40 | }() | 55 | }() | ||
| 41 | 56 | ||||
M internal/common/init_config.go +214 -16
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | 9 | "runtime" | |
| 10 | + | "strconv" | |
| 10 | 11 | "strings" | |
| 11 | 12 | ||
| 12 | 13 | "github.com/bnema/gordon/pkg/docker" | |
| @@ -18,7 +19,7 @@ type Config struct { | |||
| 18 | 19 | Http HttpConfig `yaml:"Http"` | |
| 19 | 20 | Admin AdminConfig `yaml:"Admin"` | |
| 20 | 21 | ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"` | |
| 21 | - | Traefik TraefikConfig `yaml:"Traefik"` | |
| 22 | + | ReverseProxy ReverseProxyConfig `yaml:"ReverseProxy"` | |
| 22 | 23 | Build BuildConfig `yaml:"-"` | |
| 23 | 24 | } | |
| 24 | 25 | ||
| @@ -53,19 +54,30 @@ type ContainerEngineConfig struct { | |||
| 53 | 54 | Network string `yaml:"network"` | |
| 54 | 55 | } | |
| 55 | 56 | ||
| 56 | - | type TraefikConfig struct { | |
| 57 | - | EntryPoint string `yaml:"entryPoint"` | |
| 58 | - | SecureEntryPoint string `yaml:"secureEntryPoint"` | |
| 59 | - | Resolver string `yaml:"resolver"` | |
| 57 | + | type ReverseProxyConfig struct { | |
| 58 | + | Port string `yaml:"port"` // Port for the reverse proxy to listen on | |
| 59 | + | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS | |
| 60 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates | |
| 61 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |
| 62 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |
| 63 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |
| 64 | + | Email string `yaml:"email"` // Email for Let's Encrypt | |
| 65 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |
| 66 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |
| 60 | 67 | } | |
| 61 | 68 | ||
| 62 | 69 | // Default values | |
| 63 | 70 | var ( | |
| 64 | 71 | sock = "/var/run/docker.sock" | |
| 65 | 72 | podmansock = "/run/user/1000/podman/podman.sock" | |
| 66 | - | entryPoint = "web" | |
| 67 | - | entryPointSecure = "websecure" | |
| 68 | - | resolver = "myresolver" | |
| 73 | + | reverseProxyPort = "443" // Changed to 443 for standard HTTPS | |
| 74 | + | httpPort = "80" // Standard HTTP port | |
| 75 | + | certDir = "/certs" | |
| 76 | + | letsEncryptMode = "staging" | |
| 77 | + | autoRenew = true | |
| 78 | + | renewBefore = 30 // days | |
| 79 | + | cacheSize = 1000 // entries | |
| 80 | + | gracePeriod = 30 // seconds | |
| 69 | 81 | ) | |
| 70 | 82 | ||
| 71 | 83 | func getConfigDir() (string, error) { | |
| @@ -92,7 +104,7 @@ func getConfigDir() (string, error) { | |||
| 92 | 104 | } | |
| 93 | 105 | ||
| 94 | 106 | if docker.IsRunningInContainer() { | |
| 95 | - | return "/.", nil | |
| 107 | + | return "/", nil | |
| 96 | 108 | } | |
| 97 | 109 | ||
| 98 | 110 | // Check for XDG_CONFIG_HOME first | |
| @@ -126,13 +138,135 @@ func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | |||
| 126 | 138 | return nil | |
| 127 | 139 | } | |
| 128 | 140 | ||
| 141 | + | // loadConfigFromEnv loads configuration from environment variables | |
| 142 | + | func loadConfigFromEnv(config *Config) { | |
| 143 | + | // General Configuration | |
| 144 | + | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | |
| 145 | + | config.General.StorageDir = val | |
| 146 | + | fmt.Printf("Using environment variable GORDON_STORAGE_DIR: %s\n", val) | |
| 147 | + | } | |
| 148 | + | if val := os.Getenv("GORDON_TOKEN"); val != "" { | |
| 149 | + | config.General.Token = val | |
| 150 | + | fmt.Printf("Using environment variable GORDON_TOKEN\n") | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // HTTP Configuration | |
| 154 | + | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | |
| 155 | + | config.Http.Port = val | |
| 156 | + | fmt.Printf("Using environment variable GORDON_HTTP_PORT: %s\n", val) | |
| 157 | + | } | |
| 158 | + | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | |
| 159 | + | config.Http.Domain = val | |
| 160 | + | fmt.Printf("Using environment variable GORDON_HTTP_DOMAIN: %s\n", val) | |
| 161 | + | } | |
| 162 | + | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | |
| 163 | + | config.Http.SubDomain = val | |
| 164 | + | fmt.Printf("Using environment variable GORDON_HTTP_SUBDOMAIN: %s\n", val) | |
| 165 | + | } | |
| 166 | + | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | |
| 167 | + | config.Http.BackendURL = val | |
| 168 | + | fmt.Printf("Using environment variable GORDON_HTTP_BACKEND_URL: %s\n", val) | |
| 169 | + | } | |
| 170 | + | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | |
| 171 | + | config.Http.Https = strings.ToLower(val) == "true" | |
| 172 | + | fmt.Printf("Using environment variable GORDON_HTTP_HTTPS: %t\n", config.Http.Https) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | // Admin Configuration | |
| 176 | + | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | |
| 177 | + | config.Admin.Path = val | |
| 178 | + | fmt.Printf("Using environment variable GORDON_ADMIN_PATH: %s\n", val) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | // Container Engine Configuration | |
| 182 | + | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | |
| 183 | + | config.ContainerEngine.Sock = val | |
| 184 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_SOCK: %s\n", val) | |
| 185 | + | } | |
| 186 | + | if val := os.Getenv("GORDON_CONTAINER_PODMAN_SOCK"); val != "" { | |
| 187 | + | config.ContainerEngine.PodmanSock = val | |
| 188 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN_SOCK: %s\n", val) | |
| 189 | + | } | |
| 190 | + | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | |
| 191 | + | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | |
| 192 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN: %t\n", config.ContainerEngine.Podman) | |
| 193 | + | ||
| 194 | + | // If using Podman, and Sock is empty but PodmanSock is set, use PodmanSock for Sock field | |
| 195 | + | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | |
| 196 | + | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | |
| 197 | + | fmt.Printf("Setting ContainerEngine.Sock to PodmanSock value: %s\n", config.ContainerEngine.Sock) | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | |
| 201 | + | config.ContainerEngine.Network = val | |
| 202 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_NETWORK: %s\n", val) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // If using Docker socket with podman-compose mounting | |
| 206 | + | if config.ContainerEngine.Sock == "" && fileExists("/var/run/docker.sock") { | |
| 207 | + | config.ContainerEngine.Sock = "/var/run/docker.sock" | |
| 208 | + | fmt.Printf("Docker socket found at /var/run/docker.sock, using it\n") | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // Reverse Proxy Configuration | |
| 212 | + | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | |
| 213 | + | config.ReverseProxy.Port = val | |
| 214 | + | fmt.Printf("Using environment variable GORDON_PROXY_PORT: %s\n", val) | |
| 215 | + | } | |
| 216 | + | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | |
| 217 | + | config.ReverseProxy.HttpPort = val | |
| 218 | + | fmt.Printf("Using environment variable GORDON_PROXY_HTTP_PORT: %s\n", val) | |
| 219 | + | } | |
| 220 | + | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | |
| 221 | + | config.ReverseProxy.CertDir = val | |
| 222 | + | fmt.Printf("Using environment variable GORDON_PROXY_CERT_DIR: %s\n", val) | |
| 223 | + | } | |
| 224 | + | if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" { | |
| 225 | + | config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true" | |
| 226 | + | fmt.Printf("Using environment variable GORDON_PROXY_AUTO_RENEW: %t\n", config.ReverseProxy.AutoRenew) | |
| 227 | + | } | |
| 228 | + | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | |
| 229 | + | if i, err := strconv.Atoi(val); err == nil { | |
| 230 | + | config.ReverseProxy.RenewBefore = i | |
| 231 | + | fmt.Printf("Using environment variable GORDON_PROXY_RENEW_BEFORE: %d\n", i) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | |
| 235 | + | config.ReverseProxy.LetsEncryptMode = val | |
| 236 | + | fmt.Printf("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE: %s\n", val) | |
| 237 | + | } | |
| 238 | + | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | |
| 239 | + | config.ReverseProxy.Email = val | |
| 240 | + | fmt.Printf("Using environment variable GORDON_PROXY_EMAIL: %s\n", val) | |
| 241 | + | } | |
| 242 | + | if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" { | |
| 243 | + | if i, err := strconv.Atoi(val); err == nil { | |
| 244 | + | config.ReverseProxy.CacheSize = i | |
| 245 | + | fmt.Printf("Using environment variable GORDON_PROXY_CACHE_SIZE: %d\n", i) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" { | |
| 249 | + | if i, err := strconv.Atoi(val); err == nil { | |
| 250 | + | config.ReverseProxy.GracePeriod = i | |
| 251 | + | fmt.Printf("Using environment variable GORDON_PROXY_GRACE_PERIOD: %d\n", i) | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | } | |
| 255 | + | ||
| 129 | 256 | func (config *Config) LoadConfig() (*Config, error) { | |
| 130 | 257 | configDir, err := getConfigDir() | |
| 131 | 258 | if err != nil { | |
| 132 | 259 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | |
| 133 | 260 | } | |
| 134 | 261 | ||
| 135 | - | configFilePath := filepath.Join(configDir, "config.yml") | |
| 262 | + | // Check for directly mounted config.yml in root when in container | |
| 263 | + | var configFilePath string | |
| 264 | + | if docker.IsRunningInContainer() && fileExists("/config.yml") { | |
| 265 | + | configFilePath = "/config.yml" | |
| 266 | + | fmt.Println("Found directly mounted config.yml in container root") | |
| 267 | + | } else { | |
| 268 | + | configFilePath = filepath.Join(configDir, "config.yml") | |
| 269 | + | } | |
| 136 | 270 | ||
| 137 | 271 | _, err = os.Stat(configFilePath) | |
| 138 | 272 | if errors.Is(err, fs.ErrNotExist) { | |
| @@ -145,19 +279,54 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 145 | 279 | } | |
| 146 | 280 | ||
| 147 | 281 | // Create config file with the default values of ContainerEngineConfig | |
| 148 | - | config = &Config{ | |
| 282 | + | homeDir, err := os.UserHomeDir() | |
| 283 | + | if err != nil { | |
| 284 | + | return nil, fmt.Errorf("could not get user home directory: %w", err) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | *config = Config{ | |
| 288 | + | General: GeneralConfig{ | |
| 289 | + | StorageDir: homeDir + "/.gordon", | |
| 290 | + | }, | |
| 291 | + | Http: HttpConfig{ | |
| 292 | + | Port: "8080", | |
| 293 | + | Domain: "localhost", | |
| 294 | + | Https: true, | |
| 295 | + | }, | |
| 296 | + | Admin: AdminConfig{ | |
| 297 | + | Path: "/admin", | |
| 298 | + | }, | |
| 149 | 299 | ContainerEngine: ContainerEngineConfig{ | |
| 150 | 300 | Sock: sock, | |
| 151 | 301 | PodmanSock: podmansock, | |
| 152 | 302 | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | |
| 303 | + | Network: "gordon", | |
| 153 | 304 | }, | |
| 154 | - | Traefik: TraefikConfig{ | |
| 155 | - | EntryPoint: entryPoint, | |
| 156 | - | SecureEntryPoint: entryPointSecure, | |
| 157 | - | Resolver: resolver, | |
| 305 | + | ReverseProxy: ReverseProxyConfig{ | |
| 306 | + | Port: reverseProxyPort, | |
| 307 | + | HttpPort: httpPort, | |
| 308 | + | CertDir: certDir, | |
| 309 | + | AutoRenew: autoRenew, | |
| 310 | + | RenewBefore: renewBefore, | |
| 311 | + | LetsEncryptMode: letsEncryptMode, | |
| 312 | + | CacheSize: cacheSize, | |
| 313 | + | GracePeriod: gracePeriod, | |
| 158 | 314 | }, | |
| 159 | 315 | } | |
| 160 | 316 | ||
| 317 | + | // If running in a container, skip the prompt and load from env vars | |
| 318 | + | if docker.IsRunningInContainer() { | |
| 319 | + | config.ContainerEngine.Podman = false | |
| 320 | + | fmt.Println("Running in container, loading configuration from environment variables...") | |
| 321 | + | loadConfigFromEnv(config) | |
| 322 | + | ||
| 323 | + | // Handle the Podman case if the GORDON_CONTAINER_PODMAN env var is set to true | |
| 324 | + | if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |
| 325 | + | config.ContainerEngine.Podman = true | |
| 326 | + | fmt.Println("Podman container engine detected via environment variable") | |
| 327 | + | } | |
| 328 | + | } | |
| 329 | + | ||
| 161 | 330 | err = config.SaveConfig() | |
| 162 | 331 | if err != nil { | |
| 163 | 332 | return nil, fmt.Errorf("error saving new configuration: %w", err) | |
| @@ -174,6 +343,12 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 174 | 343 | return nil, err | |
| 175 | 344 | } | |
| 176 | 345 | ||
| 346 | + | // If running in a container, override with env vars | |
| 347 | + | if docker.IsRunningInContainer() { | |
| 348 | + | fmt.Println("Running in container, overriding configuration with environment variables...") | |
| 349 | + | loadConfigFromEnv(config) | |
| 350 | + | } | |
| 351 | + | ||
| 177 | 352 | // Load run env | |
| 178 | 353 | config.Build.RunEnv = os.Getenv("RUN_ENV") | |
| 179 | 354 | ||
| @@ -181,6 +356,14 @@ func (config *Config) LoadConfig() (*Config, error) { | |||
| 181 | 356 | config.Build.RunEnv = "prod" | |
| 182 | 357 | } | |
| 183 | 358 | ||
| 359 | + | // Debug output to verify config was loaded correctly | |
| 360 | + | fmt.Printf("Loaded configuration from %s\n", configFilePath) | |
| 361 | + | fmt.Printf("Container engine config - Docker socket: %s, Podman socket: %s, Using Podman: %t, Network: %s\n", | |
| 362 | + | config.ContainerEngine.Sock, | |
| 363 | + | config.ContainerEngine.PodmanSock, | |
| 364 | + | config.ContainerEngine.Podman, | |
| 365 | + | config.ContainerEngine.Network) | |
| 366 | + | ||
| 184 | 367 | return config, nil | |
| 185 | 368 | } | |
| 186 | 369 | ||
| @@ -189,7 +372,16 @@ func (config *Config) SaveConfig() error { | |||
| 189 | 372 | if err != nil { | |
| 190 | 373 | return fmt.Errorf("error getting configuration directory: %w", err) | |
| 191 | 374 | } | |
| 192 | - | configFilePath := filepath.Join(configDir, "config.yml") | |
| 375 | + | ||
| 376 | + | // Check for directly mounted config.yml in root when in container | |
| 377 | + | var configFilePath string | |
| 378 | + | if docker.IsRunningInContainer() && fileExists("/config.yml") { | |
| 379 | + | configFilePath = "/config.yml" | |
| 380 | + | } else { | |
| 381 | + | configFilePath = filepath.Join(configDir, "config.yml") | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | fmt.Printf("Saving configuration to %s\n", configFilePath) | |
| 193 | 385 | ||
| 194 | 386 | err = parser.WriteYAMLFile(configFilePath, config) | |
| 195 | 387 | if err != nil { | |
| @@ -199,6 +391,12 @@ func (config *Config) SaveConfig() error { | |||
| 199 | 391 | return nil | |
| 200 | 392 | } | |
| 201 | 393 | ||
| 394 | + | // Helper function to check if a file exists | |
| 395 | + | func fileExists(filepath string) bool { | |
| 396 | + | _, err := os.Stat(filepath) | |
| 397 | + | return err == nil | |
| 398 | + | } | |
| 399 | + | ||
| 202 | 400 | func (config *Config) GetToken() (string, error) { | |
| 203 | 401 | token := config.General.Token | |
| 204 | 402 | if token == "" { | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "runtime" | 9 | "runtime" | ||
| 10 | + | "strconv" | |||
| 10 | "strings" | 11 | "strings" | ||
| 11 | 12 | ||||
| 12 | "github.com/bnema/gordon/pkg/docker" | 13 | "github.com/bnema/gordon/pkg/docker" | ||
| @@ -18,7 +19,7 @@ type Config struct { | |||||
| 18 | Http HttpConfig `yaml:"Http"` | 19 | Http HttpConfig `yaml:"Http"` | ||
| 19 | Admin AdminConfig `yaml:"Admin"` | 20 | Admin AdminConfig `yaml:"Admin"` | ||
| 20 | ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"` | 21 | ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"` | ||
| 21 | - | Traefik TraefikConfig `yaml:"Traefik"` | 22 | + | ReverseProxy ReverseProxyConfig `yaml:"ReverseProxy"` |
| 22 | Build BuildConfig `yaml:"-"` | 23 | Build BuildConfig `yaml:"-"` | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| @@ -53,19 +54,30 @@ type ContainerEngineConfig struct { | |||||
| 53 | Network string `yaml:"network"` | 54 | Network string `yaml:"network"` | ||
| 54 | } | 55 | } | ||
| 55 | 56 | ||||
| 56 | - | type TraefikConfig struct { | 57 | + | type ReverseProxyConfig struct { |
| 57 | - | EntryPoint string `yaml:"entryPoint"` | 58 | + | Port string `yaml:"port"` // Port for the reverse proxy to listen on |
| 58 | - | SecureEntryPoint string `yaml:"secureEntryPoint"` | 59 | + | HttpPort string `yaml:"httpPort"` // HTTP port (usually 80) for redirecting to HTTPS |
| 59 | - | Resolver string `yaml:"resolver"` | 60 | + | CertDir string `yaml:"certDir"` // Directory to store Let's Encrypt certificates |
| 61 | + | AutoRenew bool `yaml:"autoRenew"` // Whether to automatically renew certificates | |||
| 62 | + | RenewBefore int `yaml:"renewBefore"` // Days before expiry to renew certificates | |||
| 63 | + | LetsEncryptMode string `yaml:"letsEncryptMode"` // "staging" or "production" | |||
| 64 | + | Email string `yaml:"email"` // Email for Let's Encrypt | |||
| 65 | + | CacheSize int `yaml:"cacheSize"` // Size of the certificate cache | |||
| 66 | + | GracePeriod int `yaml:"gracePeriod"` // Shutdown grace period in seconds | |||
| 60 | } | 67 | } | ||
| 61 | 68 | ||||
| 62 | // Default values | 69 | // Default values | ||
| 63 | var ( | 70 | var ( | ||
| 64 | sock = "/var/run/docker.sock" | 71 | sock = "/var/run/docker.sock" | ||
| 65 | podmansock = "/run/user/1000/podman/podman.sock" | 72 | podmansock = "/run/user/1000/podman/podman.sock" | ||
| 66 | - | entryPoint = "web" | 73 | + | reverseProxyPort = "443" // Changed to 443 for standard HTTPS |
| 67 | - | entryPointSecure = "websecure" | 74 | + | httpPort = "80" // Standard HTTP port |
| 68 | - | resolver = "myresolver" | 75 | + | certDir = "/certs" |
| 76 | + | letsEncryptMode = "staging" | |||
| 77 | + | autoRenew = true | |||
| 78 | + | renewBefore = 30 // days | |||
| 79 | + | cacheSize = 1000 // entries | |||
| 80 | + | gracePeriod = 30 // seconds | |||
| 69 | ) | 81 | ) | ||
| 70 | 82 | ||||
| 71 | func getConfigDir() (string, error) { | 83 | func getConfigDir() (string, error) { | ||
| @@ -92,7 +104,7 @@ func getConfigDir() (string, error) { | |||||
| 92 | } | 104 | } | ||
| 93 | 105 | ||||
| 94 | if docker.IsRunningInContainer() { | 106 | if docker.IsRunningInContainer() { | ||
| 95 | - | return "/.", nil | 107 | + | return "/", nil |
| 96 | } | 108 | } | ||
| 97 | 109 | ||||
| 98 | // Check for XDG_CONFIG_HOME first | 110 | // Check for XDG_CONFIG_HOME first | ||
| @@ -126,13 +138,135 @@ func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error { | |||||
| 126 | return nil | 138 | return nil | ||
| 127 | } | 139 | } | ||
| 128 | 140 | ||||
| 141 | + | // loadConfigFromEnv loads configuration from environment variables | |||
| 142 | + | func loadConfigFromEnv(config *Config) { | |||
| 143 | + | // General Configuration | |||
| 144 | + | if val := os.Getenv("GORDON_STORAGE_DIR"); val != "" { | |||
| 145 | + | config.General.StorageDir = val | |||
| 146 | + | fmt.Printf("Using environment variable GORDON_STORAGE_DIR: %s\n", val) | |||
| 147 | + | } | |||
| 148 | + | if val := os.Getenv("GORDON_TOKEN"); val != "" { | |||
| 149 | + | config.General.Token = val | |||
| 150 | + | fmt.Printf("Using environment variable GORDON_TOKEN\n") | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // HTTP Configuration | |||
| 154 | + | if val := os.Getenv("GORDON_HTTP_PORT"); val != "" { | |||
| 155 | + | config.Http.Port = val | |||
| 156 | + | fmt.Printf("Using environment variable GORDON_HTTP_PORT: %s\n", val) | |||
| 157 | + | } | |||
| 158 | + | if val := os.Getenv("GORDON_HTTP_DOMAIN"); val != "" { | |||
| 159 | + | config.Http.Domain = val | |||
| 160 | + | fmt.Printf("Using environment variable GORDON_HTTP_DOMAIN: %s\n", val) | |||
| 161 | + | } | |||
| 162 | + | if val := os.Getenv("GORDON_HTTP_SUBDOMAIN"); val != "" { | |||
| 163 | + | config.Http.SubDomain = val | |||
| 164 | + | fmt.Printf("Using environment variable GORDON_HTTP_SUBDOMAIN: %s\n", val) | |||
| 165 | + | } | |||
| 166 | + | if val := os.Getenv("GORDON_HTTP_BACKEND_URL"); val != "" { | |||
| 167 | + | config.Http.BackendURL = val | |||
| 168 | + | fmt.Printf("Using environment variable GORDON_HTTP_BACKEND_URL: %s\n", val) | |||
| 169 | + | } | |||
| 170 | + | if val := os.Getenv("GORDON_HTTP_HTTPS"); val != "" { | |||
| 171 | + | config.Http.Https = strings.ToLower(val) == "true" | |||
| 172 | + | fmt.Printf("Using environment variable GORDON_HTTP_HTTPS: %t\n", config.Http.Https) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | // Admin Configuration | |||
| 176 | + | if val := os.Getenv("GORDON_ADMIN_PATH"); val != "" { | |||
| 177 | + | config.Admin.Path = val | |||
| 178 | + | fmt.Printf("Using environment variable GORDON_ADMIN_PATH: %s\n", val) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | // Container Engine Configuration | |||
| 182 | + | if val := os.Getenv("GORDON_CONTAINER_SOCK"); val != "" { | |||
| 183 | + | config.ContainerEngine.Sock = val | |||
| 184 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_SOCK: %s\n", val) | |||
| 185 | + | } | |||
| 186 | + | if val := os.Getenv("GORDON_CONTAINER_PODMAN_SOCK"); val != "" { | |||
| 187 | + | config.ContainerEngine.PodmanSock = val | |||
| 188 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN_SOCK: %s\n", val) | |||
| 189 | + | } | |||
| 190 | + | if val := os.Getenv("GORDON_CONTAINER_PODMAN"); val != "" { | |||
| 191 | + | config.ContainerEngine.Podman = strings.ToLower(val) == "true" | |||
| 192 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_PODMAN: %t\n", config.ContainerEngine.Podman) | |||
| 193 | + | ||||
| 194 | + | // If using Podman, and Sock is empty but PodmanSock is set, use PodmanSock for Sock field | |||
| 195 | + | if config.ContainerEngine.Podman && config.ContainerEngine.Sock == "" && config.ContainerEngine.PodmanSock != "" { | |||
| 196 | + | config.ContainerEngine.Sock = config.ContainerEngine.PodmanSock | |||
| 197 | + | fmt.Printf("Setting ContainerEngine.Sock to PodmanSock value: %s\n", config.ContainerEngine.Sock) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | if val := os.Getenv("GORDON_CONTAINER_NETWORK"); val != "" { | |||
| 201 | + | config.ContainerEngine.Network = val | |||
| 202 | + | fmt.Printf("Using environment variable GORDON_CONTAINER_NETWORK: %s\n", val) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // If using Docker socket with podman-compose mounting | |||
| 206 | + | if config.ContainerEngine.Sock == "" && fileExists("/var/run/docker.sock") { | |||
| 207 | + | config.ContainerEngine.Sock = "/var/run/docker.sock" | |||
| 208 | + | fmt.Printf("Docker socket found at /var/run/docker.sock, using it\n") | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | // Reverse Proxy Configuration | |||
| 212 | + | if val := os.Getenv("GORDON_PROXY_PORT"); val != "" { | |||
| 213 | + | config.ReverseProxy.Port = val | |||
| 214 | + | fmt.Printf("Using environment variable GORDON_PROXY_PORT: %s\n", val) | |||
| 215 | + | } | |||
| 216 | + | if val := os.Getenv("GORDON_PROXY_HTTP_PORT"); val != "" { | |||
| 217 | + | config.ReverseProxy.HttpPort = val | |||
| 218 | + | fmt.Printf("Using environment variable GORDON_PROXY_HTTP_PORT: %s\n", val) | |||
| 219 | + | } | |||
| 220 | + | if val := os.Getenv("GORDON_PROXY_CERT_DIR"); val != "" { | |||
| 221 | + | config.ReverseProxy.CertDir = val | |||
| 222 | + | fmt.Printf("Using environment variable GORDON_PROXY_CERT_DIR: %s\n", val) | |||
| 223 | + | } | |||
| 224 | + | if val := os.Getenv("GORDON_PROXY_AUTO_RENEW"); val != "" { | |||
| 225 | + | config.ReverseProxy.AutoRenew = strings.ToLower(val) == "true" | |||
| 226 | + | fmt.Printf("Using environment variable GORDON_PROXY_AUTO_RENEW: %t\n", config.ReverseProxy.AutoRenew) | |||
| 227 | + | } | |||
| 228 | + | if val := os.Getenv("GORDON_PROXY_RENEW_BEFORE"); val != "" { | |||
| 229 | + | if i, err := strconv.Atoi(val); err == nil { | |||
| 230 | + | config.ReverseProxy.RenewBefore = i | |||
| 231 | + | fmt.Printf("Using environment variable GORDON_PROXY_RENEW_BEFORE: %d\n", i) | |||
| 232 | + | } | |||
| 233 | + | } | |||
| 234 | + | if val := os.Getenv("GORDON_PROXY_LETSENCRYPT_MODE"); val != "" { | |||
| 235 | + | config.ReverseProxy.LetsEncryptMode = val | |||
| 236 | + | fmt.Printf("Using environment variable GORDON_PROXY_LETSENCRYPT_MODE: %s\n", val) | |||
| 237 | + | } | |||
| 238 | + | if val := os.Getenv("GORDON_PROXY_EMAIL"); val != "" { | |||
| 239 | + | config.ReverseProxy.Email = val | |||
| 240 | + | fmt.Printf("Using environment variable GORDON_PROXY_EMAIL: %s\n", val) | |||
| 241 | + | } | |||
| 242 | + | if val := os.Getenv("GORDON_PROXY_CACHE_SIZE"); val != "" { | |||
| 243 | + | if i, err := strconv.Atoi(val); err == nil { | |||
| 244 | + | config.ReverseProxy.CacheSize = i | |||
| 245 | + | fmt.Printf("Using environment variable GORDON_PROXY_CACHE_SIZE: %d\n", i) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | if val := os.Getenv("GORDON_PROXY_GRACE_PERIOD"); val != "" { | |||
| 249 | + | if i, err := strconv.Atoi(val); err == nil { | |||
| 250 | + | config.ReverseProxy.GracePeriod = i | |||
| 251 | + | fmt.Printf("Using environment variable GORDON_PROXY_GRACE_PERIOD: %d\n", i) | |||
| 252 | + | } | |||
| 253 | + | } | |||
| 254 | + | } | |||
| 255 | + | ||||
| 129 | func (config *Config) LoadConfig() (*Config, error) { | 256 | func (config *Config) LoadConfig() (*Config, error) { | ||
| 130 | configDir, err := getConfigDir() | 257 | configDir, err := getConfigDir() | ||
| 131 | if err != nil { | 258 | if err != nil { | ||
| 132 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | 259 | return nil, fmt.Errorf("error getting configuration directory: %w", err) | ||
| 133 | } | 260 | } | ||
| 134 | 261 | ||||
| 135 | - | configFilePath := filepath.Join(configDir, "config.yml") | 262 | + | // Check for directly mounted config.yml in root when in container |
| 263 | + | var configFilePath string | |||
| 264 | + | if docker.IsRunningInContainer() && fileExists("/config.yml") { | |||
| 265 | + | configFilePath = "/config.yml" | |||
| 266 | + | fmt.Println("Found directly mounted config.yml in container root") | |||
| 267 | + | } else { | |||
| 268 | + | configFilePath = filepath.Join(configDir, "config.yml") | |||
| 269 | + | } | |||
| 136 | 270 | ||||
| 137 | _, err = os.Stat(configFilePath) | 271 | _, err = os.Stat(configFilePath) | ||
| 138 | if errors.Is(err, fs.ErrNotExist) { | 272 | if errors.Is(err, fs.ErrNotExist) { | ||
| @@ -145,19 +279,54 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 145 | } | 279 | } | ||
| 146 | 280 | ||||
| 147 | // Create config file with the default values of ContainerEngineConfig | 281 | // Create config file with the default values of ContainerEngineConfig | ||
| 148 | - | config = &Config{ | 282 | + | homeDir, err := os.UserHomeDir() |
| 283 | + | if err != nil { | |||
| 284 | + | return nil, fmt.Errorf("could not get user home directory: %w", err) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | *config = Config{ | |||
| 288 | + | General: GeneralConfig{ | |||
| 289 | + | StorageDir: homeDir + "/.gordon", | |||
| 290 | + | }, | |||
| 291 | + | Http: HttpConfig{ | |||
| 292 | + | Port: "8080", | |||
| 293 | + | Domain: "localhost", | |||
| 294 | + | Https: true, | |||
| 295 | + | }, | |||
| 296 | + | Admin: AdminConfig{ | |||
| 297 | + | Path: "/admin", | |||
| 298 | + | }, | |||
| 149 | ContainerEngine: ContainerEngineConfig{ | 299 | ContainerEngine: ContainerEngineConfig{ | ||
| 150 | Sock: sock, | 300 | Sock: sock, | ||
| 151 | PodmanSock: podmansock, | 301 | PodmanSock: podmansock, | ||
| 152 | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | 302 | Podman: ReadUserInput("Are you using podman ? (y/n)") == "y", | ||
| 303 | + | Network: "gordon", | |||
| 153 | }, | 304 | }, | ||
| 154 | - | Traefik: TraefikConfig{ | 305 | + | ReverseProxy: ReverseProxyConfig{ |
| 155 | - | EntryPoint: entryPoint, | 306 | + | Port: reverseProxyPort, |
| 156 | - | SecureEntryPoint: entryPointSecure, | 307 | + | HttpPort: httpPort, |
| 157 | - | Resolver: resolver, | 308 | + | CertDir: certDir, |
| 309 | + | AutoRenew: autoRenew, | |||
| 310 | + | RenewBefore: renewBefore, | |||
| 311 | + | LetsEncryptMode: letsEncryptMode, | |||
| 312 | + | CacheSize: cacheSize, | |||
| 313 | + | GracePeriod: gracePeriod, | |||
| 158 | }, | 314 | }, | ||
| 159 | } | 315 | } | ||
| 160 | 316 | ||||
| 317 | + | // If running in a container, skip the prompt and load from env vars | |||
| 318 | + | if docker.IsRunningInContainer() { | |||
| 319 | + | config.ContainerEngine.Podman = false | |||
| 320 | + | fmt.Println("Running in container, loading configuration from environment variables...") | |||
| 321 | + | loadConfigFromEnv(config) | |||
| 322 | + | ||||
| 323 | + | // Handle the Podman case if the GORDON_CONTAINER_PODMAN env var is set to true | |||
| 324 | + | if os.Getenv("GORDON_CONTAINER_PODMAN") == "true" { | |||
| 325 | + | config.ContainerEngine.Podman = true | |||
| 326 | + | fmt.Println("Podman container engine detected via environment variable") | |||
| 327 | + | } | |||
| 328 | + | } | |||
| 329 | + | ||||
| 161 | err = config.SaveConfig() | 330 | err = config.SaveConfig() | ||
| 162 | if err != nil { | 331 | if err != nil { | ||
| 163 | return nil, fmt.Errorf("error saving new configuration: %w", err) | 332 | return nil, fmt.Errorf("error saving new configuration: %w", err) | ||
| @@ -174,6 +343,12 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 174 | return nil, err | 343 | return nil, err | ||
| 175 | } | 344 | } | ||
| 176 | 345 | ||||
| 346 | + | // If running in a container, override with env vars | |||
| 347 | + | if docker.IsRunningInContainer() { | |||
| 348 | + | fmt.Println("Running in container, overriding configuration with environment variables...") | |||
| 349 | + | loadConfigFromEnv(config) | |||
| 350 | + | } | |||
| 351 | + | ||||
| 177 | // Load run env | 352 | // Load run env | ||
| 178 | config.Build.RunEnv = os.Getenv("RUN_ENV") | 353 | config.Build.RunEnv = os.Getenv("RUN_ENV") | ||
| 179 | 354 | ||||
| @@ -181,6 +356,14 @@ func (config *Config) LoadConfig() (*Config, error) { | |||||
| 181 | config.Build.RunEnv = "prod" | 356 | config.Build.RunEnv = "prod" | ||
| 182 | } | 357 | } | ||
| 183 | 358 | ||||
| 359 | + | // Debug output to verify config was loaded correctly | |||
| 360 | + | fmt.Printf("Loaded configuration from %s\n", configFilePath) | |||
| 361 | + | fmt.Printf("Container engine config - Docker socket: %s, Podman socket: %s, Using Podman: %t, Network: %s\n", | |||
| 362 | + | config.ContainerEngine.Sock, | |||
| 363 | + | config.ContainerEngine.PodmanSock, | |||
| 364 | + | config.ContainerEngine.Podman, | |||
| 365 | + | config.ContainerEngine.Network) | |||
| 366 | + | ||||
| 184 | return config, nil | 367 | return config, nil | ||
| 185 | } | 368 | } | ||
| 186 | 369 | ||||
| @@ -189,7 +372,16 @@ func (config *Config) SaveConfig() error { | |||||
| 189 | if err != nil { | 372 | if err != nil { | ||
| 190 | return fmt.Errorf("error getting configuration directory: %w", err) | 373 | return fmt.Errorf("error getting configuration directory: %w", err) | ||
| 191 | } | 374 | } | ||
| 192 | - | configFilePath := filepath.Join(configDir, "config.yml") | 375 | + | |
| 376 | + | // Check for directly mounted config.yml in root when in container | |||
| 377 | + | var configFilePath string | |||
| 378 | + | if docker.IsRunningInContainer() && fileExists("/config.yml") { | |||
| 379 | + | configFilePath = "/config.yml" | |||
| 380 | + | } else { | |||
| 381 | + | configFilePath = filepath.Join(configDir, "config.yml") | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | fmt.Printf("Saving configuration to %s\n", configFilePath) | |||
| 193 | 385 | ||||
| 194 | err = parser.WriteYAMLFile(configFilePath, config) | 386 | err = parser.WriteYAMLFile(configFilePath, config) | ||
| 195 | if err != nil { | 387 | if err != nil { | ||
| @@ -199,6 +391,12 @@ func (config *Config) SaveConfig() error { | |||||
| 199 | return nil | 391 | return nil | ||
| 200 | } | 392 | } | ||
| 201 | 393 | ||||
| 394 | + | // Helper function to check if a file exists | |||
| 395 | + | func fileExists(filepath string) bool { | |||
| 396 | + | _, err := os.Stat(filepath) | |||
| 397 | + | return err == nil | |||
| 398 | + | } | |||
| 399 | + | ||||
| 202 | func (config *Config) GetToken() (string, error) { | 400 | func (config *Config) GetToken() (string, error) { | ||
| 203 | token := config.General.Token | 401 | token := config.General.Token | ||
| 204 | if token == "" { | 402 | if token == "" { | ||
M internal/common/init_dockerclient.go +82 -3
| @@ -1,16 +1,95 @@ | |||
| 1 | 1 | package common | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "log" | |
| 6 | + | "os" | |
| 7 | + | "strings" | |
| 5 | 8 | ||
| 6 | 9 | "github.com/bnema/gordon/pkg/docker" | |
| 7 | 10 | ) | |
| 8 | 11 | ||
| 9 | - | func DockerInit(cc *ContainerEngineConfig) { | |
| 10 | - | err := docker.InitializeClient(NewDockerConfig(cc)) | |
| 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 | + | config := NewDockerConfig(cc) | |
| 23 | + | err := docker.InitializeClient(config) | |
| 11 | 24 | if err != nil { | |
| 12 | - | log.Printf("Error initializing Docker client: %s", err) | |
| 25 | + | // Check if it's a "socket doesn't exist" error | |
| 26 | + | if strings.Contains(err.Error(), "no such file or directory") { | |
| 27 | + | // Log the error | |
| 28 | + | log.Printf("Error initializing Docker client: %s", err) | |
| 29 | + | ||
| 30 | + | // Try alternative socket paths | |
| 31 | + | socketPaths := []string{ | |
| 32 | + | "/run/podman/podman.sock", // Standard path | |
| 33 | + | "/var/run/podman/podman.sock", // Alternative standard path | |
| 34 | + | os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros | |
| 35 | + | "/run/user/1000/podman/podman.sock", // User-specific socket | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Add Docker socket path as a fallback | |
| 39 | + | if !isPodmanSocket { | |
| 40 | + | socketPaths = append(socketPaths, "/var/run/docker.sock") | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | for _, path := range socketPaths { | |
| 44 | + | // Skip if it's the path we already tried | |
| 45 | + | if path == cc.Sock { | |
| 46 | + | continue | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Check if this socket exists | |
| 50 | + | if _, err := os.Stat(path); err == nil { | |
| 51 | + | log.Printf("Found alternative socket at %s", path) | |
| 52 | + | ||
| 53 | + | // Detect if this is a Podman socket | |
| 54 | + | isPodmanSocket = detectPodmanSocket(path) | |
| 55 | + | if isPodmanSocket { | |
| 56 | + | cc.Podman = true | |
| 57 | + | cc.PodmanSock = path | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | cc.Sock = path | |
| 61 | + | ||
| 62 | + | // Try again with the new path | |
| 63 | + | newConfig := NewDockerConfig(cc) | |
| 64 | + | if err := docker.InitializeClient(newConfig); err == nil { | |
| 65 | + | return nil | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Return without an error, as we want to continue even if Docker is not available | |
| 71 | + | log.Printf("Docker/Podman functionality will be limited or unavailable") | |
| 72 | + | return fmt.Errorf("could not connect to container engine: %w", err) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return err | |
| 13 | 76 | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // detectPodmanSocket checks if a socket path is likely a Podman socket | |
| 81 | + | func detectPodmanSocket(socketPath string) bool { | |
| 82 | + | if socketPath == "" { | |
| 83 | + | return false | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | // Check if it's a user-specific Podman socket path | |
| 87 | + | if strings.Contains(socketPath, "user") && strings.Contains(socketPath, "podman") { | |
| 88 | + | log.Printf("User-specific rootless Podman socket detected: %s", socketPath) | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | return strings.Contains(socketPath, "podman") | |
| 14 | 93 | } | |
| 15 | 94 | ||
| 16 | 95 | // NewDockerConfig creates and returns a new Docker client configuration | |
| @@ -1,16 +1,95 @@ | |||||
| 1 | package common | 1 | package common | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "log" | 5 | "log" | ||
| 6 | + | "os" | |||
| 7 | + | "strings" | |||
| 5 | 8 | ||||
| 6 | "github.com/bnema/gordon/pkg/docker" | 9 | "github.com/bnema/gordon/pkg/docker" | ||
| 7 | ) | 10 | ) | ||
| 8 | 11 | ||||
| 9 | - | func DockerInit(cc *ContainerEngineConfig) { | 12 | + | func DockerInit(cc *ContainerEngineConfig) error { |
| 10 | - | err := docker.InitializeClient(NewDockerConfig(cc)) | 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 | + | config := NewDockerConfig(cc) | |||
| 23 | + | err := docker.InitializeClient(config) | |||
| 11 | if err != nil { | 24 | if err != nil { | ||
| 12 | - | log.Printf("Error initializing Docker client: %s", err) | 25 | + | // Check if it's a "socket doesn't exist" error |
| 26 | + | if strings.Contains(err.Error(), "no such file or directory") { | |||
| 27 | + | // Log the error | |||
| 28 | + | log.Printf("Error initializing Docker client: %s", err) | |||
| 29 | + | ||||
| 30 | + | // Try alternative socket paths | |||
| 31 | + | socketPaths := []string{ | |||
| 32 | + | "/run/podman/podman.sock", // Standard path | |||
| 33 | + | "/var/run/podman/podman.sock", // Alternative standard path | |||
| 34 | + | os.Getenv("HOME") + "/.local/podman/podman.sock", // Some distros | |||
| 35 | + | "/run/user/1000/podman/podman.sock", // User-specific socket | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Add Docker socket path as a fallback | |||
| 39 | + | if !isPodmanSocket { | |||
| 40 | + | socketPaths = append(socketPaths, "/var/run/docker.sock") | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | for _, path := range socketPaths { | |||
| 44 | + | // Skip if it's the path we already tried | |||
| 45 | + | if path == cc.Sock { | |||
| 46 | + | continue | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Check if this socket exists | |||
| 50 | + | if _, err := os.Stat(path); err == nil { | |||
| 51 | + | log.Printf("Found alternative socket at %s", path) | |||
| 52 | + | ||||
| 53 | + | // Detect if this is a Podman socket | |||
| 54 | + | isPodmanSocket = detectPodmanSocket(path) | |||
| 55 | + | if isPodmanSocket { | |||
| 56 | + | cc.Podman = true | |||
| 57 | + | cc.PodmanSock = path | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | cc.Sock = path | |||
| 61 | + | ||||
| 62 | + | // Try again with the new path | |||
| 63 | + | newConfig := NewDockerConfig(cc) | |||
| 64 | + | if err := docker.InitializeClient(newConfig); err == nil { | |||
| 65 | + | return nil | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | // Return without an error, as we want to continue even if Docker is not available | |||
| 71 | + | log.Printf("Docker/Podman functionality will be limited or unavailable") | |||
| 72 | + | return fmt.Errorf("could not connect to container engine: %w", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | return err | |||
| 13 | } | 76 | } | ||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // detectPodmanSocket checks if a socket path is likely a Podman socket | |||
| 81 | + | func detectPodmanSocket(socketPath string) bool { | |||
| 82 | + | if socketPath == "" { | |||
| 83 | + | return false | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // Check if it's a user-specific Podman socket path | |||
| 87 | + | if strings.Contains(socketPath, "user") && strings.Contains(socketPath, "podman") { | |||
| 88 | + | log.Printf("User-specific rootless Podman socket detected: %s", socketPath) | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | return strings.Contains(socketPath, "podman") | |||
| 14 | } | 93 | } | ||
| 15 | 94 | ||||
| 16 | // NewDockerConfig creates and returns a new Docker client configuration | 95 | // NewDockerConfig creates and returns a new Docker client configuration | ||
M internal/db/db_struct.go +35 -0
| @@ -42,3 +42,38 @@ type Clients struct { | |||
| 42 | 42 | Hostname string `sql:"hostname"` | |
| 43 | 43 | Expires string `sql:"expires"` | |
| 44 | 44 | } | |
| 45 | + | ||
| 46 | + | // Domain represents a domain configuration for the proxy | |
| 47 | + | type Domain struct { | |
| 48 | + | ID string `sql:"id, primary_key"` | |
| 49 | + | Name string `sql:"name"` // e.g. example.com | |
| 50 | + | AccountID string `sql:"account_id, foreign_key=account.id"` | |
| 51 | + | CreatedAt string `sql:"created_at"` | |
| 52 | + | UpdatedAt string `sql:"updated_at"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Certificate represents a TLS certificate for a domain | |
| 56 | + | type Certificate struct { | |
| 57 | + | ID string `sql:"id, primary_key"` | |
| 58 | + | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |
| 59 | + | CertFile string `sql:"cert_file"` // Path to the certificate file | |
| 60 | + | KeyFile string `sql:"key_file"` // Path to the private key file | |
| 61 | + | IssuedAt string `sql:"issued_at"` | |
| 62 | + | ExpiresAt string `sql:"expires_at"` | |
| 63 | + | Issuer string `sql:"issuer"` // e.g. "Let's Encrypt" | |
| 64 | + | Status string `sql:"status"` // e.g. valid, expired, revoked | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // ProxyRoute represents a mapping from a domain to a container | |
| 68 | + | type ProxyRoute struct { | |
| 69 | + | ID string `sql:"id, primary_key"` | |
| 70 | + | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |
| 71 | + | ContainerID string `sql:"container_id"` | |
| 72 | + | ContainerIP string `sql:"container_ip"` | |
| 73 | + | ContainerPort string `sql:"container_port"` | |
| 74 | + | Protocol string `sql:"protocol"` // http or https | |
| 75 | + | Path string `sql:"path"` // path prefix to route (e.g., /api) | |
| 76 | + | Active bool `sql:"active"` | |
| 77 | + | CreatedAt string `sql:"created_at"` | |
| 78 | + | UpdatedAt string `sql:"updated_at"` | |
| 79 | + | } | |
| @@ -42,3 +42,38 @@ type Clients struct { | |||||
| 42 | Hostname string `sql:"hostname"` | 42 | Hostname string `sql:"hostname"` | ||
| 43 | Expires string `sql:"expires"` | 43 | Expires string `sql:"expires"` | ||
| 44 | } | 44 | } | ||
| 45 | + | ||||
| 46 | + | // Domain represents a domain configuration for the proxy | |||
| 47 | + | type Domain struct { | |||
| 48 | + | ID string `sql:"id, primary_key"` | |||
| 49 | + | Name string `sql:"name"` // e.g. example.com | |||
| 50 | + | AccountID string `sql:"account_id, foreign_key=account.id"` | |||
| 51 | + | CreatedAt string `sql:"created_at"` | |||
| 52 | + | UpdatedAt string `sql:"updated_at"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Certificate represents a TLS certificate for a domain | |||
| 56 | + | type Certificate struct { | |||
| 57 | + | ID string `sql:"id, primary_key"` | |||
| 58 | + | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |||
| 59 | + | CertFile string `sql:"cert_file"` // Path to the certificate file | |||
| 60 | + | KeyFile string `sql:"key_file"` // Path to the private key file | |||
| 61 | + | IssuedAt string `sql:"issued_at"` | |||
| 62 | + | ExpiresAt string `sql:"expires_at"` | |||
| 63 | + | Issuer string `sql:"issuer"` // e.g. "Let's Encrypt" | |||
| 64 | + | Status string `sql:"status"` // e.g. valid, expired, revoked | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | // ProxyRoute represents a mapping from a domain to a container | |||
| 68 | + | type ProxyRoute struct { | |||
| 69 | + | ID string `sql:"id, primary_key"` | |||
| 70 | + | DomainID string `sql:"domain_id, foreign_key=domain.id"` | |||
| 71 | + | ContainerID string `sql:"container_id"` | |||
| 72 | + | ContainerIP string `sql:"container_ip"` | |||
| 73 | + | ContainerPort string `sql:"container_port"` | |||
| 74 | + | Protocol string `sql:"protocol"` // http or https | |||
| 75 | + | Path string `sql:"path"` // path prefix to route (e.g., /api) | |||
| 76 | + | Active bool `sql:"active"` | |||
| 77 | + | CreatedAt string `sql:"created_at"` | |||
| 78 | + | UpdatedAt string `sql:"updated_at"` | |||
| 79 | + | } | |||
M internal/httpserve/handlers/container_management.go +34 -16
| @@ -62,14 +62,25 @@ type HumanReadableContainerImage struct { | |||
| 62 | 62 | ||
| 63 | 63 | type HumanReadableContainer struct { | |
| 64 | 64 | *types.Container | |
| 65 | + | Name string | |
| 66 | + | Ports []string | |
| 67 | + | ShortID string | |
| 68 | + | CreatedStr string | |
| 69 | + | SizeStr string | |
| 70 | + | UpSince string | |
| 71 | + | StateColor string | |
| 72 | + | ProxyPort string | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | type ContainerDisplay struct { | |
| 76 | + | ID string | |
| 65 | 77 | Name string | |
| 66 | - | Ports []string | |
| 67 | - | ShortID string | |
| 78 | + | Ports []docker.PortMapping | |
| 79 | + | ProxyPort string | |
| 68 | 80 | CreatedStr string | |
| 69 | - | SizeStr string | |
| 70 | - | UpSince string | |
| 71 | - | StateColor string | |
| 72 | - | TraefikEntryPoint string | |
| 81 | + | Status string | |
| 82 | + | ImageName string | |
| 83 | + | IsGordonContainer bool | |
| 73 | 84 | } | |
| 74 | 85 | ||
| 75 | 86 | // renderHTML is a generalized function to render HTML | |
| @@ -180,14 +191,14 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||
| 180 | 191 | for _, name := range container.Names { | |
| 181 | 192 | name = name[1:] | |
| 182 | 193 | humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{ | |
| 183 | - | Container: &localContainer, | |
| 184 | - | SizeStr: sizeStr, | |
| 185 | - | UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 186 | - | StateColor: stateColor, | |
| 187 | - | Name: name, | |
| 188 | - | Ports: ports, | |
| 189 | - | TraefikEntryPoint: extractTraefikEntryPoint(container.Labels), | |
| 190 | - | CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 194 | + | Container: &localContainer, | |
| 195 | + | SizeStr: sizeStr, | |
| 196 | + | UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 197 | + | StateColor: stateColor, | |
| 198 | + | Name: name, | |
| 199 | + | Ports: ports, | |
| 200 | + | ProxyPort: extractProxyPort(container.Labels), | |
| 201 | + | CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)), | |
| 191 | 202 | }) | |
| 192 | 203 | } | |
| 193 | 204 | } | |
| @@ -203,12 +214,19 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||
| 203 | 214 | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | |
| 204 | 215 | } | |
| 205 | 216 | ||
| 206 | - | func extractTraefikEntryPoint(labels map[string]string) string { | |
| 217 | + | func extractProxyPort(labels map[string]string) string { | |
| 218 | + | // Check for the gordon proxy port label | |
| 219 | + | if port, ok := labels["gordon.proxy.port"]; ok { | |
| 220 | + | return port | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // Backward compatibility for Traefik-labeled containers | |
| 207 | 224 | for key, value := range labels { | |
| 208 | - | if strings.HasSuffix(key, ".loadbalancer.server.port") { | |
| 225 | + | if strings.Contains(key, "loadbalancer.server.port") { | |
| 209 | 226 | return value | |
| 210 | 227 | } | |
| 211 | 228 | } | |
| 229 | + | ||
| 212 | 230 | return "" | |
| 213 | 231 | } | |
| 214 | 232 | ||
| @@ -62,14 +62,25 @@ type HumanReadableContainerImage struct { | |||||
| 62 | 62 | ||||
| 63 | type HumanReadableContainer struct { | 63 | type HumanReadableContainer struct { | ||
| 64 | *types.Container | 64 | *types.Container | ||
| 65 | + | Name string | |||
| 66 | + | Ports []string | |||
| 67 | + | ShortID string | |||
| 68 | + | CreatedStr string | |||
| 69 | + | SizeStr string | |||
| 70 | + | UpSince string | |||
| 71 | + | StateColor string | |||
| 72 | + | ProxyPort string | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | type ContainerDisplay struct { | |||
| 76 | + | ID string | |||
| 65 | Name string | 77 | Name string | ||
| 66 | - | Ports []string | 78 | + | Ports []docker.PortMapping |
| 67 | - | ShortID string | 79 | + | ProxyPort string |
| 68 | CreatedStr string | 80 | CreatedStr string | ||
| 69 | - | SizeStr string | 81 | + | Status string |
| 70 | - | UpSince string | 82 | + | ImageName string |
| 71 | - | StateColor string | 83 | + | IsGordonContainer bool |
| 72 | - | TraefikEntryPoint string | |||
| 73 | } | 84 | } | ||
| 74 | 85 | ||||
| 75 | // renderHTML is a generalized function to render HTML | 86 | // renderHTML is a generalized function to render HTML | ||
| @@ -180,14 +191,14 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||||
| 180 | for _, name := range container.Names { | 191 | for _, name := range container.Names { | ||
| 181 | name = name[1:] | 192 | name = name[1:] | ||
| 182 | humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{ | 193 | humanReadableContainers = append(humanReadableContainers, HumanReadableContainer{ | ||
| 183 | - | Container: &localContainer, | 194 | + | Container: &localContainer, |
| 184 | - | SizeStr: sizeStr, | 195 | + | SizeStr: sizeStr, |
| 185 | - | UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)), | 196 | + | UpSince: humanize.TimeAgo(time.Unix(container.Created, 0)), |
| 186 | - | StateColor: stateColor, | 197 | + | StateColor: stateColor, |
| 187 | - | Name: name, | 198 | + | Name: name, |
| 188 | - | Ports: ports, | 199 | + | Ports: ports, |
| 189 | - | TraefikEntryPoint: extractTraefikEntryPoint(container.Labels), | 200 | + | ProxyPort: extractProxyPort(container.Labels), |
| 190 | - | CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)), | 201 | + | CreatedStr: humanize.TimeAgo(time.Unix(container.Created, 0)), |
| 191 | }) | 202 | }) | ||
| 192 | } | 203 | } | ||
| 193 | } | 204 | } | ||
| @@ -203,12 +214,19 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error { | |||||
| 203 | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | 214 | return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data) | ||
| 204 | } | 215 | } | ||
| 205 | 216 | ||||
| 206 | - | func extractTraefikEntryPoint(labels map[string]string) string { | 217 | + | func extractProxyPort(labels map[string]string) string { |
| 218 | + | // Check for the gordon proxy port label | |||
| 219 | + | if port, ok := labels["gordon.proxy.port"]; ok { | |||
| 220 | + | return port | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // Backward compatibility for Traefik-labeled containers | |||
| 207 | for key, value := range labels { | 224 | for key, value := range labels { | ||
| 208 | - | if strings.HasSuffix(key, ".loadbalancer.server.port") { | 225 | + | if strings.Contains(key, "loadbalancer.server.port") { |
| 209 | return value | 226 | return value | ||
| 210 | } | 227 | } | ||
| 211 | } | 228 | } | ||
| 229 | + | ||||
| 212 | return "" | 230 | return "" | ||
| 213 | } | 231 | } | ||
| 214 | 232 | ||||
M internal/httpserve/handlers/endpoint_deploy.go +43 -0
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | |
| 14 | 14 | "github.com/bnema/gordon/internal/common" | |
| 15 | + | "github.com/bnema/gordon/internal/proxy" | |
| 15 | 16 | "github.com/bnema/gordon/internal/server" | |
| 16 | 17 | "github.com/bnema/gordon/pkg/docker" | |
| 17 | 18 | "github.com/charmbracelet/log" | |
| @@ -76,6 +77,17 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||
| 76 | 77 | }) | |
| 77 | 78 | } | |
| 78 | 79 | ||
| 80 | + | // After successfully deploying a container, add a proxy route | |
| 81 | + | err = addProxyRoute(a, containerID, containerName, payload.Port, payload.TargetDomain) | |
| 82 | + | if err != nil { | |
| 83 | + | log.Error("Failed to add proxy route", "error", err) | |
| 84 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |
| 85 | + | Success: false, | |
| 86 | + | Message: fmt.Sprintf("Failed to add proxy route: %v", err), | |
| 87 | + | StatusCode: http.StatusInternalServerError, | |
| 88 | + | }) | |
| 89 | + | } | |
| 90 | + | ||
| 79 | 91 | // Arrived here means deployment was successful | |
| 80 | 92 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 81 | 93 | Success: true, | |
| @@ -316,6 +328,13 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||
| 316 | 328 | "containerName", containerName, | |
| 317 | 329 | "domain", targetDomain) | |
| 318 | 330 | ||
| 331 | + | // After successfully deploying a container, add a proxy route | |
| 332 | + | err = addProxyRoute(a, containerID, containerName, port, targetDomain) | |
| 333 | + | if err != nil { | |
| 334 | + | log.Error("Failed to add proxy route", "error", err) | |
| 335 | + | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |
| 336 | + | } | |
| 337 | + | ||
| 319 | 338 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | |
| 320 | 339 | Success: true, | |
| 321 | 340 | Message: "Deployment successful", | |
| @@ -371,3 +390,27 @@ func sendDeployErrorResponse(c echo.Context, message string, err error) error { | |||
| 371 | 390 | ||
| 372 | 391 | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | |
| 373 | 392 | // } | |
| 393 | + | ||
| 394 | + | // After successfully deploying a container, add a proxy route | |
| 395 | + | func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targetDomain string) error { | |
| 396 | + | // Create a new proxy instance | |
| 397 | + | p, err := proxy.NewProxy(a) | |
| 398 | + | if err != nil { | |
| 399 | + | return fmt.Errorf("failed to create proxy: %w", err) | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | // Extract the protocol (default to http) | |
| 403 | + | protocol := "http" | |
| 404 | + | ||
| 405 | + | // Add the route | |
| 406 | + | if err := p.AddRoute(targetDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | |
| 407 | + | return fmt.Errorf("failed to add proxy route: %w", err) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | log.Debug("Added proxy route", | |
| 411 | + | "domain", targetDomain, | |
| 412 | + | "containerIP", containerIP, | |
| 413 | + | "containerPort", containerPort, | |
| 414 | + | ) | |
| 415 | + | return nil | |
| 416 | + | } | |
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | 12 | ||||
| 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | 13 | authToken "github.com/bnema/gordon/internal/cli/auth" | ||
| 14 | "github.com/bnema/gordon/internal/common" | 14 | "github.com/bnema/gordon/internal/common" | ||
| 15 | + | "github.com/bnema/gordon/internal/proxy" | |||
| 15 | "github.com/bnema/gordon/internal/server" | 16 | "github.com/bnema/gordon/internal/server" | ||
| 16 | "github.com/bnema/gordon/pkg/docker" | 17 | "github.com/bnema/gordon/pkg/docker" | ||
| 17 | "github.com/charmbracelet/log" | 18 | "github.com/charmbracelet/log" | ||
| @@ -76,6 +77,17 @@ func PostDeploy(c echo.Context, a *server.App) error { | |||||
| 76 | }) | 77 | }) | ||
| 77 | } | 78 | } | ||
| 78 | 79 | ||||
| 80 | + | // After successfully deploying a container, add a proxy route | |||
| 81 | + | err = addProxyRoute(a, containerID, containerName, payload.Port, payload.TargetDomain) | |||
| 82 | + | if err != nil { | |||
| 83 | + | log.Error("Failed to add proxy route", "error", err) | |||
| 84 | + | return sendJSONResponse(c, http.StatusInternalServerError, DeployResponse{ | |||
| 85 | + | Success: false, | |||
| 86 | + | Message: fmt.Sprintf("Failed to add proxy route: %v", err), | |||
| 87 | + | StatusCode: http.StatusInternalServerError, | |||
| 88 | + | }) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 79 | // Arrived here means deployment was successful | 91 | // Arrived here means deployment was successful | ||
| 80 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | 92 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | ||
| 81 | Success: true, | 93 | Success: true, | ||
| @@ -316,6 +328,13 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe | |||||
| 316 | "containerName", containerName, | 328 | "containerName", containerName, | ||
| 317 | "domain", targetDomain) | 329 | "domain", targetDomain) | ||
| 318 | 330 | ||||
| 331 | + | // After successfully deploying a container, add a proxy route | |||
| 332 | + | err = addProxyRoute(a, containerID, containerName, port, targetDomain) | |||
| 333 | + | if err != nil { | |||
| 334 | + | log.Error("Failed to add proxy route", "error", err) | |||
| 335 | + | return sendDeployErrorResponse(c, "Failed to add proxy route", err) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 319 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | 338 | return sendJSONResponse(c, http.StatusOK, DeployResponse{ | ||
| 320 | Success: true, | 339 | Success: true, | ||
| 321 | Message: "Deployment successful", | 340 | Message: "Deployment successful", | ||
| @@ -371,3 +390,27 @@ func sendDeployErrorResponse(c echo.Context, message string, err error) error { | |||||
| 371 | 390 | ||||
| 372 | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | 391 | // return fmt.Errorf("deployment not ready after %d attempts", maxRetries) | ||
| 373 | // } | 392 | // } | ||
| 393 | + | ||||
| 394 | + | // After successfully deploying a container, add a proxy route | |||
| 395 | + | func addProxyRoute(a *server.App, containerID, containerIP, containerPort, targetDomain string) error { | |||
| 396 | + | // Create a new proxy instance | |||
| 397 | + | p, err := proxy.NewProxy(a) | |||
| 398 | + | if err != nil { | |||
| 399 | + | return fmt.Errorf("failed to create proxy: %w", err) | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | // Extract the protocol (default to http) | |||
| 403 | + | protocol := "http" | |||
| 404 | + | ||||
| 405 | + | // Add the route | |||
| 406 | + | if err := p.AddRoute(targetDomain, containerID, containerIP, containerPort, protocol, "/"); err != nil { | |||
| 407 | + | return fmt.Errorf("failed to add proxy route: %w", err) | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | log.Debug("Added proxy route", | |||
| 411 | + | "domain", targetDomain, | |||
| 412 | + | "containerIP", containerIP, | |||
| 413 | + | "containerPort", containerPort, | |||
| 414 | + | ) | |||
| 415 | + | return nil | |||
| 416 | + | } | |||
A internal/httpserve/proxy_init.go +26 -0
| @@ -0,0 +1,26 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,26 @@ | |||||
| 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 | + | } | |||
A internal/interfaces/app_interface.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 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 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 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/proxy/proxy.go +804 -0
| @@ -0,0 +1,804 @@ | |||
| 1 | + | package proxy | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "crypto/tls" | |
| 6 | + | "database/sql" | |
| 7 | + | "fmt" | |
| 8 | + | "net/http" | |
| 9 | + | "net/http/httputil" | |
| 10 | + | "net/url" | |
| 11 | + | "os" | |
| 12 | + | "strings" | |
| 13 | + | "sync" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/gordon/internal/common" | |
| 17 | + | "github.com/bnema/gordon/internal/interfaces" | |
| 18 | + | "github.com/charmbracelet/log" | |
| 19 | + | "github.com/labstack/echo/v4" | |
| 20 | + | "github.com/labstack/echo/v4/middleware" | |
| 21 | + | "golang.org/x/crypto/acme" | |
| 22 | + | "golang.org/x/crypto/acme/autocert" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Proxy represents the reverse proxy server | |
| 26 | + | type Proxy struct { | |
| 27 | + | config common.ReverseProxyConfig | |
| 28 | + | app interfaces.AppInterface | |
| 29 | + | httpsServer *echo.Echo | |
| 30 | + | httpServer *echo.Echo | |
| 31 | + | routes map[string]*ProxyRouteInfo | |
| 32 | + | mu sync.RWMutex | |
| 33 | + | certManager *autocert.Manager | |
| 34 | + | serverStarted bool | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // ProxyRouteInfo contains the information needed to route traffic to a container | |
| 38 | + | type ProxyRouteInfo struct { | |
| 39 | + | Domain string | |
| 40 | + | ContainerIP string | |
| 41 | + | ContainerPort string | |
| 42 | + | ContainerID string | |
| 43 | + | Protocol string | |
| 44 | + | Path string | |
| 45 | + | Active bool | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // NewProxy creates a new reverse proxy | |
| 49 | + | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |
| 50 | + | config := app.GetConfig().ReverseProxy | |
| 51 | + | ||
| 52 | + | // Create the routes map | |
| 53 | + | routes := make(map[string]*ProxyRouteInfo) | |
| 54 | + | ||
| 55 | + | // Create the HTTPS echo instance | |
| 56 | + | httpsServer := echo.New() | |
| 57 | + | httpsServer.HideBanner = true | |
| 58 | + | httpsServer.Use(middleware.Recover()) | |
| 59 | + | httpsServer.Use(middleware.Logger()) | |
| 60 | + | ||
| 61 | + | // Create the HTTP echo instance (for redirects to HTTPS) | |
| 62 | + | httpServer := echo.New() | |
| 63 | + | httpServer.HideBanner = true | |
| 64 | + | httpServer.Use(middleware.Recover()) | |
| 65 | + | httpServer.Use(middleware.Logger()) | |
| 66 | + | ||
| 67 | + | // Create the proxy | |
| 68 | + | p := &Proxy{ | |
| 69 | + | config: config, | |
| 70 | + | app: app, | |
| 71 | + | httpsServer: httpsServer, | |
| 72 | + | httpServer: httpServer, | |
| 73 | + | routes: routes, | |
| 74 | + | serverStarted: false, | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Set up the certificate manager | |
| 78 | + | p.setupCertManager() | |
| 79 | + | ||
| 80 | + | log.Debug("Reverse proxy initialized") | |
| 81 | + | return p, nil | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // setupCertManager configures the autocert manager for Let's Encrypt | |
| 85 | + | func (p *Proxy) setupCertManager() { | |
| 86 | + | // Create a cache directory if it doesn't exist | |
| 87 | + | dir := p.config.CertDir | |
| 88 | + | if dir == "" { | |
| 89 | + | dir = p.app.GetConfig().General.StorageDir + "/certs" | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Set up the certificate manager | |
| 93 | + | certManager := &autocert.Manager{ | |
| 94 | + | Prompt: autocert.AcceptTOS, | |
| 95 | + | Cache: autocert.DirCache(dir), | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // Configure the email if provided | |
| 99 | + | if p.config.Email != "" { | |
| 100 | + | certManager.Email = p.config.Email | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // Configure the Let's Encrypt client | |
| 104 | + | if p.config.LetsEncryptMode == "staging" { | |
| 105 | + | certManager.Client = &acme.Client{ | |
| 106 | + | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // Set HostPolicy to allow the admin domain | |
| 111 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 112 | + | certManager.HostPolicy = func(_ context.Context, host string) error { | |
| 113 | + | // Always allow the admin domain | |
| 114 | + | if host == adminDomain { | |
| 115 | + | return nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // For other domains, check if they are in our routes | |
| 119 | + | p.mu.RLock() | |
| 120 | + | defer p.mu.RUnlock() | |
| 121 | + | ||
| 122 | + | if _, ok := p.routes[host]; ok { | |
| 123 | + | return nil | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | return fmt.Errorf("host %q not configured in gordon", host) | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | p.certManager = certManager | |
| 130 | + | log.Debug("Certificate manager setup completed", | |
| 131 | + | "directory", dir, | |
| 132 | + | "mode", p.config.LetsEncryptMode) | |
| 133 | + | ||
| 134 | + | // Request the certificate for the admin domain | |
| 135 | + | go p.requestAdminCertificate() | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |
| 139 | + | // for the Gordon admin interface | |
| 140 | + | func (p *Proxy) requestAdminCertificate() { | |
| 141 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 142 | + | ||
| 143 | + | log.Info("Initiating Let's Encrypt certificate request for admin domain", | |
| 144 | + | "domain", adminDomain, | |
| 145 | + | "email", p.config.Email, | |
| 146 | + | "mode", p.config.LetsEncryptMode, | |
| 147 | + | "cert_dir", p.config.CertDir) | |
| 148 | + | ||
| 149 | + | // Set up an HTTP client that can be used to make a request to trigger certificate generation | |
| 150 | + | client := &http.Client{ | |
| 151 | + | Transport: &http.Transport{ | |
| 152 | + | TLSClientConfig: &tls.Config{ | |
| 153 | + | InsecureSkipVerify: true, // We expect cert errors since we're creating the cert | |
| 154 | + | }, | |
| 155 | + | }, | |
| 156 | + | Timeout: 30 * time.Second, | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | // We need to make an HTTPS request to our domain to trigger the certificate generation | |
| 160 | + | // The first attempt will generate a certificate through Let's Encrypt | |
| 161 | + | url := fmt.Sprintf("https://%s", adminDomain) | |
| 162 | + | ||
| 163 | + | log.Debug("Sending HTTPS request to trigger certificate creation", | |
| 164 | + | "url", url) | |
| 165 | + | ||
| 166 | + | // Make the request | |
| 167 | + | resp, err := client.Get(url) | |
| 168 | + | if err != nil { | |
| 169 | + | log.Debug("Certificate request triggered with expected TLS error (this is normal)", | |
| 170 | + | "domain", adminDomain, | |
| 171 | + | "error", err.Error()) | |
| 172 | + | ||
| 173 | + | // Check if the error message contains indications that DNS might not be configured | |
| 174 | + | if strings.Contains(err.Error(), "no such host") || | |
| 175 | + | strings.Contains(err.Error(), "dial tcp") || | |
| 176 | + | strings.Contains(err.Error(), "lookup") { | |
| 177 | + | log.Error("⚠️ DNS resolution failed for admin domain. Make sure the domain points to this server's IP address", | |
| 178 | + | "domain", adminDomain) | |
| 179 | + | } | |
| 180 | + | } else { | |
| 181 | + | resp.Body.Close() | |
| 182 | + | log.Info("Initial HTTPS request completed (unexpected)", | |
| 183 | + | "domain", adminDomain, | |
| 184 | + | "status", resp.StatusCode) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | // Wait a bit for the certificate to be obtained | |
| 188 | + | log.Debug("Waiting for certificate generation process to complete") | |
| 189 | + | for i := 1; i <= 12; i++ { // Check for ~60 seconds (12 * 5s) | |
| 190 | + | time.Sleep(5 * time.Second) | |
| 191 | + | ||
| 192 | + | // Try to get the certificate from the cache | |
| 193 | + | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |
| 194 | + | cert, err := p.certManager.GetCertificate(hello) | |
| 195 | + | ||
| 196 | + | if err == nil && cert != nil && cert.Leaf != nil { | |
| 197 | + | log.Info("✅ Successfully obtained certificate for admin domain", | |
| 198 | + | "domain", adminDomain, | |
| 199 | + | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |
| 200 | + | "issuer", cert.Leaf.Issuer.CommonName, | |
| 201 | + | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |
| 202 | + | return | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | log.Debug("Certificate not ready yet, continuing to wait", | |
| 206 | + | "attempt", i, | |
| 207 | + | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Final check | |
| 211 | + | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |
| 212 | + | cert, err := p.certManager.GetCertificate(hello) | |
| 213 | + | ||
| 214 | + | if err != nil { | |
| 215 | + | log.Error("Failed to request certificate for admin domain after multiple attempts", | |
| 216 | + | "domain", adminDomain, | |
| 217 | + | "error", err) | |
| 218 | + | ||
| 219 | + | // Let's provide clear guidance about what might be wrong | |
| 220 | + | log.Error("⚠️ Certificate generation troubleshooting guide:") | |
| 221 | + | log.Error("1. Ensure DNS is correctly configured with the domain pointing to this server's IP") | |
| 222 | + | log.Error("2. Make sure port 80 is open and accessible from the internet for HTTP-01 challenge verification") | |
| 223 | + | log.Error("3. Check for rate limits from Let's Encrypt (https://letsencrypt.org/docs/rate-limits/)") | |
| 224 | + | log.Error("4. Verify that the domain belongs to you and is properly registered") | |
| 225 | + | } else if cert != nil && cert.Leaf != nil { | |
| 226 | + | log.Info("✅ Successfully obtained certificate for admin domain", | |
| 227 | + | "domain", adminDomain, | |
| 228 | + | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |
| 229 | + | "issuer", cert.Leaf.Issuer.CommonName) | |
| 230 | + | } else { | |
| 231 | + | log.Warn("Certificate request in progress for admin domain - may take longer to complete", | |
| 232 | + | "domain", adminDomain) | |
| 233 | + | log.Info("You may need to restart the server once your DNS propagation is complete") | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Start loads the routes from the database and starts the proxy server | |
| 238 | + | func (p *Proxy) Start() error { | |
| 239 | + | // Check if there might be port conflicts with the main server | |
| 240 | + | mainServerPort := p.app.GetConfig().Http.Port | |
| 241 | + | ||
| 242 | + | // Check HTTP port conflict | |
| 243 | + | if p.config.HttpPort == mainServerPort { | |
| 244 | + | log.Warn("HTTP port for reverse proxy conflicts with main server port", | |
| 245 | + | "port", p.config.HttpPort, | |
| 246 | + | "solution", "reverse proxy HTTP server will be disabled") | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // Check HTTPS port conflict (less common, but still possible) | |
| 251 | + | if p.config.Port == mainServerPort { | |
| 252 | + | log.Warn("HTTPS port for reverse proxy conflicts with main server port", | |
| 253 | + | "port", p.config.Port, | |
| 254 | + | "solution", "reverse proxy HTTPS server will be disabled") | |
| 255 | + | return nil | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // Load routes from the database | |
| 259 | + | if err := p.loadRoutes(); err != nil { | |
| 260 | + | return fmt.Errorf("failed to load routes: %w", err) | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // Add a special route for the admin domain (Gordon itself) | |
| 264 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 265 | + | ||
| 266 | + | // In a container environment, we need to use the host container IP | |
| 267 | + | // instead of 127.0.0.1 because each container has its own localhost | |
| 268 | + | containerIP := "host.containers.internal" // Modern Docker/Podman default hostname for host | |
| 269 | + | ||
| 270 | + | // Fall back options for container IP | |
| 271 | + | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |
| 272 | + | // Allow explicit configuration via env var | |
| 273 | + | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |
| 274 | + | log.Debug("Using admin host from environment variable", | |
| 275 | + | "host", containerIP) | |
| 276 | + | } else if os.Getenv("HOSTNAME") != "" { | |
| 277 | + | // Use container's own hostname as they're on the same network | |
| 278 | + | containerIP = os.Getenv("HOSTNAME") | |
| 279 | + | log.Debug("Using container hostname for admin routing", | |
| 280 | + | "hostname", containerIP) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // Try to detect the optimal Gordon admin host by testing connections | |
| 284 | + | testedIP := p.testAdminConnection(containerIP, p.app.GetConfig().Http.Port) | |
| 285 | + | if testedIP != "" && testedIP != containerIP { | |
| 286 | + | log.Info("Auto-detected working connection to Gordon admin", | |
| 287 | + | "host", testedIP, | |
| 288 | + | "original", containerIP) | |
| 289 | + | containerIP = testedIP | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | p.mu.Lock() | |
| 293 | + | // Only add if it doesn't already exist | |
| 294 | + | if _, exists := p.routes[adminDomain]; !exists { | |
| 295 | + | p.routes[adminDomain] = &ProxyRouteInfo{ | |
| 296 | + | Domain: adminDomain, | |
| 297 | + | ContainerIP: containerIP, | |
| 298 | + | ContainerPort: p.app.GetConfig().Http.Port, | |
| 299 | + | ContainerID: "gordon-server", | |
| 300 | + | Protocol: "http", // Gordon server uses HTTP internally | |
| 301 | + | Path: "/", | |
| 302 | + | Active: true, | |
| 303 | + | } | |
| 304 | + | log.Info("Added special route for admin domain", | |
| 305 | + | "domain", adminDomain, | |
| 306 | + | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |
| 307 | + | } | |
| 308 | + | p.mu.Unlock() | |
| 309 | + | ||
| 310 | + | // Configure the proxy routes | |
| 311 | + | p.configureRoutes() | |
| 312 | + | ||
| 313 | + | // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges | |
| 314 | + | // and redirect everything else to HTTPS | |
| 315 | + | p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil))) | |
| 316 | + | ||
| 317 | + | // Handle all other HTTP requests by redirecting to HTTPS | |
| 318 | + | p.httpServer.Any("/*", func(c echo.Context) error { | |
| 319 | + | // Skip handling acme challenges (already handled above) | |
| 320 | + | if strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/") { | |
| 321 | + | return nil | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | host := c.Request().Host | |
| 325 | + | if strings.Contains(host, ":") { | |
| 326 | + | host = strings.Split(host, ":")[0] | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | // Redirect to HTTPS | |
| 330 | + | return c.Redirect(http.StatusMovedPermanently, | |
| 331 | + | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | |
| 332 | + | }) | |
| 333 | + | ||
| 334 | + | // Start the HTTPS server | |
| 335 | + | httpsServer := &http.Server{ | |
| 336 | + | Addr: ":" + p.config.Port, | |
| 337 | + | Handler: p.httpsServer, | |
| 338 | + | TLSConfig: &tls.Config{ | |
| 339 | + | GetCertificate: p.certManager.GetCertificate, | |
| 340 | + | MinVersion: tls.VersionTLS12, | |
| 341 | + | }, | |
| 342 | + | ReadTimeout: 5 * time.Minute, | |
| 343 | + | WriteTimeout: 5 * time.Minute, | |
| 344 | + | IdleTimeout: 120 * time.Second, | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |
| 348 | + | httpServer := &http.Server{ | |
| 349 | + | Addr: ":" + p.config.HttpPort, | |
| 350 | + | Handler: p.httpServer, | |
| 351 | + | ReadTimeout: 5 * time.Minute, | |
| 352 | + | WriteTimeout: 5 * time.Minute, | |
| 353 | + | IdleTimeout: 120 * time.Second, | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | p.serverStarted = true | |
| 357 | + | ||
| 358 | + | // Start the HTTPS server | |
| 359 | + | go func() { | |
| 360 | + | log.Info("Starting HTTPS reverse proxy server", "port", p.config.Port) | |
| 361 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |
| 362 | + | log.Error("Failed to start HTTPS reverse proxy server", "err", err) | |
| 363 | + | } | |
| 364 | + | }() | |
| 365 | + | ||
| 366 | + | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |
| 367 | + | go func() { | |
| 368 | + | log.Info("Starting HTTP reverse proxy server (for redirects and Let's Encrypt challenges)", "port", p.config.HttpPort) | |
| 369 | + | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |
| 370 | + | log.Error("Failed to start HTTP reverse proxy server", "err", err) | |
| 371 | + | } | |
| 372 | + | }() | |
| 373 | + | ||
| 374 | + | return nil | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | // testAdminConnection attempts to find a working connection to the Gordon admin | |
| 378 | + | // by testing different hostnames and IPs | |
| 379 | + | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |
| 380 | + | log.Debug("Testing connections to Gordon admin server") | |
| 381 | + | ||
| 382 | + | // Create a client with a short timeout to quickly test connections | |
| 383 | + | client := &http.Client{ | |
| 384 | + | Timeout: 1 * time.Second, | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // Define addresses to try (from most likely to least likely) | |
| 388 | + | addresses := []string{ | |
| 389 | + | defaultHost, // Try the provided default first | |
| 390 | + | "localhost", // Local connections | |
| 391 | + | "127.0.0.1", // Localhost IP | |
| 392 | + | "host.docker.internal", // Docker host | |
| 393 | + | "host.containers.internal", // Podman/Docker host | |
| 394 | + | "172.17.0.1", // Common Docker gateway | |
| 395 | + | os.Getenv("HOSTNAME"), // Container's own hostname | |
| 396 | + | "gordon", // Service name | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | // Remove duplicates and empty entries | |
| 400 | + | uniqueAddresses := make([]string, 0, len(addresses)) | |
| 401 | + | seen := make(map[string]bool) | |
| 402 | + | ||
| 403 | + | for _, addr := range addresses { | |
| 404 | + | if addr == "" || seen[addr] { | |
| 405 | + | continue | |
| 406 | + | } | |
| 407 | + | seen[addr] = true | |
| 408 | + | uniqueAddresses = append(uniqueAddresses, addr) | |
| 409 | + | } | |
| 410 | + | ||
| 411 | + | // Test each address | |
| 412 | + | for _, addr := range uniqueAddresses { | |
| 413 | + | url := fmt.Sprintf("http://%s:%s/admin/ping", addr, port) | |
| 414 | + | log.Debug("Testing connection to Gordon admin", "url", url) | |
| 415 | + | ||
| 416 | + | resp, err := client.Get(url) | |
| 417 | + | if err == nil { | |
| 418 | + | resp.Body.Close() | |
| 419 | + | if resp.StatusCode < 500 { | |
| 420 | + | log.Debug("Successfully connected to Gordon admin", | |
| 421 | + | "host", addr, | |
| 422 | + | "status", resp.StatusCode) | |
| 423 | + | return addr | |
| 424 | + | } | |
| 425 | + | log.Debug("Received error status from Gordon admin", | |
| 426 | + | "host", addr, | |
| 427 | + | "status", resp.StatusCode) | |
| 428 | + | } else { | |
| 429 | + | log.Debug("Failed to connect to Gordon admin", | |
| 430 | + | "host", addr, | |
| 431 | + | "error", err.Error()) | |
| 432 | + | } | |
| 433 | + | } | |
| 434 | + | ||
| 435 | + | // If none worked, return the default | |
| 436 | + | return defaultHost | |
| 437 | + | } | |
| 438 | + | ||
| 439 | + | // Stop stops the proxy server | |
| 440 | + | func (p *Proxy) Stop() error { | |
| 441 | + | if !p.serverStarted { | |
| 442 | + | return nil | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | // Create a context with a timeout | |
| 446 | + | ctx, cancel := context.WithTimeout( | |
| 447 | + | context.Background(), | |
| 448 | + | time.Duration(p.config.GracePeriod)*time.Second, | |
| 449 | + | ) | |
| 450 | + | defer cancel() | |
| 451 | + | ||
| 452 | + | // Shutdown the servers | |
| 453 | + | log.Info("Stopping reverse proxy servers") | |
| 454 | + | ||
| 455 | + | // Stop HTTPS server | |
| 456 | + | if err := p.httpsServer.Shutdown(ctx); err != nil { | |
| 457 | + | log.Error("Error stopping HTTPS server", "err", err) | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | // Stop HTTP server | |
| 461 | + | if err := p.httpServer.Shutdown(ctx); err != nil { | |
| 462 | + | log.Error("Error stopping HTTP server", "err", err) | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | return nil | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | // loadRoutes loads the routes from the database | |
| 469 | + | func (p *Proxy) loadRoutes() error { | |
| 470 | + | // Lock the routes map | |
| 471 | + | p.mu.Lock() | |
| 472 | + | defer p.mu.Unlock() | |
| 473 | + | ||
| 474 | + | log.Debug("Loading proxy routes from database") | |
| 475 | + | ||
| 476 | + | // Query the database for active proxy routes | |
| 477 | + | rows, err := p.app.GetDB().Query(` | |
| 478 | + | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |
| 479 | + | FROM proxy_route pr | |
| 480 | + | JOIN domain d ON pr.domain_id = d.id | |
| 481 | + | WHERE pr.active = 1 | |
| 482 | + | `) | |
| 483 | + | if err != nil { | |
| 484 | + | if err == sql.ErrNoRows { | |
| 485 | + | log.Info("No active proxy routes found") | |
| 486 | + | return nil | |
| 487 | + | } | |
| 488 | + | return fmt.Errorf("failed to query database: %w", err) | |
| 489 | + | } | |
| 490 | + | defer rows.Close() | |
| 491 | + | ||
| 492 | + | // Clear the routes map | |
| 493 | + | p.routes = make(map[string]*ProxyRouteInfo) | |
| 494 | + | ||
| 495 | + | // Populate the routes map | |
| 496 | + | for rows.Next() { | |
| 497 | + | var id, domain, containerID, containerIP, containerPort, protocol, path string | |
| 498 | + | var active bool | |
| 499 | + | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |
| 500 | + | return fmt.Errorf("failed to scan row: %w", err) | |
| 501 | + | } | |
| 502 | + | ||
| 503 | + | // Add the route to the map | |
| 504 | + | p.routes[domain] = &ProxyRouteInfo{ | |
| 505 | + | Domain: domain, | |
| 506 | + | ContainerID: containerID, | |
| 507 | + | ContainerIP: containerIP, | |
| 508 | + | ContainerPort: containerPort, | |
| 509 | + | Protocol: protocol, | |
| 510 | + | Path: path, | |
| 511 | + | Active: active, | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | log.Debug("Loaded proxy route", | |
| 515 | + | "domain", domain, | |
| 516 | + | "containerIP", containerIP, | |
| 517 | + | "containerPort", containerPort, | |
| 518 | + | ) | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | log.Info("Loaded proxy routes", "count", len(p.routes)) | |
| 522 | + | return nil | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | // configureRoutes sets up the echo routes for proxying | |
| 526 | + | func (p *Proxy) configureRoutes() { | |
| 527 | + | // Lock the routes map for reading | |
| 528 | + | p.mu.RLock() | |
| 529 | + | defer p.mu.RUnlock() | |
| 530 | + | ||
| 531 | + | // Log all available routes for debugging | |
| 532 | + | log.Debug("Configuring proxy routes") | |
| 533 | + | for domain, route := range p.routes { | |
| 534 | + | log.Debug("Configured route", | |
| 535 | + | "domain", domain, | |
| 536 | + | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |
| 537 | + | "active", route.Active) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | // Add a handler for all incoming requests (HTTPS) | |
| 541 | + | p.httpsServer.Any("/*", func(c echo.Context) error { | |
| 542 | + | host := c.Request().Host | |
| 543 | + | ||
| 544 | + | // Strip the port from the host | |
| 545 | + | if strings.Contains(host, ":") { | |
| 546 | + | host = strings.Split(host, ":")[0] | |
| 547 | + | } | |
| 548 | + | ||
| 549 | + | log.Debug("Processing request", | |
| 550 | + | "host", host, | |
| 551 | + | "path", c.Request().URL.Path, | |
| 552 | + | "method", c.Request().Method) | |
| 553 | + | ||
| 554 | + | // Find the route for this host | |
| 555 | + | route, ok := p.routes[host] | |
| 556 | + | if !ok { | |
| 557 | + | // Create list of available domains for debugging | |
| 558 | + | availableDomains := make([]string, 0, len(p.routes)) | |
| 559 | + | for d := range p.routes { | |
| 560 | + | availableDomains = append(availableDomains, d) | |
| 561 | + | } | |
| 562 | + | ||
| 563 | + | log.Warn("Domain not found in routes", | |
| 564 | + | "domain", host, | |
| 565 | + | "available_domains", strings.Join(availableDomains, ", ")) | |
| 566 | + | return c.String(http.StatusNotFound, "Domain not found") | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | // Check if the route is active | |
| 570 | + | if !route.Active { | |
| 571 | + | log.Warn("Route is not active", "domain", host) | |
| 572 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |
| 573 | + | } | |
| 574 | + | ||
| 575 | + | // Special handling for the admin domain | |
| 576 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |
| 577 | + | if host == adminDomain { | |
| 578 | + | log.Debug("Proxying request to admin domain", | |
| 579 | + | "domain", host, | |
| 580 | + | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |
| 581 | + | "path", c.Request().URL.Path) | |
| 582 | + | } | |
| 583 | + | ||
| 584 | + | // Create the target URL | |
| 585 | + | targetURL := &url.URL{ | |
| 586 | + | Scheme: route.Protocol, | |
| 587 | + | Host: fmt.Sprintf("%s:%s", route.ContainerIP, route.ContainerPort), | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // Create a reverse proxy | |
| 591 | + | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |
| 592 | + | ||
| 593 | + | // Update headers to allow for SSL redirection | |
| 594 | + | originalDirector := proxy.Director | |
| 595 | + | proxy.Director = func(req *http.Request) { | |
| 596 | + | originalDirector(req) | |
| 597 | + | req.Header.Set("X-Forwarded-Proto", "https") | |
| 598 | + | req.Header.Set("X-Forwarded-Host", host) | |
| 599 | + | req.Header.Set("X-Forwarded-For", c.RealIP()) | |
| 600 | + | req.Header.Set("X-Real-IP", c.RealIP()) | |
| 601 | + | ||
| 602 | + | // Debug information | |
| 603 | + | log.Debug("Proxying request", | |
| 604 | + | "host", host, | |
| 605 | + | "target", targetURL.String(), | |
| 606 | + | "path", req.URL.Path, | |
| 607 | + | "clientIP", c.RealIP()) | |
| 608 | + | } | |
| 609 | + | ||
| 610 | + | // Add error handling | |
| 611 | + | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |
| 612 | + | log.Error("Proxy error", | |
| 613 | + | "host", host, | |
| 614 | + | "path", r.URL.Path, | |
| 615 | + | "error", err) | |
| 616 | + | c.String(http.StatusBadGateway, "Proxy Error: "+err.Error()) | |
| 617 | + | } | |
| 618 | + | ||
| 619 | + | // Serve the request | |
| 620 | + | proxy.ServeHTTP(c.Response(), c.Request()) | |
| 621 | + | return nil | |
| 622 | + | }) | |
| 623 | + | } | |
| 624 | + | ||
| 625 | + | // Reload reloads the routes from the database and reconfigures the proxy | |
| 626 | + | func (p *Proxy) Reload() error { | |
| 627 | + | // Load routes from the database | |
| 628 | + | if err := p.loadRoutes(); err != nil { | |
| 629 | + | return fmt.Errorf("failed to load routes: %w", err) | |
| 630 | + | } | |
| 631 | + | ||
| 632 | + | // Reconfigure the routes | |
| 633 | + | p.configureRoutes() | |
| 634 | + | ||
| 635 | + | return nil | |
| 636 | + | } | |
| 637 | + | ||
| 638 | + | // AddRoute adds a new route to the database and reloads the proxy | |
| 639 | + | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | |
| 640 | + | // Begin a transaction | |
| 641 | + | tx, err := p.app.GetDB().Begin() | |
| 642 | + | if err != nil { | |
| 643 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |
| 644 | + | } | |
| 645 | + | defer tx.Rollback() | |
| 646 | + | ||
| 647 | + | // Check if the domain exists | |
| 648 | + | var domainID string | |
| 649 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |
| 650 | + | if err != nil { | |
| 651 | + | if err == sql.ErrNoRows { | |
| 652 | + | // Domain doesn't exist, create it | |
| 653 | + | domainID = generateUUID() | |
| 654 | + | now := time.Now().Format(time.RFC3339) | |
| 655 | + | _, err = tx.Exec( | |
| 656 | + | "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", | |
| 657 | + | domainID, domainName, now, now, | |
| 658 | + | ) | |
| 659 | + | if err != nil { | |
| 660 | + | return fmt.Errorf("failed to insert domain: %w", err) | |
| 661 | + | } | |
| 662 | + | } else { | |
| 663 | + | return fmt.Errorf("failed to query domain: %w", err) | |
| 664 | + | } | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // Check if a route already exists for this domain | |
| 668 | + | var existingRouteID string | |
| 669 | + | err = tx.QueryRow("SELECT id FROM proxy_route WHERE domain_id = ?", domainID).Scan(&existingRouteID) | |
| 670 | + | if err == nil { | |
| 671 | + | // Route exists, update it | |
| 672 | + | now := time.Now().Format(time.RFC3339) | |
| 673 | + | _, err = tx.Exec( | |
| 674 | + | `UPDATE proxy_route SET | |
| 675 | + | container_id = ?, | |
| 676 | + | container_ip = ?, | |
| 677 | + | container_port = ?, | |
| 678 | + | protocol = ?, | |
| 679 | + | path = ?, | |
| 680 | + | active = ?, | |
| 681 | + | updated_at = ? | |
| 682 | + | WHERE id = ?`, | |
| 683 | + | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | |
| 684 | + | ) | |
| 685 | + | if err != nil { | |
| 686 | + | return fmt.Errorf("failed to update route: %w", err) | |
| 687 | + | } | |
| 688 | + | } else if err == sql.ErrNoRows { | |
| 689 | + | // Route doesn't exist, create it | |
| 690 | + | routeID := generateUUID() | |
| 691 | + | now := time.Now().Format(time.RFC3339) | |
| 692 | + | _, err = tx.Exec( | |
| 693 | + | `INSERT INTO proxy_route ( | |
| 694 | + | id, domain_id, container_id, container_ip, container_port, | |
| 695 | + | protocol, path, active, created_at, updated_at | |
| 696 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |
| 697 | + | routeID, domainID, containerID, containerIP, containerPort, | |
| 698 | + | protocol, path, true, now, now, | |
| 699 | + | ) | |
| 700 | + | if err != nil { | |
| 701 | + | return fmt.Errorf("failed to insert route: %w", err) | |
| 702 | + | } | |
| 703 | + | } else { | |
| 704 | + | return fmt.Errorf("failed to query route: %w", err) | |
| 705 | + | } | |
| 706 | + | ||
| 707 | + | // Commit the transaction | |
| 708 | + | if err := tx.Commit(); err != nil { | |
| 709 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 710 | + | } | |
| 711 | + | ||
| 712 | + | // Reload the routes | |
| 713 | + | if err := p.Reload(); err != nil { | |
| 714 | + | return fmt.Errorf("failed to reload routes: %w", err) | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | log.Info("Added proxy route", | |
| 718 | + | "domain", domainName, | |
| 719 | + | "containerIP", containerIP, | |
| 720 | + | "containerPort", containerPort, | |
| 721 | + | ) | |
| 722 | + | return nil | |
| 723 | + | } | |
| 724 | + | ||
| 725 | + | // RemoveRoute removes a route from the database and reloads the proxy | |
| 726 | + | func (p *Proxy) RemoveRoute(domainName string) error { | |
| 727 | + | // Begin a transaction | |
| 728 | + | tx, err := p.app.GetDB().Begin() | |
| 729 | + | if err != nil { | |
| 730 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |
| 731 | + | } | |
| 732 | + | defer tx.Rollback() | |
| 733 | + | ||
| 734 | + | // Get the domain ID | |
| 735 | + | var domainID string | |
| 736 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |
| 737 | + | if err != nil { | |
| 738 | + | if err == sql.ErrNoRows { | |
| 739 | + | return fmt.Errorf("domain not found: %s", domainName) | |
| 740 | + | } | |
| 741 | + | return fmt.Errorf("failed to query domain: %w", err) | |
| 742 | + | } | |
| 743 | + | ||
| 744 | + | // Delete the route | |
| 745 | + | _, err = tx.Exec("DELETE FROM proxy_route WHERE domain_id = ?", domainID) | |
| 746 | + | if err != nil { | |
| 747 | + | return fmt.Errorf("failed to delete route: %w", err) | |
| 748 | + | } | |
| 749 | + | ||
| 750 | + | // Delete the domain | |
| 751 | + | _, err = tx.Exec("DELETE FROM domain WHERE id = ?", domainID) | |
| 752 | + | if err != nil { | |
| 753 | + | return fmt.Errorf("failed to delete domain: %w", err) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // Commit the transaction | |
| 757 | + | if err := tx.Commit(); err != nil { | |
| 758 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 759 | + | } | |
| 760 | + | ||
| 761 | + | // Reload the routes | |
| 762 | + | if err := p.Reload(); err != nil { | |
| 763 | + | return fmt.Errorf("failed to reload routes: %w", err) | |
| 764 | + | } | |
| 765 | + | ||
| 766 | + | log.Info("Removed proxy route", "domain", domainName) | |
| 767 | + | return nil | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | // generateUUID generates a UUID for use as a primary key | |
| 771 | + | func generateUUID() string { | |
| 772 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |
| 773 | + | } | |
| 774 | + | ||
| 775 | + | // joinPaths joins two paths ensuring there's only one slash between them | |
| 776 | + | func joinPaths(a, b string) string { | |
| 777 | + | if a == "" { | |
| 778 | + | return b | |
| 779 | + | } | |
| 780 | + | if b == "" { | |
| 781 | + | return a | |
| 782 | + | } | |
| 783 | + | if a[len(a)-1] == '/' { | |
| 784 | + | a = a[:len(a)-1] | |
| 785 | + | } | |
| 786 | + | if b[0] == '/' { | |
| 787 | + | b = b[1:] | |
| 788 | + | } | |
| 789 | + | return a + "/" + b | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | // GetRoutes returns a copy of the routes map | |
| 793 | + | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |
| 794 | + | p.mu.RLock() | |
| 795 | + | defer p.mu.RUnlock() | |
| 796 | + | ||
| 797 | + | // Create a copy of the routes map | |
| 798 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |
| 799 | + | for k, v := range p.routes { | |
| 800 | + | routes[k] = v | |
| 801 | + | } | |
| 802 | + | ||
| 803 | + | return routes | |
| 804 | + | } | |
| @@ -0,0 +1,804 @@ | |||||
| 1 | + | package proxy | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "crypto/tls" | |||
| 6 | + | "database/sql" | |||
| 7 | + | "fmt" | |||
| 8 | + | "net/http" | |||
| 9 | + | "net/http/httputil" | |||
| 10 | + | "net/url" | |||
| 11 | + | "os" | |||
| 12 | + | "strings" | |||
| 13 | + | "sync" | |||
| 14 | + | "time" | |||
| 15 | + | ||||
| 16 | + | "github.com/bnema/gordon/internal/common" | |||
| 17 | + | "github.com/bnema/gordon/internal/interfaces" | |||
| 18 | + | "github.com/charmbracelet/log" | |||
| 19 | + | "github.com/labstack/echo/v4" | |||
| 20 | + | "github.com/labstack/echo/v4/middleware" | |||
| 21 | + | "golang.org/x/crypto/acme" | |||
| 22 | + | "golang.org/x/crypto/acme/autocert" | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Proxy represents the reverse proxy server | |||
| 26 | + | type Proxy struct { | |||
| 27 | + | config common.ReverseProxyConfig | |||
| 28 | + | app interfaces.AppInterface | |||
| 29 | + | httpsServer *echo.Echo | |||
| 30 | + | httpServer *echo.Echo | |||
| 31 | + | routes map[string]*ProxyRouteInfo | |||
| 32 | + | mu sync.RWMutex | |||
| 33 | + | certManager *autocert.Manager | |||
| 34 | + | serverStarted bool | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // ProxyRouteInfo contains the information needed to route traffic to a container | |||
| 38 | + | type ProxyRouteInfo struct { | |||
| 39 | + | Domain string | |||
| 40 | + | ContainerIP string | |||
| 41 | + | ContainerPort string | |||
| 42 | + | ContainerID string | |||
| 43 | + | Protocol string | |||
| 44 | + | Path string | |||
| 45 | + | Active bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // NewProxy creates a new reverse proxy | |||
| 49 | + | func NewProxy(app interfaces.AppInterface) (*Proxy, error) { | |||
| 50 | + | config := app.GetConfig().ReverseProxy | |||
| 51 | + | ||||
| 52 | + | // Create the routes map | |||
| 53 | + | routes := make(map[string]*ProxyRouteInfo) | |||
| 54 | + | ||||
| 55 | + | // Create the HTTPS echo instance | |||
| 56 | + | httpsServer := echo.New() | |||
| 57 | + | httpsServer.HideBanner = true | |||
| 58 | + | httpsServer.Use(middleware.Recover()) | |||
| 59 | + | httpsServer.Use(middleware.Logger()) | |||
| 60 | + | ||||
| 61 | + | // Create the HTTP echo instance (for redirects to HTTPS) | |||
| 62 | + | httpServer := echo.New() | |||
| 63 | + | httpServer.HideBanner = true | |||
| 64 | + | httpServer.Use(middleware.Recover()) | |||
| 65 | + | httpServer.Use(middleware.Logger()) | |||
| 66 | + | ||||
| 67 | + | // Create the proxy | |||
| 68 | + | p := &Proxy{ | |||
| 69 | + | config: config, | |||
| 70 | + | app: app, | |||
| 71 | + | httpsServer: httpsServer, | |||
| 72 | + | httpServer: httpServer, | |||
| 73 | + | routes: routes, | |||
| 74 | + | serverStarted: false, | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // Set up the certificate manager | |||
| 78 | + | p.setupCertManager() | |||
| 79 | + | ||||
| 80 | + | log.Debug("Reverse proxy initialized") | |||
| 81 | + | return p, nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | // setupCertManager configures the autocert manager for Let's Encrypt | |||
| 85 | + | func (p *Proxy) setupCertManager() { | |||
| 86 | + | // Create a cache directory if it doesn't exist | |||
| 87 | + | dir := p.config.CertDir | |||
| 88 | + | if dir == "" { | |||
| 89 | + | dir = p.app.GetConfig().General.StorageDir + "/certs" | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Set up the certificate manager | |||
| 93 | + | certManager := &autocert.Manager{ | |||
| 94 | + | Prompt: autocert.AcceptTOS, | |||
| 95 | + | Cache: autocert.DirCache(dir), | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // Configure the email if provided | |||
| 99 | + | if p.config.Email != "" { | |||
| 100 | + | certManager.Email = p.config.Email | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // Configure the Let's Encrypt client | |||
| 104 | + | if p.config.LetsEncryptMode == "staging" { | |||
| 105 | + | certManager.Client = &acme.Client{ | |||
| 106 | + | DirectoryURL: "https://acme-staging-v02.api.letsencrypt.org/directory", | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | // Set HostPolicy to allow the admin domain | |||
| 111 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 112 | + | certManager.HostPolicy = func(_ context.Context, host string) error { | |||
| 113 | + | // Always allow the admin domain | |||
| 114 | + | if host == adminDomain { | |||
| 115 | + | return nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // For other domains, check if they are in our routes | |||
| 119 | + | p.mu.RLock() | |||
| 120 | + | defer p.mu.RUnlock() | |||
| 121 | + | ||||
| 122 | + | if _, ok := p.routes[host]; ok { | |||
| 123 | + | return nil | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | return fmt.Errorf("host %q not configured in gordon", host) | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | p.certManager = certManager | |||
| 130 | + | log.Debug("Certificate manager setup completed", | |||
| 131 | + | "directory", dir, | |||
| 132 | + | "mode", p.config.LetsEncryptMode) | |||
| 133 | + | ||||
| 134 | + | // Request the certificate for the admin domain | |||
| 135 | + | go p.requestAdminCertificate() | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | // requestAdminCertificate preemptively requests a Let's Encrypt certificate | |||
| 139 | + | // for the Gordon admin interface | |||
| 140 | + | func (p *Proxy) requestAdminCertificate() { | |||
| 141 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 142 | + | ||||
| 143 | + | log.Info("Initiating Let's Encrypt certificate request for admin domain", | |||
| 144 | + | "domain", adminDomain, | |||
| 145 | + | "email", p.config.Email, | |||
| 146 | + | "mode", p.config.LetsEncryptMode, | |||
| 147 | + | "cert_dir", p.config.CertDir) | |||
| 148 | + | ||||
| 149 | + | // Set up an HTTP client that can be used to make a request to trigger certificate generation | |||
| 150 | + | client := &http.Client{ | |||
| 151 | + | Transport: &http.Transport{ | |||
| 152 | + | TLSClientConfig: &tls.Config{ | |||
| 153 | + | InsecureSkipVerify: true, // We expect cert errors since we're creating the cert | |||
| 154 | + | }, | |||
| 155 | + | }, | |||
| 156 | + | Timeout: 30 * time.Second, | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | // We need to make an HTTPS request to our domain to trigger the certificate generation | |||
| 160 | + | // The first attempt will generate a certificate through Let's Encrypt | |||
| 161 | + | url := fmt.Sprintf("https://%s", adminDomain) | |||
| 162 | + | ||||
| 163 | + | log.Debug("Sending HTTPS request to trigger certificate creation", | |||
| 164 | + | "url", url) | |||
| 165 | + | ||||
| 166 | + | // Make the request | |||
| 167 | + | resp, err := client.Get(url) | |||
| 168 | + | if err != nil { | |||
| 169 | + | log.Debug("Certificate request triggered with expected TLS error (this is normal)", | |||
| 170 | + | "domain", adminDomain, | |||
| 171 | + | "error", err.Error()) | |||
| 172 | + | ||||
| 173 | + | // Check if the error message contains indications that DNS might not be configured | |||
| 174 | + | if strings.Contains(err.Error(), "no such host") || | |||
| 175 | + | strings.Contains(err.Error(), "dial tcp") || | |||
| 176 | + | strings.Contains(err.Error(), "lookup") { | |||
| 177 | + | log.Error("⚠️ DNS resolution failed for admin domain. Make sure the domain points to this server's IP address", | |||
| 178 | + | "domain", adminDomain) | |||
| 179 | + | } | |||
| 180 | + | } else { | |||
| 181 | + | resp.Body.Close() | |||
| 182 | + | log.Info("Initial HTTPS request completed (unexpected)", | |||
| 183 | + | "domain", adminDomain, | |||
| 184 | + | "status", resp.StatusCode) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | // Wait a bit for the certificate to be obtained | |||
| 188 | + | log.Debug("Waiting for certificate generation process to complete") | |||
| 189 | + | for i := 1; i <= 12; i++ { // Check for ~60 seconds (12 * 5s) | |||
| 190 | + | time.Sleep(5 * time.Second) | |||
| 191 | + | ||||
| 192 | + | // Try to get the certificate from the cache | |||
| 193 | + | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |||
| 194 | + | cert, err := p.certManager.GetCertificate(hello) | |||
| 195 | + | ||||
| 196 | + | if err == nil && cert != nil && cert.Leaf != nil { | |||
| 197 | + | log.Info("✅ Successfully obtained certificate for admin domain", | |||
| 198 | + | "domain", adminDomain, | |||
| 199 | + | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |||
| 200 | + | "issuer", cert.Leaf.Issuer.CommonName, | |||
| 201 | + | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |||
| 202 | + | return | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | log.Debug("Certificate not ready yet, continuing to wait", | |||
| 206 | + | "attempt", i, | |||
| 207 | + | "elapsed_time", fmt.Sprintf("%ds", i*5)) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // Final check | |||
| 211 | + | hello := &tls.ClientHelloInfo{ServerName: adminDomain} | |||
| 212 | + | cert, err := p.certManager.GetCertificate(hello) | |||
| 213 | + | ||||
| 214 | + | if err != nil { | |||
| 215 | + | log.Error("Failed to request certificate for admin domain after multiple attempts", | |||
| 216 | + | "domain", adminDomain, | |||
| 217 | + | "error", err) | |||
| 218 | + | ||||
| 219 | + | // Let's provide clear guidance about what might be wrong | |||
| 220 | + | log.Error("⚠️ Certificate generation troubleshooting guide:") | |||
| 221 | + | log.Error("1. Ensure DNS is correctly configured with the domain pointing to this server's IP") | |||
| 222 | + | log.Error("2. Make sure port 80 is open and accessible from the internet for HTTP-01 challenge verification") | |||
| 223 | + | log.Error("3. Check for rate limits from Let's Encrypt (https://letsencrypt.org/docs/rate-limits/)") | |||
| 224 | + | log.Error("4. Verify that the domain belongs to you and is properly registered") | |||
| 225 | + | } else if cert != nil && cert.Leaf != nil { | |||
| 226 | + | log.Info("✅ Successfully obtained certificate for admin domain", | |||
| 227 | + | "domain", adminDomain, | |||
| 228 | + | "cert_expiry", cert.Leaf.NotAfter.Format("2006-01-02 15:04:05"), | |||
| 229 | + | "issuer", cert.Leaf.Issuer.CommonName) | |||
| 230 | + | } else { | |||
| 231 | + | log.Warn("Certificate request in progress for admin domain - may take longer to complete", | |||
| 232 | + | "domain", adminDomain) | |||
| 233 | + | log.Info("You may need to restart the server once your DNS propagation is complete") | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Start loads the routes from the database and starts the proxy server | |||
| 238 | + | func (p *Proxy) Start() error { | |||
| 239 | + | // Check if there might be port conflicts with the main server | |||
| 240 | + | mainServerPort := p.app.GetConfig().Http.Port | |||
| 241 | + | ||||
| 242 | + | // Check HTTP port conflict | |||
| 243 | + | if p.config.HttpPort == mainServerPort { | |||
| 244 | + | log.Warn("HTTP port for reverse proxy conflicts with main server port", | |||
| 245 | + | "port", p.config.HttpPort, | |||
| 246 | + | "solution", "reverse proxy HTTP server will be disabled") | |||
| 247 | + | return nil | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | // Check HTTPS port conflict (less common, but still possible) | |||
| 251 | + | if p.config.Port == mainServerPort { | |||
| 252 | + | log.Warn("HTTPS port for reverse proxy conflicts with main server port", | |||
| 253 | + | "port", p.config.Port, | |||
| 254 | + | "solution", "reverse proxy HTTPS server will be disabled") | |||
| 255 | + | return nil | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // Load routes from the database | |||
| 259 | + | if err := p.loadRoutes(); err != nil { | |||
| 260 | + | return fmt.Errorf("failed to load routes: %w", err) | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // Add a special route for the admin domain (Gordon itself) | |||
| 264 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 265 | + | ||||
| 266 | + | // In a container environment, we need to use the host container IP | |||
| 267 | + | // instead of 127.0.0.1 because each container has its own localhost | |||
| 268 | + | containerIP := "host.containers.internal" // Modern Docker/Podman default hostname for host | |||
| 269 | + | ||||
| 270 | + | // Fall back options for container IP | |||
| 271 | + | if os.Getenv("GORDON_ADMIN_HOST") != "" { | |||
| 272 | + | // Allow explicit configuration via env var | |||
| 273 | + | containerIP = os.Getenv("GORDON_ADMIN_HOST") | |||
| 274 | + | log.Debug("Using admin host from environment variable", | |||
| 275 | + | "host", containerIP) | |||
| 276 | + | } else if os.Getenv("HOSTNAME") != "" { | |||
| 277 | + | // Use container's own hostname as they're on the same network | |||
| 278 | + | containerIP = os.Getenv("HOSTNAME") | |||
| 279 | + | log.Debug("Using container hostname for admin routing", | |||
| 280 | + | "hostname", containerIP) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // Try to detect the optimal Gordon admin host by testing connections | |||
| 284 | + | testedIP := p.testAdminConnection(containerIP, p.app.GetConfig().Http.Port) | |||
| 285 | + | if testedIP != "" && testedIP != containerIP { | |||
| 286 | + | log.Info("Auto-detected working connection to Gordon admin", | |||
| 287 | + | "host", testedIP, | |||
| 288 | + | "original", containerIP) | |||
| 289 | + | containerIP = testedIP | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | p.mu.Lock() | |||
| 293 | + | // Only add if it doesn't already exist | |||
| 294 | + | if _, exists := p.routes[adminDomain]; !exists { | |||
| 295 | + | p.routes[adminDomain] = &ProxyRouteInfo{ | |||
| 296 | + | Domain: adminDomain, | |||
| 297 | + | ContainerIP: containerIP, | |||
| 298 | + | ContainerPort: p.app.GetConfig().Http.Port, | |||
| 299 | + | ContainerID: "gordon-server", | |||
| 300 | + | Protocol: "http", // Gordon server uses HTTP internally | |||
| 301 | + | Path: "/", | |||
| 302 | + | Active: true, | |||
| 303 | + | } | |||
| 304 | + | log.Info("Added special route for admin domain", | |||
| 305 | + | "domain", adminDomain, | |||
| 306 | + | "target", fmt.Sprintf("http://%s:%s", containerIP, p.app.GetConfig().Http.Port)) | |||
| 307 | + | } | |||
| 308 | + | p.mu.Unlock() | |||
| 309 | + | ||||
| 310 | + | // Configure the proxy routes | |||
| 311 | + | p.configureRoutes() | |||
| 312 | + | ||||
| 313 | + | // Configure HTTP server to handle Let's Encrypt HTTP-01 challenges | |||
| 314 | + | // and redirect everything else to HTTPS | |||
| 315 | + | p.httpServer.Any("/.well-known/acme-challenge/*", echo.WrapHandler(p.certManager.HTTPHandler(nil))) | |||
| 316 | + | ||||
| 317 | + | // Handle all other HTTP requests by redirecting to HTTPS | |||
| 318 | + | p.httpServer.Any("/*", func(c echo.Context) error { | |||
| 319 | + | // Skip handling acme challenges (already handled above) | |||
| 320 | + | if strings.HasPrefix(c.Request().URL.Path, "/.well-known/acme-challenge/") { | |||
| 321 | + | return nil | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | host := c.Request().Host | |||
| 325 | + | if strings.Contains(host, ":") { | |||
| 326 | + | host = strings.Split(host, ":")[0] | |||
| 327 | + | } | |||
| 328 | + | ||||
| 329 | + | // Redirect to HTTPS | |||
| 330 | + | return c.Redirect(http.StatusMovedPermanently, | |||
| 331 | + | fmt.Sprintf("https://%s%s", host, c.Request().RequestURI)) | |||
| 332 | + | }) | |||
| 333 | + | ||||
| 334 | + | // Start the HTTPS server | |||
| 335 | + | httpsServer := &http.Server{ | |||
| 336 | + | Addr: ":" + p.config.Port, | |||
| 337 | + | Handler: p.httpsServer, | |||
| 338 | + | TLSConfig: &tls.Config{ | |||
| 339 | + | GetCertificate: p.certManager.GetCertificate, | |||
| 340 | + | MinVersion: tls.VersionTLS12, | |||
| 341 | + | }, | |||
| 342 | + | ReadTimeout: 5 * time.Minute, | |||
| 343 | + | WriteTimeout: 5 * time.Minute, | |||
| 344 | + | IdleTimeout: 120 * time.Second, | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |||
| 348 | + | httpServer := &http.Server{ | |||
| 349 | + | Addr: ":" + p.config.HttpPort, | |||
| 350 | + | Handler: p.httpServer, | |||
| 351 | + | ReadTimeout: 5 * time.Minute, | |||
| 352 | + | WriteTimeout: 5 * time.Minute, | |||
| 353 | + | IdleTimeout: 120 * time.Second, | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | p.serverStarted = true | |||
| 357 | + | ||||
| 358 | + | // Start the HTTPS server | |||
| 359 | + | go func() { | |||
| 360 | + | log.Info("Starting HTTPS reverse proxy server", "port", p.config.Port) | |||
| 361 | + | if err := httpsServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed { | |||
| 362 | + | log.Error("Failed to start HTTPS reverse proxy server", "err", err) | |||
| 363 | + | } | |||
| 364 | + | }() | |||
| 365 | + | ||||
| 366 | + | // Start the HTTP server (for redirects and Let's Encrypt challenges) | |||
| 367 | + | go func() { | |||
| 368 | + | log.Info("Starting HTTP reverse proxy server (for redirects and Let's Encrypt challenges)", "port", p.config.HttpPort) | |||
| 369 | + | if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { | |||
| 370 | + | log.Error("Failed to start HTTP reverse proxy server", "err", err) | |||
| 371 | + | } | |||
| 372 | + | }() | |||
| 373 | + | ||||
| 374 | + | return nil | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | // testAdminConnection attempts to find a working connection to the Gordon admin | |||
| 378 | + | // by testing different hostnames and IPs | |||
| 379 | + | func (p *Proxy) testAdminConnection(defaultHost string, port string) string { | |||
| 380 | + | log.Debug("Testing connections to Gordon admin server") | |||
| 381 | + | ||||
| 382 | + | // Create a client with a short timeout to quickly test connections | |||
| 383 | + | client := &http.Client{ | |||
| 384 | + | Timeout: 1 * time.Second, | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // Define addresses to try (from most likely to least likely) | |||
| 388 | + | addresses := []string{ | |||
| 389 | + | defaultHost, // Try the provided default first | |||
| 390 | + | "localhost", // Local connections | |||
| 391 | + | "127.0.0.1", // Localhost IP | |||
| 392 | + | "host.docker.internal", // Docker host | |||
| 393 | + | "host.containers.internal", // Podman/Docker host | |||
| 394 | + | "172.17.0.1", // Common Docker gateway | |||
| 395 | + | os.Getenv("HOSTNAME"), // Container's own hostname | |||
| 396 | + | "gordon", // Service name | |||
| 397 | + | } | |||
| 398 | + | ||||
| 399 | + | // Remove duplicates and empty entries | |||
| 400 | + | uniqueAddresses := make([]string, 0, len(addresses)) | |||
| 401 | + | seen := make(map[string]bool) | |||
| 402 | + | ||||
| 403 | + | for _, addr := range addresses { | |||
| 404 | + | if addr == "" || seen[addr] { | |||
| 405 | + | continue | |||
| 406 | + | } | |||
| 407 | + | seen[addr] = true | |||
| 408 | + | uniqueAddresses = append(uniqueAddresses, addr) | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | // Test each address | |||
| 412 | + | for _, addr := range uniqueAddresses { | |||
| 413 | + | url := fmt.Sprintf("http://%s:%s/admin/ping", addr, port) | |||
| 414 | + | log.Debug("Testing connection to Gordon admin", "url", url) | |||
| 415 | + | ||||
| 416 | + | resp, err := client.Get(url) | |||
| 417 | + | if err == nil { | |||
| 418 | + | resp.Body.Close() | |||
| 419 | + | if resp.StatusCode < 500 { | |||
| 420 | + | log.Debug("Successfully connected to Gordon admin", | |||
| 421 | + | "host", addr, | |||
| 422 | + | "status", resp.StatusCode) | |||
| 423 | + | return addr | |||
| 424 | + | } | |||
| 425 | + | log.Debug("Received error status from Gordon admin", | |||
| 426 | + | "host", addr, | |||
| 427 | + | "status", resp.StatusCode) | |||
| 428 | + | } else { | |||
| 429 | + | log.Debug("Failed to connect to Gordon admin", | |||
| 430 | + | "host", addr, | |||
| 431 | + | "error", err.Error()) | |||
| 432 | + | } | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | // If none worked, return the default | |||
| 436 | + | return defaultHost | |||
| 437 | + | } | |||
| 438 | + | ||||
| 439 | + | // Stop stops the proxy server | |||
| 440 | + | func (p *Proxy) Stop() error { | |||
| 441 | + | if !p.serverStarted { | |||
| 442 | + | return nil | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | // Create a context with a timeout | |||
| 446 | + | ctx, cancel := context.WithTimeout( | |||
| 447 | + | context.Background(), | |||
| 448 | + | time.Duration(p.config.GracePeriod)*time.Second, | |||
| 449 | + | ) | |||
| 450 | + | defer cancel() | |||
| 451 | + | ||||
| 452 | + | // Shutdown the servers | |||
| 453 | + | log.Info("Stopping reverse proxy servers") | |||
| 454 | + | ||||
| 455 | + | // Stop HTTPS server | |||
| 456 | + | if err := p.httpsServer.Shutdown(ctx); err != nil { | |||
| 457 | + | log.Error("Error stopping HTTPS server", "err", err) | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | // Stop HTTP server | |||
| 461 | + | if err := p.httpServer.Shutdown(ctx); err != nil { | |||
| 462 | + | log.Error("Error stopping HTTP server", "err", err) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | return nil | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | // loadRoutes loads the routes from the database | |||
| 469 | + | func (p *Proxy) loadRoutes() error { | |||
| 470 | + | // Lock the routes map | |||
| 471 | + | p.mu.Lock() | |||
| 472 | + | defer p.mu.Unlock() | |||
| 473 | + | ||||
| 474 | + | log.Debug("Loading proxy routes from database") | |||
| 475 | + | ||||
| 476 | + | // Query the database for active proxy routes | |||
| 477 | + | rows, err := p.app.GetDB().Query(` | |||
| 478 | + | SELECT pr.id, d.name, pr.container_id, pr.container_ip, pr.container_port, pr.protocol, pr.path, pr.active | |||
| 479 | + | FROM proxy_route pr | |||
| 480 | + | JOIN domain d ON pr.domain_id = d.id | |||
| 481 | + | WHERE pr.active = 1 | |||
| 482 | + | `) | |||
| 483 | + | if err != nil { | |||
| 484 | + | if err == sql.ErrNoRows { | |||
| 485 | + | log.Info("No active proxy routes found") | |||
| 486 | + | return nil | |||
| 487 | + | } | |||
| 488 | + | return fmt.Errorf("failed to query database: %w", err) | |||
| 489 | + | } | |||
| 490 | + | defer rows.Close() | |||
| 491 | + | ||||
| 492 | + | // Clear the routes map | |||
| 493 | + | p.routes = make(map[string]*ProxyRouteInfo) | |||
| 494 | + | ||||
| 495 | + | // Populate the routes map | |||
| 496 | + | for rows.Next() { | |||
| 497 | + | var id, domain, containerID, containerIP, containerPort, protocol, path string | |||
| 498 | + | var active bool | |||
| 499 | + | if err := rows.Scan(&id, &domain, &containerID, &containerIP, &containerPort, &protocol, &path, &active); err != nil { | |||
| 500 | + | return fmt.Errorf("failed to scan row: %w", err) | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // Add the route to the map | |||
| 504 | + | p.routes[domain] = &ProxyRouteInfo{ | |||
| 505 | + | Domain: domain, | |||
| 506 | + | ContainerID: containerID, | |||
| 507 | + | ContainerIP: containerIP, | |||
| 508 | + | ContainerPort: containerPort, | |||
| 509 | + | Protocol: protocol, | |||
| 510 | + | Path: path, | |||
| 511 | + | Active: active, | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | log.Debug("Loaded proxy route", | |||
| 515 | + | "domain", domain, | |||
| 516 | + | "containerIP", containerIP, | |||
| 517 | + | "containerPort", containerPort, | |||
| 518 | + | ) | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | log.Info("Loaded proxy routes", "count", len(p.routes)) | |||
| 522 | + | return nil | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | // configureRoutes sets up the echo routes for proxying | |||
| 526 | + | func (p *Proxy) configureRoutes() { | |||
| 527 | + | // Lock the routes map for reading | |||
| 528 | + | p.mu.RLock() | |||
| 529 | + | defer p.mu.RUnlock() | |||
| 530 | + | ||||
| 531 | + | // Log all available routes for debugging | |||
| 532 | + | log.Debug("Configuring proxy routes") | |||
| 533 | + | for domain, route := range p.routes { | |||
| 534 | + | log.Debug("Configured route", | |||
| 535 | + | "domain", domain, | |||
| 536 | + | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |||
| 537 | + | "active", route.Active) | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | // Add a handler for all incoming requests (HTTPS) | |||
| 541 | + | p.httpsServer.Any("/*", func(c echo.Context) error { | |||
| 542 | + | host := c.Request().Host | |||
| 543 | + | ||||
| 544 | + | // Strip the port from the host | |||
| 545 | + | if strings.Contains(host, ":") { | |||
| 546 | + | host = strings.Split(host, ":")[0] | |||
| 547 | + | } | |||
| 548 | + | ||||
| 549 | + | log.Debug("Processing request", | |||
| 550 | + | "host", host, | |||
| 551 | + | "path", c.Request().URL.Path, | |||
| 552 | + | "method", c.Request().Method) | |||
| 553 | + | ||||
| 554 | + | // Find the route for this host | |||
| 555 | + | route, ok := p.routes[host] | |||
| 556 | + | if !ok { | |||
| 557 | + | // Create list of available domains for debugging | |||
| 558 | + | availableDomains := make([]string, 0, len(p.routes)) | |||
| 559 | + | for d := range p.routes { | |||
| 560 | + | availableDomains = append(availableDomains, d) | |||
| 561 | + | } | |||
| 562 | + | ||||
| 563 | + | log.Warn("Domain not found in routes", | |||
| 564 | + | "domain", host, | |||
| 565 | + | "available_domains", strings.Join(availableDomains, ", ")) | |||
| 566 | + | return c.String(http.StatusNotFound, "Domain not found") | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | // Check if the route is active | |||
| 570 | + | if !route.Active { | |||
| 571 | + | log.Warn("Route is not active", "domain", host) | |||
| 572 | + | return c.String(http.StatusServiceUnavailable, "Route is not active") | |||
| 573 | + | } | |||
| 574 | + | ||||
| 575 | + | // Special handling for the admin domain | |||
| 576 | + | adminDomain := p.app.GetConfig().Http.FullDomain() | |||
| 577 | + | if host == adminDomain { | |||
| 578 | + | log.Debug("Proxying request to admin domain", | |||
| 579 | + | "domain", host, | |||
| 580 | + | "target", fmt.Sprintf("%s://%s:%s", route.Protocol, route.ContainerIP, route.ContainerPort), | |||
| 581 | + | "path", c.Request().URL.Path) | |||
| 582 | + | } | |||
| 583 | + | ||||
| 584 | + | // Create the target URL | |||
| 585 | + | targetURL := &url.URL{ | |||
| 586 | + | Scheme: route.Protocol, | |||
| 587 | + | Host: fmt.Sprintf("%s:%s", route.ContainerIP, route.ContainerPort), | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | // Create a reverse proxy | |||
| 591 | + | proxy := httputil.NewSingleHostReverseProxy(targetURL) | |||
| 592 | + | ||||
| 593 | + | // Update headers to allow for SSL redirection | |||
| 594 | + | originalDirector := proxy.Director | |||
| 595 | + | proxy.Director = func(req *http.Request) { | |||
| 596 | + | originalDirector(req) | |||
| 597 | + | req.Header.Set("X-Forwarded-Proto", "https") | |||
| 598 | + | req.Header.Set("X-Forwarded-Host", host) | |||
| 599 | + | req.Header.Set("X-Forwarded-For", c.RealIP()) | |||
| 600 | + | req.Header.Set("X-Real-IP", c.RealIP()) | |||
| 601 | + | ||||
| 602 | + | // Debug information | |||
| 603 | + | log.Debug("Proxying request", | |||
| 604 | + | "host", host, | |||
| 605 | + | "target", targetURL.String(), | |||
| 606 | + | "path", req.URL.Path, | |||
| 607 | + | "clientIP", c.RealIP()) | |||
| 608 | + | } | |||
| 609 | + | ||||
| 610 | + | // Add error handling | |||
| 611 | + | proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { | |||
| 612 | + | log.Error("Proxy error", | |||
| 613 | + | "host", host, | |||
| 614 | + | "path", r.URL.Path, | |||
| 615 | + | "error", err) | |||
| 616 | + | c.String(http.StatusBadGateway, "Proxy Error: "+err.Error()) | |||
| 617 | + | } | |||
| 618 | + | ||||
| 619 | + | // Serve the request | |||
| 620 | + | proxy.ServeHTTP(c.Response(), c.Request()) | |||
| 621 | + | return nil | |||
| 622 | + | }) | |||
| 623 | + | } | |||
| 624 | + | ||||
| 625 | + | // Reload reloads the routes from the database and reconfigures the proxy | |||
| 626 | + | func (p *Proxy) Reload() error { | |||
| 627 | + | // Load routes from the database | |||
| 628 | + | if err := p.loadRoutes(); err != nil { | |||
| 629 | + | return fmt.Errorf("failed to load routes: %w", err) | |||
| 630 | + | } | |||
| 631 | + | ||||
| 632 | + | // Reconfigure the routes | |||
| 633 | + | p.configureRoutes() | |||
| 634 | + | ||||
| 635 | + | return nil | |||
| 636 | + | } | |||
| 637 | + | ||||
| 638 | + | // AddRoute adds a new route to the database and reloads the proxy | |||
| 639 | + | func (p *Proxy) AddRoute(domainName, containerID, containerIP, containerPort, protocol, path string) error { | |||
| 640 | + | // Begin a transaction | |||
| 641 | + | tx, err := p.app.GetDB().Begin() | |||
| 642 | + | if err != nil { | |||
| 643 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 644 | + | } | |||
| 645 | + | defer tx.Rollback() | |||
| 646 | + | ||||
| 647 | + | // Check if the domain exists | |||
| 648 | + | var domainID string | |||
| 649 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |||
| 650 | + | if err != nil { | |||
| 651 | + | if err == sql.ErrNoRows { | |||
| 652 | + | // Domain doesn't exist, create it | |||
| 653 | + | domainID = generateUUID() | |||
| 654 | + | now := time.Now().Format(time.RFC3339) | |||
| 655 | + | _, err = tx.Exec( | |||
| 656 | + | "INSERT INTO domain (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)", | |||
| 657 | + | domainID, domainName, now, now, | |||
| 658 | + | ) | |||
| 659 | + | if err != nil { | |||
| 660 | + | return fmt.Errorf("failed to insert domain: %w", err) | |||
| 661 | + | } | |||
| 662 | + | } else { | |||
| 663 | + | return fmt.Errorf("failed to query domain: %w", err) | |||
| 664 | + | } | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // Check if a route already exists for this domain | |||
| 668 | + | var existingRouteID string | |||
| 669 | + | err = tx.QueryRow("SELECT id FROM proxy_route WHERE domain_id = ?", domainID).Scan(&existingRouteID) | |||
| 670 | + | if err == nil { | |||
| 671 | + | // Route exists, update it | |||
| 672 | + | now := time.Now().Format(time.RFC3339) | |||
| 673 | + | _, err = tx.Exec( | |||
| 674 | + | `UPDATE proxy_route SET | |||
| 675 | + | container_id = ?, | |||
| 676 | + | container_ip = ?, | |||
| 677 | + | container_port = ?, | |||
| 678 | + | protocol = ?, | |||
| 679 | + | path = ?, | |||
| 680 | + | active = ?, | |||
| 681 | + | updated_at = ? | |||
| 682 | + | WHERE id = ?`, | |||
| 683 | + | containerID, containerIP, containerPort, protocol, path, true, now, existingRouteID, | |||
| 684 | + | ) | |||
| 685 | + | if err != nil { | |||
| 686 | + | return fmt.Errorf("failed to update route: %w", err) | |||
| 687 | + | } | |||
| 688 | + | } else if err == sql.ErrNoRows { | |||
| 689 | + | // Route doesn't exist, create it | |||
| 690 | + | routeID := generateUUID() | |||
| 691 | + | now := time.Now().Format(time.RFC3339) | |||
| 692 | + | _, err = tx.Exec( | |||
| 693 | + | `INSERT INTO proxy_route ( | |||
| 694 | + | id, domain_id, container_id, container_ip, container_port, | |||
| 695 | + | protocol, path, active, created_at, updated_at | |||
| 696 | + | ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, | |||
| 697 | + | routeID, domainID, containerID, containerIP, containerPort, | |||
| 698 | + | protocol, path, true, now, now, | |||
| 699 | + | ) | |||
| 700 | + | if err != nil { | |||
| 701 | + | return fmt.Errorf("failed to insert route: %w", err) | |||
| 702 | + | } | |||
| 703 | + | } else { | |||
| 704 | + | return fmt.Errorf("failed to query route: %w", err) | |||
| 705 | + | } | |||
| 706 | + | ||||
| 707 | + | // Commit the transaction | |||
| 708 | + | if err := tx.Commit(); err != nil { | |||
| 709 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 710 | + | } | |||
| 711 | + | ||||
| 712 | + | // Reload the routes | |||
| 713 | + | if err := p.Reload(); err != nil { | |||
| 714 | + | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 715 | + | } | |||
| 716 | + | ||||
| 717 | + | log.Info("Added proxy route", | |||
| 718 | + | "domain", domainName, | |||
| 719 | + | "containerIP", containerIP, | |||
| 720 | + | "containerPort", containerPort, | |||
| 721 | + | ) | |||
| 722 | + | return nil | |||
| 723 | + | } | |||
| 724 | + | ||||
| 725 | + | // RemoveRoute removes a route from the database and reloads the proxy | |||
| 726 | + | func (p *Proxy) RemoveRoute(domainName string) error { | |||
| 727 | + | // Begin a transaction | |||
| 728 | + | tx, err := p.app.GetDB().Begin() | |||
| 729 | + | if err != nil { | |||
| 730 | + | return fmt.Errorf("failed to begin transaction: %w", err) | |||
| 731 | + | } | |||
| 732 | + | defer tx.Rollback() | |||
| 733 | + | ||||
| 734 | + | // Get the domain ID | |||
| 735 | + | var domainID string | |||
| 736 | + | err = tx.QueryRow("SELECT id FROM domain WHERE name = ?", domainName).Scan(&domainID) | |||
| 737 | + | if err != nil { | |||
| 738 | + | if err == sql.ErrNoRows { | |||
| 739 | + | return fmt.Errorf("domain not found: %s", domainName) | |||
| 740 | + | } | |||
| 741 | + | return fmt.Errorf("failed to query domain: %w", err) | |||
| 742 | + | } | |||
| 743 | + | ||||
| 744 | + | // Delete the route | |||
| 745 | + | _, err = tx.Exec("DELETE FROM proxy_route WHERE domain_id = ?", domainID) | |||
| 746 | + | if err != nil { | |||
| 747 | + | return fmt.Errorf("failed to delete route: %w", err) | |||
| 748 | + | } | |||
| 749 | + | ||||
| 750 | + | // Delete the domain | |||
| 751 | + | _, err = tx.Exec("DELETE FROM domain WHERE id = ?", domainID) | |||
| 752 | + | if err != nil { | |||
| 753 | + | return fmt.Errorf("failed to delete domain: %w", err) | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // Commit the transaction | |||
| 757 | + | if err := tx.Commit(); err != nil { | |||
| 758 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 759 | + | } | |||
| 760 | + | ||||
| 761 | + | // Reload the routes | |||
| 762 | + | if err := p.Reload(); err != nil { | |||
| 763 | + | return fmt.Errorf("failed to reload routes: %w", err) | |||
| 764 | + | } | |||
| 765 | + | ||||
| 766 | + | log.Info("Removed proxy route", "domain", domainName) | |||
| 767 | + | return nil | |||
| 768 | + | } | |||
| 769 | + | ||||
| 770 | + | // generateUUID generates a UUID for use as a primary key | |||
| 771 | + | func generateUUID() string { | |||
| 772 | + | return fmt.Sprintf("%d", time.Now().UnixNano()) | |||
| 773 | + | } | |||
| 774 | + | ||||
| 775 | + | // joinPaths joins two paths ensuring there's only one slash between them | |||
| 776 | + | func joinPaths(a, b string) string { | |||
| 777 | + | if a == "" { | |||
| 778 | + | return b | |||
| 779 | + | } | |||
| 780 | + | if b == "" { | |||
| 781 | + | return a | |||
| 782 | + | } | |||
| 783 | + | if a[len(a)-1] == '/' { | |||
| 784 | + | a = a[:len(a)-1] | |||
| 785 | + | } | |||
| 786 | + | if b[0] == '/' { | |||
| 787 | + | b = b[1:] | |||
| 788 | + | } | |||
| 789 | + | return a + "/" + b | |||
| 790 | + | } | |||
| 791 | + | ||||
| 792 | + | // GetRoutes returns a copy of the routes map | |||
| 793 | + | func (p *Proxy) GetRoutes() map[string]*ProxyRouteInfo { | |||
| 794 | + | p.mu.RLock() | |||
| 795 | + | defer p.mu.RUnlock() | |||
| 796 | + | ||||
| 797 | + | // Create a copy of the routes map | |||
| 798 | + | routes := make(map[string]*ProxyRouteInfo, len(p.routes)) | |||
| 799 | + | for k, v := range p.routes { | |||
| 800 | + | routes[k] = v | |||
| 801 | + | } | |||
| 802 | + | ||||
| 803 | + | return routes | |||
| 804 | + | } | |||
M internal/server/app_server.go +18 -5
| @@ -26,11 +26,24 @@ type App struct { | |||
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | type DBTables struct { | |
| 29 | - | User db.User `sql:"user"` | |
| 30 | - | Account db.Account `sql:"account"` | |
| 31 | - | Sessions db.Sessions `sql:"sessions"` | |
| 32 | - | Provider db.Provider `sql:"provider"` | |
| 33 | - | Clients db.Clients `sql:"clients"` | |
| 29 | + | User db.User `sql:"user"` | |
| 30 | + | Account db.Account `sql:"account"` | |
| 31 | + | Sessions db.Sessions `sql:"sessions"` | |
| 32 | + | Provider db.Provider `sql:"provider"` | |
| 33 | + | Clients db.Clients `sql:"clients"` | |
| 34 | + | Domain db.Domain `sql:"domain"` | |
| 35 | + | Certificate db.Certificate `sql:"certificate"` | |
| 36 | + | ProxyRoute db.ProxyRoute `sql:"proxy_route"` | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // GetConfig returns the configuration | |
| 40 | + | func (a *App) GetConfig() *common.Config { | |
| 41 | + | return &a.Config | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // GetDB returns the database connection | |
| 45 | + | func (a *App) GetDB() *sql.DB { | |
| 46 | + | return a.DB | |
| 34 | 47 | } | |
| 35 | 48 | ||
| 36 | 49 | // GenerateOauthCallbackURL generates the OAuth callback URL | |
| @@ -26,11 +26,24 @@ type App struct { | |||||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | type DBTables struct { | 28 | type DBTables struct { | ||
| 29 | - | User db.User `sql:"user"` | 29 | + | User db.User `sql:"user"` |
| 30 | - | Account db.Account `sql:"account"` | 30 | + | Account db.Account `sql:"account"` |
| 31 | - | Sessions db.Sessions `sql:"sessions"` | 31 | + | Sessions db.Sessions `sql:"sessions"` |
| 32 | - | Provider db.Provider `sql:"provider"` | 32 | + | Provider db.Provider `sql:"provider"` |
| 33 | - | Clients db.Clients `sql:"clients"` | 33 | + | Clients db.Clients `sql:"clients"` |
| 34 | + | Domain db.Domain `sql:"domain"` | |||
| 35 | + | Certificate db.Certificate `sql:"certificate"` | |||
| 36 | + | ProxyRoute db.ProxyRoute `sql:"proxy_route"` | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | // GetConfig returns the configuration | |||
| 40 | + | func (a *App) GetConfig() *common.Config { | |||
| 41 | + | return &a.Config | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | // GetDB returns the database connection | |||
| 45 | + | func (a *App) GetDB() *sql.DB { | |||
| 46 | + | return a.DB | |||
| 34 | } | 47 | } | ||
| 35 | 48 | ||||
| 36 | // GenerateOauthCallbackURL generates the OAuth callback URL | 49 | // GenerateOauthCallbackURL generates the OAuth callback URL | ||
M internal/server/new_server.go +40 -0
| @@ -2,9 +2,12 @@ package server | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "io" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 5 | 7 | "time" | |
| 6 | 8 | ||
| 7 | 9 | "github.com/bnema/gordon/internal/common" | |
| 10 | + | "github.com/bnema/gordon/internal/proxy" | |
| 8 | 11 | "github.com/bnema/gordon/internal/templating" | |
| 9 | 12 | "github.com/bnema/gordon/internal/webui" | |
| 10 | 13 | "github.com/charmbracelet/log" | |
| @@ -23,6 +26,24 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) { | |||
| 23 | 26 | log.Fatalf("Failed to load configuration: %v", err) | |
| 24 | 27 | } | |
| 25 | 28 | ||
| 29 | + | // For development environment, use the project root directory | |
| 30 | + | if config.Build.RunEnv == "dev" { | |
| 31 | + | // Get the current working directory as the project root | |
| 32 | + | projectRoot, err := os.Getwd() | |
| 33 | + | if err != nil { | |
| 34 | + | log.Fatalf("Failed to get current working directory: %v", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | // Set storage directory to project root + /data instead of ~/.gordon | |
| 38 | + | config.General.StorageDir = filepath.Join(projectRoot, "data") | |
| 39 | + | log.Debug("Dev environment detected, using local storage path", "path", config.General.StorageDir) | |
| 40 | + | ||
| 41 | + | // Create the directory if it doesn't exist | |
| 42 | + | if err := os.MkdirAll(config.General.StorageDir, 0755); err != nil { | |
| 43 | + | log.Fatalf("Failed to create storage directory: %v", err) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 26 | 47 | // Open the strings.yml file containing the strings for the current language | |
| 27 | 48 | file, err := templating.TemplateFS.Open("txt/locstrings.yml") | |
| 28 | 49 | if err != nil { | |
| @@ -55,3 +76,22 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) { | |||
| 55 | 76 | ||
| 56 | 77 | return a, nil | |
| 57 | 78 | } | |
| 79 | + | ||
| 80 | + | // InitializeProxy initializes and starts the reverse proxy | |
| 81 | + | func (a *App) InitializeProxy() (*proxy.Proxy, error) { | |
| 82 | + | log.Info("Initializing reverse proxy") | |
| 83 | + | ||
| 84 | + | // Create a new proxy | |
| 85 | + | p, err := proxy.NewProxy(a) | |
| 86 | + | if err != nil { | |
| 87 | + | return nil, err | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | // Start the proxy | |
| 91 | + | if err := p.Start(); err != nil { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | log.Info("Reverse proxy initialized and started") | |
| 96 | + | return p, nil | |
| 97 | + | } | |
| @@ -2,9 +2,12 @@ package server | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "io" | 4 | "io" | ||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 5 | "time" | 7 | "time" | ||
| 6 | 8 | ||||
| 7 | "github.com/bnema/gordon/internal/common" | 9 | "github.com/bnema/gordon/internal/common" | ||
| 10 | + | "github.com/bnema/gordon/internal/proxy" | |||
| 8 | "github.com/bnema/gordon/internal/templating" | 11 | "github.com/bnema/gordon/internal/templating" | ||
| 9 | "github.com/bnema/gordon/internal/webui" | 12 | "github.com/bnema/gordon/internal/webui" | ||
| 10 | "github.com/charmbracelet/log" | 13 | "github.com/charmbracelet/log" | ||
| @@ -23,6 +26,24 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) { | |||||
| 23 | log.Fatalf("Failed to load configuration: %v", err) | 26 | log.Fatalf("Failed to load configuration: %v", err) | ||
| 24 | } | 27 | } | ||
| 25 | 28 | ||||
| 29 | + | // For development environment, use the project root directory | |||
| 30 | + | if config.Build.RunEnv == "dev" { | |||
| 31 | + | // Get the current working directory as the project root | |||
| 32 | + | projectRoot, err := os.Getwd() | |||
| 33 | + | if err != nil { | |||
| 34 | + | log.Fatalf("Failed to get current working directory: %v", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Set storage directory to project root + /data instead of ~/.gordon | |||
| 38 | + | config.General.StorageDir = filepath.Join(projectRoot, "data") | |||
| 39 | + | log.Debug("Dev environment detected, using local storage path", "path", config.General.StorageDir) | |||
| 40 | + | ||||
| 41 | + | // Create the directory if it doesn't exist | |||
| 42 | + | if err := os.MkdirAll(config.General.StorageDir, 0755); err != nil { | |||
| 43 | + | log.Fatalf("Failed to create storage directory: %v", err) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 26 | // Open the strings.yml file containing the strings for the current language | 47 | // Open the strings.yml file containing the strings for the current language | ||
| 27 | file, err := templating.TemplateFS.Open("txt/locstrings.yml") | 48 | file, err := templating.TemplateFS.Open("txt/locstrings.yml") | ||
| 28 | if err != nil { | 49 | if err != nil { | ||
| @@ -55,3 +76,22 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) { | |||||
| 55 | 76 | ||||
| 56 | return a, nil | 77 | return a, nil | ||
| 57 | } | 78 | } | ||
| 79 | + | ||||
| 80 | + | // InitializeProxy initializes and starts the reverse proxy | |||
| 81 | + | func (a *App) InitializeProxy() (*proxy.Proxy, error) { | |||
| 82 | + | log.Info("Initializing reverse proxy") | |||
| 83 | + | ||||
| 84 | + | // Create a new proxy | |||
| 85 | + | p, err := proxy.NewProxy(a) | |||
| 86 | + | if err != nil { | |||
| 87 | + | return nil, err | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | // Start the proxy | |||
| 91 | + | if err := p.Start(); err != nil { | |||
| 92 | + | return nil, err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | log.Info("Reverse proxy initialized and started") | |||
| 96 | + | return p, nil | |||
| 97 | + | } | |||
M internal/server/sqlite_init.go +193 -26
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "path/filepath" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/gordon/internal/db" | |
| 10 | - | pkgsqlite "github.com/bnema/gordon/pkg/sqlite" | |
| 10 | + | "github.com/charmbracelet/log" | |
| 11 | 11 | _ "modernc.org/sqlite" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| @@ -18,12 +18,14 @@ const ( | |||
| 18 | 18 | func (a *App) getDiskDBFilePath() string { | |
| 19 | 19 | DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename) | |
| 20 | 20 | // if a.DBDir is empty return log "Set a path using storageDir in the config file" | |
| 21 | + | log.Debug("DB file path", "path", DiskDBfilepath) | |
| 21 | 22 | return DiskDBfilepath | |
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // ensureDBDir ensures that the database directory exists. | |
| 25 | 26 | func (a *App) ensureDBDir() error { | |
| 26 | 27 | if _, err := os.Stat(a.DBDir); os.IsNotExist(err) { | |
| 28 | + | log.Debug("Creating database directory", "dir", a.DBDir) | |
| 27 | 29 | if err := os.MkdirAll(a.DBDir, 0755); err != nil { | |
| 28 | 30 | return err | |
| 29 | 31 | } | |
| @@ -34,20 +36,48 @@ func (a *App) ensureDBDir() error { | |||
| 34 | 36 | // checkDBFile checks if the database file exists. | |
| 35 | 37 | func (a *App) checkDBFile(dbPath string) (bool, error) { | |
| 36 | 38 | if _, err := os.Stat(dbPath); os.IsNotExist(err) { | |
| 39 | + | log.Debug("Database file does not exist", "path", dbPath) | |
| 37 | 40 | return false, nil | |
| 38 | 41 | } else if err != nil { | |
| 39 | 42 | return false, err | |
| 40 | 43 | } | |
| 44 | + | ||
| 45 | + | // File exists, but let's also check if the tables exist | |
| 46 | + | db, err := sql.Open("sqlite", dbPath) | |
| 47 | + | if err != nil { | |
| 48 | + | return false, fmt.Errorf("failed to open DB to check tables: %w", err) | |
| 49 | + | } | |
| 50 | + | defer db.Close() | |
| 51 | + | ||
| 52 | + | // Check if domain table exists | |
| 53 | + | var tableCount int | |
| 54 | + | err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='domain'").Scan(&tableCount) | |
| 55 | + | if err != nil { | |
| 56 | + | return false, fmt.Errorf("failed to check for domain table: %w", err) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | if tableCount == 0 { | |
| 60 | + | log.Debug("Database file exists but domain table is missing, will reinitialize") | |
| 61 | + | // The file exists but doesn't have the required tables, treat as non-existent | |
| 62 | + | if err := os.Remove(dbPath); err != nil { | |
| 63 | + | return false, fmt.Errorf("failed to remove corrupted DB file: %w", err) | |
| 64 | + | } | |
| 65 | + | return false, nil | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | log.Debug("Database file exists and contains required tables", "path", dbPath) | |
| 41 | 69 | return true, nil | |
| 42 | 70 | } | |
| 43 | 71 | ||
| 44 | 72 | // InitializeDB initializes the database | |
| 45 | 73 | func InitializeDB(a *App) (*sql.DB, error) { | |
| 74 | + | log.Debug("Initializing database") | |
| 46 | 75 | if err := a.ensureDBDir(); err != nil { | |
| 47 | 76 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | |
| 48 | 77 | } | |
| 49 | 78 | ||
| 50 | 79 | dbPath := a.getDiskDBFilePath() | |
| 80 | + | a.DBPath = dbPath | |
| 51 | 81 | ||
| 52 | 82 | dbExists, err := a.checkDBFile(dbPath) | |
| 53 | 83 | if err != nil { | |
| @@ -55,6 +85,7 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||
| 55 | 85 | } | |
| 56 | 86 | ||
| 57 | 87 | if !dbExists { | |
| 88 | + | log.Debug("Database needs bootstrapping", "path", dbPath) | |
| 58 | 89 | if err := bootstrapDB(dbPath, a); err != nil { | |
| 59 | 90 | return nil, fmt.Errorf("failed to bootstrap DB: %w", err) | |
| 60 | 91 | } | |
| @@ -65,42 +96,120 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||
| 65 | 96 | return nil, fmt.Errorf("failed to open disk DB: %w", err) | |
| 66 | 97 | } | |
| 67 | 98 | ||
| 99 | + | // Test that the database is working | |
| 100 | + | if err := db.Ping(); err != nil { | |
| 101 | + | return nil, fmt.Errorf("failed to ping database: %w", err) | |
| 102 | + | } | |
| 103 | + | ||
| 68 | 104 | a.DB = db | |
| 69 | 105 | ||
| 70 | 106 | if dbExists { | |
| 71 | 107 | // Populate the struct tables from the database | |
| 108 | + | log.Debug("Populating struct tables from existing database") | |
| 72 | 109 | if err := PopulateStructWithTables(a); err != nil { | |
| 73 | 110 | return nil, err | |
| 74 | 111 | } | |
| 112 | + | } else { | |
| 113 | + | log.Debug("Initialized new database") | |
| 75 | 114 | } | |
| 76 | 115 | ||
| 77 | 116 | return db, nil | |
| 78 | 117 | } | |
| 79 | 118 | ||
| 80 | 119 | func bootstrapDB(dbPath string, app *App) error { | |
| 120 | + | // Open the database file | |
| 81 | 121 | db, err := sql.Open("sqlite", dbPath) | |
| 82 | 122 | if err != nil { | |
| 83 | - | return err | |
| 123 | + | return fmt.Errorf("error opening database: %w", err) | |
| 84 | 124 | } | |
| 85 | 125 | defer db.Close() | |
| 86 | 126 | ||
| 87 | - | tables := app.DBTables | |
| 88 | - | ||
| 89 | - | if err := pkgsqlite.CreateTable(db, tables.User, "user"); err != nil { | |
| 90 | - | return err | |
| 91 | - | } | |
| 92 | - | if err := pkgsqlite.CreateTable(db, tables.Account, "account"); err != nil { | |
| 93 | - | return err | |
| 94 | - | } | |
| 95 | - | if err := pkgsqlite.CreateTable(db, tables.Sessions, "sessions"); err != nil { | |
| 96 | - | return err | |
| 127 | + | // Begin a transaction | |
| 128 | + | tx, err := db.Begin() | |
| 129 | + | if err != nil { | |
| 130 | + | return fmt.Errorf("error beginning transaction: %w", err) | |
| 97 | 131 | } | |
| 98 | - | if err := pkgsqlite.CreateTable(db, tables.Provider, "provider"); err != nil { | |
| 99 | - | return err | |
| 132 | + | ||
| 133 | + | // Create tables | |
| 134 | + | _, err = tx.Exec(` | |
| 135 | + | CREATE TABLE IF NOT EXISTS user ( | |
| 136 | + | id TEXT PRIMARY KEY, | |
| 137 | + | name TEXT, | |
| 138 | + | email TEXT | |
| 139 | + | ); | |
| 140 | + | CREATE TABLE IF NOT EXISTS account ( | |
| 141 | + | id TEXT PRIMARY KEY, | |
| 142 | + | user_id TEXT, | |
| 143 | + | FOREIGN KEY (user_id) REFERENCES user(id) | |
| 144 | + | ); | |
| 145 | + | CREATE TABLE IF NOT EXISTS sessions ( | |
| 146 | + | id TEXT PRIMARY KEY, | |
| 147 | + | account_id TEXT, | |
| 148 | + | browser_info TEXT, | |
| 149 | + | access_token TEXT, | |
| 150 | + | expires TEXT, | |
| 151 | + | is_online INTEGER, | |
| 152 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |
| 153 | + | ); | |
| 154 | + | CREATE TABLE IF NOT EXISTS provider ( | |
| 155 | + | id TEXT PRIMARY KEY, | |
| 156 | + | account_id TEXT, | |
| 157 | + | name TEXT, | |
| 158 | + | login TEXT, | |
| 159 | + | avatar_url TEXT, | |
| 160 | + | profile_url TEXT, | |
| 161 | + | email TEXT, | |
| 162 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |
| 163 | + | ); | |
| 164 | + | CREATE TABLE IF NOT EXISTS clients ( | |
| 165 | + | id TEXT PRIMARY KEY, | |
| 166 | + | account_id TEXT, | |
| 167 | + | os TEXT, | |
| 168 | + | ip TEXT, | |
| 169 | + | hostname TEXT, | |
| 170 | + | expires TEXT, | |
| 171 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |
| 172 | + | ); | |
| 173 | + | CREATE TABLE IF NOT EXISTS domain ( | |
| 174 | + | id TEXT PRIMARY KEY, | |
| 175 | + | name TEXT, | |
| 176 | + | account_id TEXT, | |
| 177 | + | created_at TEXT, | |
| 178 | + | updated_at TEXT, | |
| 179 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |
| 180 | + | ); | |
| 181 | + | CREATE TABLE IF NOT EXISTS certificate ( | |
| 182 | + | id TEXT PRIMARY KEY, | |
| 183 | + | domain_id TEXT, | |
| 184 | + | cert_file TEXT, | |
| 185 | + | key_file TEXT, | |
| 186 | + | issued_at TEXT, | |
| 187 | + | expires_at TEXT, | |
| 188 | + | issuer TEXT, | |
| 189 | + | status TEXT, | |
| 190 | + | FOREIGN KEY (domain_id) REFERENCES domain(id) | |
| 191 | + | ); | |
| 192 | + | CREATE TABLE IF NOT EXISTS proxy_route ( | |
| 193 | + | id TEXT PRIMARY KEY, | |
| 194 | + | domain_id TEXT, | |
| 195 | + | container_id TEXT, | |
| 196 | + | container_ip TEXT, | |
| 197 | + | container_port TEXT, | |
| 198 | + | protocol TEXT, | |
| 199 | + | path TEXT, | |
| 200 | + | active INTEGER, | |
| 201 | + | created_at TEXT, | |
| 202 | + | updated_at TEXT, | |
| 203 | + | FOREIGN KEY (domain_id) REFERENCES domain(id) | |
| 204 | + | ); | |
| 205 | + | `) | |
| 206 | + | if err != nil { | |
| 207 | + | return fmt.Errorf("failed to create tables: %w", err) | |
| 100 | 208 | } | |
| 101 | 209 | ||
| 102 | - | if err := pkgsqlite.CreateTable(db, tables.Clients, "clients"); err != nil { | |
| 103 | - | return err | |
| 210 | + | // Commit the transaction | |
| 211 | + | if err := tx.Commit(); err != nil { | |
| 212 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |
| 104 | 213 | } | |
| 105 | 214 | ||
| 106 | 215 | return nil | |
| @@ -130,6 +239,18 @@ type ClientsWrapper struct { | |||
| 130 | 239 | *db.Clients | |
| 131 | 240 | } | |
| 132 | 241 | ||
| 242 | + | type DomainWrapper struct { | |
| 243 | + | *db.Domain | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | type CertificateWrapper struct { | |
| 247 | + | *db.Certificate | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | type ProxyRouteWrapper struct { | |
| 251 | + | *db.ProxyRoute | |
| 252 | + | } | |
| 253 | + | ||
| 133 | 254 | func populateTableFromDB(a *App, query string, table DBTable) error { | |
| 134 | 255 | rows, err := a.DB.Query(query) | |
| 135 | 256 | if err != nil { | |
| @@ -166,20 +287,66 @@ func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error { | |||
| 166 | 287 | return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires) | |
| 167 | 288 | } | |
| 168 | 289 | ||
| 290 | + | func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 291 | + | return rows.Scan(&d.ID, &d.Name, &d.AccountID, &d.CreatedAt, &d.UpdatedAt) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 295 | + | return rows.Scan(&c.ID, &c.DomainID, &c.CertFile, &c.KeyFile, &c.IssuedAt, &c.ExpiresAt, &c.Issuer, &c.Status) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error { | |
| 299 | + | return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active, &p.CreatedAt, &p.UpdatedAt) | |
| 300 | + | } | |
| 301 | + | ||
| 169 | 302 | // PopulateStructWithTables updates db struct with tables | |
| 170 | 303 | func PopulateStructWithTables(a *App) error { | |
| 171 | - | tables := map[string]DBTable{ | |
| 172 | - | "SELECT id, name, email FROM user": &UserWrapper{&a.DBTables.User}, | |
| 173 | - | "SELECT id, user_id FROM account": &AccountWrapper{&a.DBTables.Account}, | |
| 174 | - | "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider": &ProviderWrapper{&a.DBTables.Provider}, | |
| 175 | - | "SELECT id, account_id, access_token, browser_info, expires, is_online FROM sessions": &SessionsWrapper{&a.DBTables.Sessions}, | |
| 176 | - | "SELECT id, account_id, os, ip, hostname, expires FROM clients": &ClientsWrapper{&a.DBTables.Clients}, | |
| 304 | + | // Populate the User table | |
| 305 | + | userWrapper := &UserWrapper{User: &a.DBTables.User} | |
| 306 | + | if err := populateTableFromDB(a, "SELECT id, name, email FROM user", userWrapper); err != nil { | |
| 307 | + | return err | |
| 177 | 308 | } | |
| 178 | 309 | ||
| 179 | - | for query, table := range tables { | |
| 180 | - | if err := populateTableFromDB(a, query, table); err != nil { | |
| 181 | - | return err | |
| 182 | - | } | |
| 310 | + | // Populate the Account table | |
| 311 | + | accountWrapper := &AccountWrapper{Account: &a.DBTables.Account} | |
| 312 | + | if err := populateTableFromDB(a, "SELECT id, user_id FROM account", accountWrapper); err != nil { | |
| 313 | + | return err | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | // Populate the Provider table | |
| 317 | + | providerWrapper := &ProviderWrapper{Provider: &a.DBTables.Provider} | |
| 318 | + | if err := populateTableFromDB(a, "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider", providerWrapper); err != nil { | |
| 319 | + | return err | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // Populate the Sessions table | |
| 323 | + | sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions} | |
| 324 | + | if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_online FROM sessions", sessionsWrapper); err != nil { | |
| 325 | + | return err | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | // Populate the Clients table | |
| 329 | + | clientsWrapper := &ClientsWrapper{Clients: &a.DBTables.Clients} | |
| 330 | + | if err := populateTableFromDB(a, "SELECT id, account_id, os, ip, hostname, expires FROM clients", clientsWrapper); err != nil { | |
| 331 | + | return err | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | // Populate the Domain table | |
| 335 | + | domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain} | |
| 336 | + | if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at FROM domain", domainWrapper); err != nil { | |
| 337 | + | return err | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | // Populate the Certificate table | |
| 341 | + | certificateWrapper := &CertificateWrapper{Certificate: &a.DBTables.Certificate} | |
| 342 | + | if err := populateTableFromDB(a, "SELECT id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status FROM certificate", certificateWrapper); err != nil { | |
| 343 | + | return err | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | // Populate the ProxyRoute table | |
| 347 | + | proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute} | |
| 348 | + | if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active, created_at, updated_at FROM proxy_route", proxyRouteWrapper); err != nil { | |
| 349 | + | return err | |
| 183 | 350 | } | |
| 184 | 351 | ||
| 185 | 352 | return nil | |
| @@ -7,7 +7,7 @@ import ( | |||||
| 7 | "path/filepath" | 7 | "path/filepath" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/gordon/internal/db" | 9 | "github.com/bnema/gordon/internal/db" | ||
| 10 | - | pkgsqlite "github.com/bnema/gordon/pkg/sqlite" | 10 | + | "github.com/charmbracelet/log" |
| 11 | _ "modernc.org/sqlite" | 11 | _ "modernc.org/sqlite" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| @@ -18,12 +18,14 @@ const ( | |||||
| 18 | func (a *App) getDiskDBFilePath() string { | 18 | func (a *App) getDiskDBFilePath() string { | ||
| 19 | DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename) | 19 | DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename) | ||
| 20 | // if a.DBDir is empty return log "Set a path using storageDir in the config file" | 20 | // if a.DBDir is empty return log "Set a path using storageDir in the config file" | ||
| 21 | + | log.Debug("DB file path", "path", DiskDBfilepath) | |||
| 21 | return DiskDBfilepath | 22 | return DiskDBfilepath | ||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // ensureDBDir ensures that the database directory exists. | 25 | // ensureDBDir ensures that the database directory exists. | ||
| 25 | func (a *App) ensureDBDir() error { | 26 | func (a *App) ensureDBDir() error { | ||
| 26 | if _, err := os.Stat(a.DBDir); os.IsNotExist(err) { | 27 | if _, err := os.Stat(a.DBDir); os.IsNotExist(err) { | ||
| 28 | + | log.Debug("Creating database directory", "dir", a.DBDir) | |||
| 27 | if err := os.MkdirAll(a.DBDir, 0755); err != nil { | 29 | if err := os.MkdirAll(a.DBDir, 0755); err != nil { | ||
| 28 | return err | 30 | return err | ||
| 29 | } | 31 | } | ||
| @@ -34,20 +36,48 @@ func (a *App) ensureDBDir() error { | |||||
| 34 | // checkDBFile checks if the database file exists. | 36 | // checkDBFile checks if the database file exists. | ||
| 35 | func (a *App) checkDBFile(dbPath string) (bool, error) { | 37 | func (a *App) checkDBFile(dbPath string) (bool, error) { | ||
| 36 | if _, err := os.Stat(dbPath); os.IsNotExist(err) { | 38 | if _, err := os.Stat(dbPath); os.IsNotExist(err) { | ||
| 39 | + | log.Debug("Database file does not exist", "path", dbPath) | |||
| 37 | return false, nil | 40 | return false, nil | ||
| 38 | } else if err != nil { | 41 | } else if err != nil { | ||
| 39 | return false, err | 42 | return false, err | ||
| 40 | } | 43 | } | ||
| 44 | + | ||||
| 45 | + | // File exists, but let's also check if the tables exist | |||
| 46 | + | db, err := sql.Open("sqlite", dbPath) | |||
| 47 | + | if err != nil { | |||
| 48 | + | return false, fmt.Errorf("failed to open DB to check tables: %w", err) | |||
| 49 | + | } | |||
| 50 | + | defer db.Close() | |||
| 51 | + | ||||
| 52 | + | // Check if domain table exists | |||
| 53 | + | var tableCount int | |||
| 54 | + | err = db.QueryRow("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='domain'").Scan(&tableCount) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return false, fmt.Errorf("failed to check for domain table: %w", err) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | if tableCount == 0 { | |||
| 60 | + | log.Debug("Database file exists but domain table is missing, will reinitialize") | |||
| 61 | + | // The file exists but doesn't have the required tables, treat as non-existent | |||
| 62 | + | if err := os.Remove(dbPath); err != nil { | |||
| 63 | + | return false, fmt.Errorf("failed to remove corrupted DB file: %w", err) | |||
| 64 | + | } | |||
| 65 | + | return false, nil | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | log.Debug("Database file exists and contains required tables", "path", dbPath) | |||
| 41 | return true, nil | 69 | return true, nil | ||
| 42 | } | 70 | } | ||
| 43 | 71 | ||||
| 44 | // InitializeDB initializes the database | 72 | // InitializeDB initializes the database | ||
| 45 | func InitializeDB(a *App) (*sql.DB, error) { | 73 | func InitializeDB(a *App) (*sql.DB, error) { | ||
| 74 | + | log.Debug("Initializing database") | |||
| 46 | if err := a.ensureDBDir(); err != nil { | 75 | if err := a.ensureDBDir(); err != nil { | ||
| 47 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | 76 | return nil, fmt.Errorf("failed to ensure DB directory: %w", err) | ||
| 48 | } | 77 | } | ||
| 49 | 78 | ||||
| 50 | dbPath := a.getDiskDBFilePath() | 79 | dbPath := a.getDiskDBFilePath() | ||
| 80 | + | a.DBPath = dbPath | |||
| 51 | 81 | ||||
| 52 | dbExists, err := a.checkDBFile(dbPath) | 82 | dbExists, err := a.checkDBFile(dbPath) | ||
| 53 | if err != nil { | 83 | if err != nil { | ||
| @@ -55,6 +85,7 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||||
| 55 | } | 85 | } | ||
| 56 | 86 | ||||
| 57 | if !dbExists { | 87 | if !dbExists { | ||
| 88 | + | log.Debug("Database needs bootstrapping", "path", dbPath) | |||
| 58 | if err := bootstrapDB(dbPath, a); err != nil { | 89 | if err := bootstrapDB(dbPath, a); err != nil { | ||
| 59 | return nil, fmt.Errorf("failed to bootstrap DB: %w", err) | 90 | return nil, fmt.Errorf("failed to bootstrap DB: %w", err) | ||
| 60 | } | 91 | } | ||
| @@ -65,42 +96,120 @@ func InitializeDB(a *App) (*sql.DB, error) { | |||||
| 65 | return nil, fmt.Errorf("failed to open disk DB: %w", err) | 96 | return nil, fmt.Errorf("failed to open disk DB: %w", err) | ||
| 66 | } | 97 | } | ||
| 67 | 98 | ||||
| 99 | + | // Test that the database is working | |||
| 100 | + | if err := db.Ping(); err != nil { | |||
| 101 | + | return nil, fmt.Errorf("failed to ping database: %w", err) | |||
| 102 | + | } | |||
| 103 | + | ||||
| 68 | a.DB = db | 104 | a.DB = db | ||
| 69 | 105 | ||||
| 70 | if dbExists { | 106 | if dbExists { | ||
| 71 | // Populate the struct tables from the database | 107 | // Populate the struct tables from the database | ||
| 108 | + | log.Debug("Populating struct tables from existing database") | |||
| 72 | if err := PopulateStructWithTables(a); err != nil { | 109 | if err := PopulateStructWithTables(a); err != nil { | ||
| 73 | return nil, err | 110 | return nil, err | ||
| 74 | } | 111 | } | ||
| 112 | + | } else { | |||
| 113 | + | log.Debug("Initialized new database") | |||
| 75 | } | 114 | } | ||
| 76 | 115 | ||||
| 77 | return db, nil | 116 | return db, nil | ||
| 78 | } | 117 | } | ||
| 79 | 118 | ||||
| 80 | func bootstrapDB(dbPath string, app *App) error { | 119 | func bootstrapDB(dbPath string, app *App) error { | ||
| 120 | + | // Open the database file | |||
| 81 | db, err := sql.Open("sqlite", dbPath) | 121 | db, err := sql.Open("sqlite", dbPath) | ||
| 82 | if err != nil { | 122 | if err != nil { | ||
| 83 | - | return err | 123 | + | return fmt.Errorf("error opening database: %w", err) |
| 84 | } | 124 | } | ||
| 85 | defer db.Close() | 125 | defer db.Close() | ||
| 86 | 126 | ||||
| 87 | - | tables := app.DBTables | 127 | + | // Begin a transaction |
| 88 | - | 128 | + | tx, err := db.Begin() | |
| 89 | - | if err := pkgsqlite.CreateTable(db, tables.User, "user"); err != nil { | 129 | + | if err != nil { |
| 90 | - | return err | 130 | + | return fmt.Errorf("error beginning transaction: %w", err) |
| 91 | - | } | |||
| 92 | - | if err := pkgsqlite.CreateTable(db, tables.Account, "account"); err != nil { | |||
| 93 | - | return err | |||
| 94 | - | } | |||
| 95 | - | if err := pkgsqlite.CreateTable(db, tables.Sessions, "sessions"); err != nil { | |||
| 96 | - | return err | |||
| 97 | } | 131 | } | ||
| 98 | - | if err := pkgsqlite.CreateTable(db, tables.Provider, "provider"); err != nil { | 132 | + | |
| 99 | - | return err | 133 | + | // Create tables |
| 134 | + | _, err = tx.Exec(` | |||
| 135 | + | CREATE TABLE IF NOT EXISTS user ( | |||
| 136 | + | id TEXT PRIMARY KEY, | |||
| 137 | + | name TEXT, | |||
| 138 | + | email TEXT | |||
| 139 | + | ); | |||
| 140 | + | CREATE TABLE IF NOT EXISTS account ( | |||
| 141 | + | id TEXT PRIMARY KEY, | |||
| 142 | + | user_id TEXT, | |||
| 143 | + | FOREIGN KEY (user_id) REFERENCES user(id) | |||
| 144 | + | ); | |||
| 145 | + | CREATE TABLE IF NOT EXISTS sessions ( | |||
| 146 | + | id TEXT PRIMARY KEY, | |||
| 147 | + | account_id TEXT, | |||
| 148 | + | browser_info TEXT, | |||
| 149 | + | access_token TEXT, | |||
| 150 | + | expires TEXT, | |||
| 151 | + | is_online INTEGER, | |||
| 152 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |||
| 153 | + | ); | |||
| 154 | + | CREATE TABLE IF NOT EXISTS provider ( | |||
| 155 | + | id TEXT PRIMARY KEY, | |||
| 156 | + | account_id TEXT, | |||
| 157 | + | name TEXT, | |||
| 158 | + | login TEXT, | |||
| 159 | + | avatar_url TEXT, | |||
| 160 | + | profile_url TEXT, | |||
| 161 | + | email TEXT, | |||
| 162 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |||
| 163 | + | ); | |||
| 164 | + | CREATE TABLE IF NOT EXISTS clients ( | |||
| 165 | + | id TEXT PRIMARY KEY, | |||
| 166 | + | account_id TEXT, | |||
| 167 | + | os TEXT, | |||
| 168 | + | ip TEXT, | |||
| 169 | + | hostname TEXT, | |||
| 170 | + | expires TEXT, | |||
| 171 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |||
| 172 | + | ); | |||
| 173 | + | CREATE TABLE IF NOT EXISTS domain ( | |||
| 174 | + | id TEXT PRIMARY KEY, | |||
| 175 | + | name TEXT, | |||
| 176 | + | account_id TEXT, | |||
| 177 | + | created_at TEXT, | |||
| 178 | + | updated_at TEXT, | |||
| 179 | + | FOREIGN KEY (account_id) REFERENCES account(id) | |||
| 180 | + | ); | |||
| 181 | + | CREATE TABLE IF NOT EXISTS certificate ( | |||
| 182 | + | id TEXT PRIMARY KEY, | |||
| 183 | + | domain_id TEXT, | |||
| 184 | + | cert_file TEXT, | |||
| 185 | + | key_file TEXT, | |||
| 186 | + | issued_at TEXT, | |||
| 187 | + | expires_at TEXT, | |||
| 188 | + | issuer TEXT, | |||
| 189 | + | status TEXT, | |||
| 190 | + | FOREIGN KEY (domain_id) REFERENCES domain(id) | |||
| 191 | + | ); | |||
| 192 | + | CREATE TABLE IF NOT EXISTS proxy_route ( | |||
| 193 | + | id TEXT PRIMARY KEY, | |||
| 194 | + | domain_id TEXT, | |||
| 195 | + | container_id TEXT, | |||
| 196 | + | container_ip TEXT, | |||
| 197 | + | container_port TEXT, | |||
| 198 | + | protocol TEXT, | |||
| 199 | + | path TEXT, | |||
| 200 | + | active INTEGER, | |||
| 201 | + | created_at TEXT, | |||
| 202 | + | updated_at TEXT, | |||
| 203 | + | FOREIGN KEY (domain_id) REFERENCES domain(id) | |||
| 204 | + | ); | |||
| 205 | + | `) | |||
| 206 | + | if err != nil { | |||
| 207 | + | return fmt.Errorf("failed to create tables: %w", err) | |||
| 100 | } | 208 | } | ||
| 101 | 209 | ||||
| 102 | - | if err := pkgsqlite.CreateTable(db, tables.Clients, "clients"); err != nil { | 210 | + | // Commit the transaction |
| 103 | - | return err | 211 | + | if err := tx.Commit(); err != nil { |
| 212 | + | return fmt.Errorf("failed to commit transaction: %w", err) | |||
| 104 | } | 213 | } | ||
| 105 | 214 | ||||
| 106 | return nil | 215 | return nil | ||
| @@ -130,6 +239,18 @@ type ClientsWrapper struct { | |||||
| 130 | *db.Clients | 239 | *db.Clients | ||
| 131 | } | 240 | } | ||
| 132 | 241 | ||||
| 242 | + | type DomainWrapper struct { | |||
| 243 | + | *db.Domain | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | type CertificateWrapper struct { | |||
| 247 | + | *db.Certificate | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | type ProxyRouteWrapper struct { | |||
| 251 | + | *db.ProxyRoute | |||
| 252 | + | } | |||
| 253 | + | ||||
| 133 | func populateTableFromDB(a *App, query string, table DBTable) error { | 254 | func populateTableFromDB(a *App, query string, table DBTable) error { | ||
| 134 | rows, err := a.DB.Query(query) | 255 | rows, err := a.DB.Query(query) | ||
| 135 | if err != nil { | 256 | if err != nil { | ||
| @@ -166,20 +287,66 @@ func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error { | |||||
| 166 | return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires) | 287 | return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires) | ||
| 167 | } | 288 | } | ||
| 168 | 289 | ||||
| 290 | + | func (d *DomainWrapper) ScanFromRows(rows *sql.Rows) error { | |||
| 291 | + | return rows.Scan(&d.ID, &d.Name, &d.AccountID, &d.CreatedAt, &d.UpdatedAt) | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func (c *CertificateWrapper) ScanFromRows(rows *sql.Rows) error { | |||
| 295 | + | return rows.Scan(&c.ID, &c.DomainID, &c.CertFile, &c.KeyFile, &c.IssuedAt, &c.ExpiresAt, &c.Issuer, &c.Status) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func (p *ProxyRouteWrapper) ScanFromRows(rows *sql.Rows) error { | |||
| 299 | + | return rows.Scan(&p.ID, &p.DomainID, &p.ContainerID, &p.ContainerIP, &p.ContainerPort, &p.Protocol, &p.Path, &p.Active, &p.CreatedAt, &p.UpdatedAt) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 169 | // PopulateStructWithTables updates db struct with tables | 302 | // PopulateStructWithTables updates db struct with tables | ||
| 170 | func PopulateStructWithTables(a *App) error { | 303 | func PopulateStructWithTables(a *App) error { | ||
| 171 | - | tables := map[string]DBTable{ | 304 | + | // Populate the User table |
| 172 | - | "SELECT id, name, email FROM user": &UserWrapper{&a.DBTables.User}, | 305 | + | userWrapper := &UserWrapper{User: &a.DBTables.User} |
| 173 | - | "SELECT id, user_id FROM account": &AccountWrapper{&a.DBTables.Account}, | 306 | + | if err := populateTableFromDB(a, "SELECT id, name, email FROM user", userWrapper); err != nil { |
| 174 | - | "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider": &ProviderWrapper{&a.DBTables.Provider}, | 307 | + | return err |
| 175 | - | "SELECT id, account_id, access_token, browser_info, expires, is_online FROM sessions": &SessionsWrapper{&a.DBTables.Sessions}, | |||
| 176 | - | "SELECT id, account_id, os, ip, hostname, expires FROM clients": &ClientsWrapper{&a.DBTables.Clients}, | |||
| 177 | } | 308 | } | ||
| 178 | 309 | ||||
| 179 | - | for query, table := range tables { | 310 | + | // Populate the Account table |
| 180 | - | if err := populateTableFromDB(a, query, table); err != nil { | 311 | + | accountWrapper := &AccountWrapper{Account: &a.DBTables.Account} |
| 181 | - | return err | 312 | + | if err := populateTableFromDB(a, "SELECT id, user_id FROM account", accountWrapper); err != nil { |
| 182 | - | } | 313 | + | return err |
| 314 | + | } | |||
| 315 | + | ||||
| 316 | + | // Populate the Provider table | |||
| 317 | + | providerWrapper := &ProviderWrapper{Provider: &a.DBTables.Provider} | |||
| 318 | + | if err := populateTableFromDB(a, "SELECT id, account_id, name, login, avatar_url, profile_url, email FROM provider", providerWrapper); err != nil { | |||
| 319 | + | return err | |||
| 320 | + | } | |||
| 321 | + | ||||
| 322 | + | // Populate the Sessions table | |||
| 323 | + | sessionsWrapper := &SessionsWrapper{Sessions: &a.DBTables.Sessions} | |||
| 324 | + | if err := populateTableFromDB(a, "SELECT id, account_id, browser_info, access_token, expires, is_online FROM sessions", sessionsWrapper); err != nil { | |||
| 325 | + | return err | |||
| 326 | + | } | |||
| 327 | + | ||||
| 328 | + | // Populate the Clients table | |||
| 329 | + | clientsWrapper := &ClientsWrapper{Clients: &a.DBTables.Clients} | |||
| 330 | + | if err := populateTableFromDB(a, "SELECT id, account_id, os, ip, hostname, expires FROM clients", clientsWrapper); err != nil { | |||
| 331 | + | return err | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // Populate the Domain table | |||
| 335 | + | domainWrapper := &DomainWrapper{Domain: &a.DBTables.Domain} | |||
| 336 | + | if err := populateTableFromDB(a, "SELECT id, name, account_id, created_at, updated_at FROM domain", domainWrapper); err != nil { | |||
| 337 | + | return err | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | // Populate the Certificate table | |||
| 341 | + | certificateWrapper := &CertificateWrapper{Certificate: &a.DBTables.Certificate} | |||
| 342 | + | if err := populateTableFromDB(a, "SELECT id, domain_id, cert_file, key_file, issued_at, expires_at, issuer, status FROM certificate", certificateWrapper); err != nil { | |||
| 343 | + | return err | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | // Populate the ProxyRoute table | |||
| 347 | + | proxyRouteWrapper := &ProxyRouteWrapper{ProxyRoute: &a.DBTables.ProxyRoute} | |||
| 348 | + | if err := populateTableFromDB(a, "SELECT id, domain_id, container_id, container_ip, container_port, protocol, path, active, created_at, updated_at FROM proxy_route", proxyRouteWrapper); err != nil { | |||
| 349 | + | return err | |||
| 183 | } | 350 | } | ||
| 184 | 351 | ||||
| 185 | return nil | 352 | return nil | ||
M internal/templating/cmdparams/from_cli.go +13 -13
| @@ -37,22 +37,22 @@ func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imag | |||
| 37 | 37 | containerDomain := strings.Join(hostParts[1:], ".") | |
| 38 | 38 | ||
| 39 | 39 | params := docker.ContainerCommandParams{ | |
| 40 | - | IsSSL: protocol == "https", | |
| 41 | - | ContainerName: containerSubdomain, | |
| 42 | - | ServiceName: containerSubdomain, | |
| 43 | - | Domain: containerDomain, | |
| 44 | - | ImageName: ppl.ImageName, | |
| 45 | - | ImageID: imageID, | |
| 46 | - | Restart: "always", | |
| 47 | - | Volumes: volumeSlice, | |
| 48 | - | Environment: environmentSlice, | |
| 49 | - | Network: a.Config.ContainerEngine.Network, | |
| 50 | - | TraefikEntryPoint: ppl.Port, | |
| 40 | + | IsSSL: protocol == "https", | |
| 41 | + | ContainerName: containerSubdomain, | |
| 42 | + | ServiceName: containerSubdomain, | |
| 43 | + | Domain: containerDomain, | |
| 44 | + | ImageName: ppl.ImageName, | |
| 45 | + | ImageID: imageID, | |
| 46 | + | Restart: "always", | |
| 47 | + | Volumes: volumeSlice, | |
| 48 | + | Environment: environmentSlice, | |
| 49 | + | Network: a.Config.ContainerEngine.Network, | |
| 50 | + | ProxyPort: ppl.Port, | |
| 51 | 51 | } | |
| 52 | 52 | ||
| 53 | - | err = CreateTraefikLabels(¶ms, params.TraefikEntryPoint, a) | |
| 53 | + | err = SetupProxyRoute(¶ms, params.ProxyPort, a) | |
| 54 | 54 | if err != nil { | |
| 55 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error creating Traefik labels: %w", err) | |
| 55 | + | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | return params, nil | |
| @@ -37,22 +37,22 @@ func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imag | |||||
| 37 | containerDomain := strings.Join(hostParts[1:], ".") | 37 | containerDomain := strings.Join(hostParts[1:], ".") | ||
| 38 | 38 | ||||
| 39 | params := docker.ContainerCommandParams{ | 39 | params := docker.ContainerCommandParams{ | ||
| 40 | - | IsSSL: protocol == "https", | 40 | + | IsSSL: protocol == "https", |
| 41 | - | ContainerName: containerSubdomain, | 41 | + | ContainerName: containerSubdomain, |
| 42 | - | ServiceName: containerSubdomain, | 42 | + | ServiceName: containerSubdomain, |
| 43 | - | Domain: containerDomain, | 43 | + | Domain: containerDomain, |
| 44 | - | ImageName: ppl.ImageName, | 44 | + | ImageName: ppl.ImageName, |
| 45 | - | ImageID: imageID, | 45 | + | ImageID: imageID, |
| 46 | - | Restart: "always", | 46 | + | Restart: "always", |
| 47 | - | Volumes: volumeSlice, | 47 | + | Volumes: volumeSlice, |
| 48 | - | Environment: environmentSlice, | 48 | + | Environment: environmentSlice, |
| 49 | - | Network: a.Config.ContainerEngine.Network, | 49 | + | Network: a.Config.ContainerEngine.Network, |
| 50 | - | TraefikEntryPoint: ppl.Port, | 50 | + | ProxyPort: ppl.Port, |
| 51 | } | 51 | } | ||
| 52 | 52 | ||||
| 53 | - | err = CreateTraefikLabels(¶ms, params.TraefikEntryPoint, a) | 53 | + | err = SetupProxyRoute(¶ms, params.ProxyPort, a) |
| 54 | if err != nil { | 54 | if err != nil { | ||
| 55 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error creating Traefik labels: %w", err) | 55 | + | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) |
| 56 | } | 56 | } | ||
| 57 | 57 | ||||
| 58 | return params, nil | 58 | return params, nil | ||
M internal/templating/cmdparams/from_inputs.go +14 -14
| @@ -24,23 +24,23 @@ func FromInputsToCmdParams(inputs map[string]string, a *server.App) (docker.Cont | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | params := docker.ContainerCommandParams{ | |
| 27 | - | IsSSL: inputs["container_protocol"] == "https", | |
| 28 | - | ContainerName: inputs["container_name"], | |
| 29 | - | ServiceName: inputs["container_subdomain"], | |
| 30 | - | Domain: inputs["container_domain"], | |
| 31 | - | ImageName: inputs["image_name"], | |
| 32 | - | ImageID: inputs["image_id"], | |
| 33 | - | Restart: inputs["restart"], | |
| 34 | - | TraefikEntryPoint: inputs["traefik_entry_point"], | |
| 35 | - | Volumes: volumeSlice, | |
| 36 | - | Environment: environmentSlice, | |
| 37 | - | Network: a.Config.ContainerEngine.Network, | |
| 38 | - | PortMappings: portMappings, | |
| 27 | + | IsSSL: inputs["container_protocol"] == "https", | |
| 28 | + | ContainerName: inputs["container_name"], | |
| 29 | + | ServiceName: inputs["container_subdomain"], | |
| 30 | + | Domain: inputs["container_domain"], | |
| 31 | + | ImageName: inputs["image_name"], | |
| 32 | + | ImageID: inputs["image_id"], | |
| 33 | + | Restart: inputs["restart"], | |
| 34 | + | ProxyPort: inputs["proxy_port"], | |
| 35 | + | Volumes: volumeSlice, | |
| 36 | + | Environment: environmentSlice, | |
| 37 | + | Network: a.Config.ContainerEngine.Network, | |
| 38 | + | PortMappings: portMappings, | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | - | err = CreateTraefikLabels(¶ms, params.TraefikEntryPoint, a) | |
| 41 | + | err = SetupProxyRoute(¶ms, params.ProxyPort, a) | |
| 42 | 42 | if err != nil { | |
| 43 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error creating Traefik labels: %w", err) | |
| 43 | + | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) | |
| 44 | 44 | } | |
| 45 | 45 | ||
| 46 | 46 | return params, nil | |
| @@ -24,23 +24,23 @@ func FromInputsToCmdParams(inputs map[string]string, a *server.App) (docker.Cont | |||||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | params := docker.ContainerCommandParams{ | 26 | params := docker.ContainerCommandParams{ | ||
| 27 | - | IsSSL: inputs["container_protocol"] == "https", | 27 | + | IsSSL: inputs["container_protocol"] == "https", |
| 28 | - | ContainerName: inputs["container_name"], | 28 | + | ContainerName: inputs["container_name"], |
| 29 | - | ServiceName: inputs["container_subdomain"], | 29 | + | ServiceName: inputs["container_subdomain"], |
| 30 | - | Domain: inputs["container_domain"], | 30 | + | Domain: inputs["container_domain"], |
| 31 | - | ImageName: inputs["image_name"], | 31 | + | ImageName: inputs["image_name"], |
| 32 | - | ImageID: inputs["image_id"], | 32 | + | ImageID: inputs["image_id"], |
| 33 | - | Restart: inputs["restart"], | 33 | + | Restart: inputs["restart"], |
| 34 | - | TraefikEntryPoint: inputs["traefik_entry_point"], | 34 | + | ProxyPort: inputs["proxy_port"], |
| 35 | - | Volumes: volumeSlice, | 35 | + | Volumes: volumeSlice, |
| 36 | - | Environment: environmentSlice, | 36 | + | Environment: environmentSlice, |
| 37 | - | Network: a.Config.ContainerEngine.Network, | 37 | + | Network: a.Config.ContainerEngine.Network, |
| 38 | - | PortMappings: portMappings, | 38 | + | PortMappings: portMappings, |
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | - | err = CreateTraefikLabels(¶ms, params.TraefikEntryPoint, a) | 41 | + | err = SetupProxyRoute(¶ms, params.ProxyPort, a) |
| 42 | if err != nil { | 42 | if err != nil { | ||
| 43 | - | return docker.ContainerCommandParams{}, fmt.Errorf("error creating Traefik labels: %w", err) | 43 | + | return docker.ContainerCommandParams{}, fmt.Errorf("error setting up proxy route: %w", err) |
| 44 | } | 44 | } | ||
| 45 | 45 | ||||
| 46 | return params, nil | 46 | return params, nil | ||
A internal/templating/cmdparams/proxy.go +38 -0
| @@ -0,0 +1,38 @@ | |||
| 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 | + | // Add custom labels to identify this container as manageable by Gordon | |
| 15 | + | params.Labels = []string{ | |
| 16 | + | "gordon.managed=true", | |
| 17 | + | "gordon.domain=" + params.Domain, | |
| 18 | + | "gordon.service=" + params.ServiceName, | |
| 19 | + | "gordon.proxy.port=" + containerPort, | |
| 20 | + | "gordon.proxy.ssl=" + boolToString(params.IsSSL), | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | log.Debug("Container proxy configuration set up", | |
| 24 | + | "domain", params.Domain, | |
| 25 | + | "service", params.ServiceName, | |
| 26 | + | "proxyPort", containerPort, | |
| 27 | + | "ssl", params.IsSSL) | |
| 28 | + | ||
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // boolToString converts a boolean to a string "true" or "false" | |
| 33 | + | func boolToString(b bool) string { | |
| 34 | + | if b { | |
| 35 | + | return "true" | |
| 36 | + | } | |
| 37 | + | return "false" | |
| 38 | + | } | |
| @@ -0,0 +1,38 @@ | |||||
| 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 | + | // Add custom labels to identify this container as manageable by Gordon | |||
| 15 | + | params.Labels = []string{ | |||
| 16 | + | "gordon.managed=true", | |||
| 17 | + | "gordon.domain=" + params.Domain, | |||
| 18 | + | "gordon.service=" + params.ServiceName, | |||
| 19 | + | "gordon.proxy.port=" + containerPort, | |||
| 20 | + | "gordon.proxy.ssl=" + boolToString(params.IsSSL), | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | log.Debug("Container proxy configuration set up", | |||
| 24 | + | "domain", params.Domain, | |||
| 25 | + | "service", params.ServiceName, | |||
| 26 | + | "proxyPort", containerPort, | |||
| 27 | + | "ssl", params.IsSSL) | |||
| 28 | + | ||||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // boolToString converts a boolean to a string "true" or "false" | |||
| 33 | + | func boolToString(b bool) string { | |||
| 34 | + | if b { | |||
| 35 | + | return "true" | |||
| 36 | + | } | |||
| 37 | + | return "false" | |||
| 38 | + | } | |||
D internal/templating/cmdparams/traefik.go +0 -37
| @@ -1,37 +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 | - | // CreateTraefikLabels creates Traefik labels based on the provided parameters. | |
| 11 | - | func CreateTraefikLabels(params *docker.ContainerCommandParams, containerPort string, a *server.App) error { | |
| 12 | - | entryPoint := a.Config.Traefik.EntryPoint | |
| 13 | - | if params.IsSSL { | |
| 14 | - | entryPoint = a.Config.Traefik.SecureEntryPoint | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | baseRouter := "traefik.http.routers." + params.ContainerName | |
| 18 | - | baseService := "traefik.http.services." + params.ContainerName | |
| 19 | - | ||
| 20 | - | params.Labels = []string{ | |
| 21 | - | "traefik.enable=true", | |
| 22 | - | fmt.Sprintf("%s.rule=Host(`%s.%s`)", baseRouter, params.ServiceName, params.Domain), | |
| 23 | - | fmt.Sprintf("%s.entrypoints=%s", baseRouter, entryPoint), | |
| 24 | - | fmt.Sprintf("%s.loadbalancer.server.port=%s", baseService, containerPort), | |
| 25 | - | } | |
| 26 | - | ||
| 27 | - | resolver := a.Config.Traefik.Resolver | |
| 28 | - | if resolver == "" { | |
| 29 | - | return fmt.Errorf("resolver not set in config") | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | if params.IsSSL { | |
| 33 | - | params.Labels = append(params.Labels, fmt.Sprintf("%s.tls.certresolver=%s", baseRouter, resolver)) | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | return nil | |
| 37 | - | } | |
| @@ -1,37 +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 | - | // CreateTraefikLabels creates Traefik labels based on the provided parameters. | |||
| 11 | - | func CreateTraefikLabels(params *docker.ContainerCommandParams, containerPort string, a *server.App) error { | |||
| 12 | - | entryPoint := a.Config.Traefik.EntryPoint | |||
| 13 | - | if params.IsSSL { | |||
| 14 | - | entryPoint = a.Config.Traefik.SecureEntryPoint | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | baseRouter := "traefik.http.routers." + params.ContainerName | |||
| 18 | - | baseService := "traefik.http.services." + params.ContainerName | |||
| 19 | - | ||||
| 20 | - | params.Labels = []string{ | |||
| 21 | - | "traefik.enable=true", | |||
| 22 | - | fmt.Sprintf("%s.rule=Host(`%s.%s`)", baseRouter, params.ServiceName, params.Domain), | |||
| 23 | - | fmt.Sprintf("%s.entrypoints=%s", baseRouter, entryPoint), | |||
| 24 | - | fmt.Sprintf("%s.loadbalancer.server.port=%s", baseService, containerPort), | |||
| 25 | - | } | |||
| 26 | - | ||||
| 27 | - | resolver := a.Config.Traefik.Resolver | |||
| 28 | - | if resolver == "" { | |||
| 29 | - | return fmt.Errorf("resolver not set in config") | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | if params.IsSSL { | |||
| 33 | - | params.Labels = append(params.Labels, fmt.Sprintf("%s.tls.certresolver=%s", baseRouter, resolver)) | |||
| 34 | - | } | |||
| 35 | - | ||||
| 36 | - | return nil | |||
| 37 | - | } | |||
M internal/templating/models/html/admin/install.gohtml +19 -79
| @@ -50,88 +50,28 @@ | |||
| 50 | 50 | ||
| 51 | 51 | <!-- Main Content --> | |
| 52 | 52 | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | |
| 53 | - | <!-- Install Traefik --> | |
| 54 | - | <div id="install-traefik"> | |
| 55 | - | <div class="flex flex-col items-center"> | |
| 56 | - | <h2 class="text-2xl font-semibold text-gray-800"> | |
| 57 | - | 1 - Install Traefik | |
| 58 | - | </h2> | |
| 59 | - | <!-- Dual view left : install procedure / right : SSE log installation --> | |
| 60 | - | <div class="flex flex-col md:flex-row justify-items-center w-full"> | |
| 61 | - | <!-- Left --> | |
| 62 | - | <div class="w-full md:w-1/2 p-4"> | |
| 63 | - | <form | |
| 64 | - | id="install-traefik-form" | |
| 65 | - | action="/admin/install/traefik" | |
| 66 | - | method="POST" | |
| 67 | - | hx-post="/admin/install/traefik" | |
| 68 | - | hx-swap="outerHTML" | |
| 69 | - | > | |
| 70 | - | <h2 class="">1.1 - Set your top domain name</h2> | |
| 71 | - | <input | |
| 72 | - | type="text" | |
| 73 | - | name="topdomain" | |
| 74 | - | id="topdomain" | |
| 75 | - | class="w-full py-2 px-4 my-2 border border-gray-300 rounded-md" | |
| 76 | - | placeholder="example.com" | |
| 77 | - | /> | |
| 78 | - | <h2>1.2 - Set your admin email (a reachable one)</h2> | |
| 79 | - | <input | |
| 80 | - | type="text" | |
| 81 | - | name="adminemail" | |
| 82 | - | id="adminemail" | |
| 83 | - | class="w-full p-2 border border-gray-300 rounded-md" | |
| 84 | - | placeholder="admin@example.com" | |
| 85 | - | /> | |
| 86 | - | <button | |
| 87 | - | type="submit" | |
| 88 | - | class="w-full p-2 bg-gordonmascot-hblue text-slate-100 rounded-md mt-4" | |
| 89 | - | > | |
| 90 | - | Save & Install Traefik | |
| 91 | - | </button> | |
| 92 | - | </form> | |
| 93 | - | </div> | |
| 94 | - | <!-- Right --> | |
| 95 | - | <div class="w-full md:w-1/2 p-4 text-slate-100"> | |
| 96 | - | <div | |
| 97 | - | class="flex flex-col items-center bg-gordonmascot-hblue rounded-md" | |
| 98 | - | > | |
| 99 | - | <div class="w-full h-64 overflow-y-scroll-auto p-4"> | |
| 100 | - | <div | |
| 101 | - | class="flex flex-col space-y-2" | |
| 102 | - | hx-ext="sse" | |
| 103 | - | sse-connect="/admin/install/traefik/sse" | |
| 104 | - | > | |
| 105 | - | <!-- One item --> | |
| 106 | - | <div class="flex flex-row items-center"> | |
| 107 | - | <span class="">[INFO]</span> | |
| 108 | - | <span class="">Traefik installed</span> | |
| 109 | - | </div> | |
| 110 | - | <!-- One item --> | |
| 111 | - | <div class="flex flex-row items-center"> | |
| 112 | - | <span class="">[INFO]</span> | |
| 113 | - | <span class="ml-2">Traefik started</span> | |
| 114 | - | </div> | |
| 115 | - | <!-- One item --> | |
| 116 | - | <div class="flex flex-row items-center"> | |
| 117 | - | <span class="">[INFO]</span> | |
| 118 | - | <span class="ml-2" | |
| 119 | - | >Traefik configuration file created</span | |
| 120 | - | > | |
| 121 | - | </div> | |
| 122 | - | <!-- One item --> | |
| 123 | - | <div class="flex flex-row items-center"> | |
| 124 | - | <span class="">[INFO]</span> | |
| 125 | - | <span class="">Traefik configuration file updated</span> | |
| 126 | - | </div> | |
| 127 | - | </div> | |
| 128 | - | </div> | |
| 129 | - | </div> | |
| 130 | - | </div> | |
| 53 | + | <!-- Built-in Proxy --> | |
| 54 | + | <div id="built-in-proxy" class="mt-8"> | |
| 55 | + | <div class="flex items-center mb-4"> | |
| 56 | + | <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center"> | |
| 57 | + | <span class="text-white font-bold">✓</span> | |
| 131 | 58 | </div> | |
| 59 | + | <h2 class="text-lg ml-2">Reverse Proxy</h2> | |
| 60 | + | </div> | |
| 61 | + | ||
| 62 | + | <div class="bg-gray-100 p-4 rounded-md"> | |
| 63 | + | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | |
| 64 | + | <p>The proxy automatically:</p> | |
| 65 | + | <ul class="list-disc ml-6 mb-4"> | |
| 66 | + | <li>Manages Let's Encrypt certificates</li> | |
| 67 | + | <li>Routes domains to the appropriate containers</li> | |
| 68 | + | <li>Handles HTTP to HTTPS redirects</li> | |
| 69 | + | <li>Provides proper headers for forwarded requests</li> | |
| 70 | + | </ul> | |
| 71 | + | <p>No additional configuration is required.</p> | |
| 132 | 72 | </div> | |
| 133 | 73 | </div> | |
| 134 | - | <!-- End Install Traefik --> | |
| 74 | + | <!-- End Built-in Proxy --> | |
| 135 | 75 | </div> | |
| 136 | 76 | </body> | |
| 137 | 77 | </html> | |
| @@ -50,88 +50,28 @@ | |||||
| 50 | 50 | ||||
| 51 | <!-- Main Content --> | 51 | <!-- Main Content --> | ||
| 52 | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | 52 | <div class="bg-white p-4 rounded-md shadow mb-4 w-2/3"> | ||
| 53 | - | <!-- Install Traefik --> | 53 | + | <!-- Built-in Proxy --> |
| 54 | - | <div id="install-traefik"> | 54 | + | <div id="built-in-proxy" class="mt-8"> |
| 55 | - | <div class="flex flex-col items-center"> | 55 | + | <div class="flex items-center mb-4"> |
| 56 | - | <h2 class="text-2xl font-semibold text-gray-800"> | 56 | + | <div class="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center"> |
| 57 | - | 1 - Install Traefik | 57 | + | <span class="text-white font-bold">✓</span> |
| 58 | - | </h2> | |||
| 59 | - | <!-- Dual view left : install procedure / right : SSE log installation --> | |||
| 60 | - | <div class="flex flex-col md:flex-row justify-items-center w-full"> | |||
| 61 | - | <!-- Left --> | |||
| 62 | - | <div class="w-full md:w-1/2 p-4"> | |||
| 63 | - | <form | |||
| 64 | - | id="install-traefik-form" | |||
| 65 | - | action="/admin/install/traefik" | |||
| 66 | - | method="POST" | |||
| 67 | - | hx-post="/admin/install/traefik" | |||
| 68 | - | hx-swap="outerHTML" | |||
| 69 | - | > | |||
| 70 | - | <h2 class="">1.1 - Set your top domain name</h2> | |||
| 71 | - | <input | |||
| 72 | - | type="text" | |||
| 73 | - | name="topdomain" | |||
| 74 | - | id="topdomain" | |||
| 75 | - | class="w-full py-2 px-4 my-2 border border-gray-300 rounded-md" | |||
| 76 | - | placeholder="example.com" | |||
| 77 | - | /> | |||
| 78 | - | <h2>1.2 - Set your admin email (a reachable one)</h2> | |||
| 79 | - | <input | |||
| 80 | - | type="text" | |||
| 81 | - | name="adminemail" | |||
| 82 | - | id="adminemail" | |||
| 83 | - | class="w-full p-2 border border-gray-300 rounded-md" | |||
| 84 | - | placeholder="admin@example.com" | |||
| 85 | - | /> | |||
| 86 | - | <button | |||
| 87 | - | type="submit" | |||
| 88 | - | class="w-full p-2 bg-gordonmascot-hblue text-slate-100 rounded-md mt-4" | |||
| 89 | - | > | |||
| 90 | - | Save & Install Traefik | |||
| 91 | - | </button> | |||
| 92 | - | </form> | |||
| 93 | - | </div> | |||
| 94 | - | <!-- Right --> | |||
| 95 | - | <div class="w-full md:w-1/2 p-4 text-slate-100"> | |||
| 96 | - | <div | |||
| 97 | - | class="flex flex-col items-center bg-gordonmascot-hblue rounded-md" | |||
| 98 | - | > | |||
| 99 | - | <div class="w-full h-64 overflow-y-scroll-auto p-4"> | |||
| 100 | - | <div | |||
| 101 | - | class="flex flex-col space-y-2" | |||
| 102 | - | hx-ext="sse" | |||
| 103 | - | sse-connect="/admin/install/traefik/sse" | |||
| 104 | - | > | |||
| 105 | - | <!-- One item --> | |||
| 106 | - | <div class="flex flex-row items-center"> | |||
| 107 | - | <span class="">[INFO]</span> | |||
| 108 | - | <span class="">Traefik installed</span> | |||
| 109 | - | </div> | |||
| 110 | - | <!-- One item --> | |||
| 111 | - | <div class="flex flex-row items-center"> | |||
| 112 | - | <span class="">[INFO]</span> | |||
| 113 | - | <span class="ml-2">Traefik started</span> | |||
| 114 | - | </div> | |||
| 115 | - | <!-- One item --> | |||
| 116 | - | <div class="flex flex-row items-center"> | |||
| 117 | - | <span class="">[INFO]</span> | |||
| 118 | - | <span class="ml-2" | |||
| 119 | - | >Traefik configuration file created</span | |||
| 120 | - | > | |||
| 121 | - | </div> | |||
| 122 | - | <!-- One item --> | |||
| 123 | - | <div class="flex flex-row items-center"> | |||
| 124 | - | <span class="">[INFO]</span> | |||
| 125 | - | <span class="">Traefik configuration file updated</span> | |||
| 126 | - | </div> | |||
| 127 | - | </div> | |||
| 128 | - | </div> | |||
| 129 | - | </div> | |||
| 130 | - | </div> | |||
| 131 | </div> | 58 | </div> | ||
| 59 | + | <h2 class="text-lg ml-2">Reverse Proxy</h2> | |||
| 60 | + | </div> | |||
| 61 | + | ||||
| 62 | + | <div class="bg-gray-100 p-4 rounded-md"> | |||
| 63 | + | <p class="mb-4">Gordon now uses a built-in reverse proxy to handle all domain routing and TLS certificates.</p> | |||
| 64 | + | <p>The proxy automatically:</p> | |||
| 65 | + | <ul class="list-disc ml-6 mb-4"> | |||
| 66 | + | <li>Manages Let's Encrypt certificates</li> | |||
| 67 | + | <li>Routes domains to the appropriate containers</li> | |||
| 68 | + | <li>Handles HTTP to HTTPS redirects</li> | |||
| 69 | + | <li>Provides proper headers for forwarded requests</li> | |||
| 70 | + | </ul> | |||
| 71 | + | <p>No additional configuration is required.</p> | |||
| 132 | </div> | 72 | </div> | ||
| 133 | </div> | 73 | </div> | ||
| 134 | - | <!-- End Install Traefik --> | 74 | + | <!-- End Built-in Proxy --> |
| 135 | </div> | 75 | </div> | ||
| 136 | </body> | 76 | </body> | ||
| 137 | </html> | 77 | </html> | ||
M internal/templating/models/html/fragments/containerlist.gohtml +1 -1
| @@ -27,7 +27,7 @@ | |||
| 27 | 27 | > | |
| 28 | 28 | </td> | |
| 29 | 29 | <td class="port-container">{{ .Ports }}</td> | |
| 30 | - | <td class="entrypoint-container">{{.TraefikEntryPoint}}</td> | |
| 30 | + | <td class="entrypoint-container">{{.ProxyPort}}</td> | |
| 31 | 31 | <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td> | |
| 32 | 32 | <td style="color: {{.StateColor}};">{{.State}}</td> | |
| 33 | 33 | <td> | |
| @@ -27,7 +27,7 @@ | |||||
| 27 | > | 27 | > | ||
| 28 | </td> | 28 | </td> | ||
| 29 | <td class="port-container">{{ .Ports }}</td> | 29 | <td class="port-container">{{ .Ports }}</td> | ||
| 30 | - | <td class="entrypoint-container">{{.TraefikEntryPoint}}</td> | 30 | + | <td class="entrypoint-container">{{.ProxyPort}}</td> |
| 31 | <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td> | 31 | <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td> | ||
| 32 | <td style="color: {{.StateColor}};">{{.State}}</td> | 32 | <td style="color: {{.StateColor}};">{{.State}}</td> | ||
| 33 | <td> | 33 | <td> | ||
M internal/templating/models/html/fragments/createcontainer.gohtml +7 -7
| @@ -59,13 +59,13 @@ | |||
| 59 | 59 | <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" /> | |
| 60 | 60 | </div> | |
| 61 | 61 | ||
| 62 | - | <!-- Traefik Entry Point --> | |
| 63 | - | <div class="pb-4"> | |
| 64 | - | <label class="block">Traefik Entry Point (Required)</label> | |
| 65 | - | <span class="text-gray-500"> | |
| 66 | - | Specify the internal container port used in Traefik's loadbalancer labels | |
| 67 | - | </span> | |
| 68 | - | <input type="text" name="traefik_entry_point" class="w-full p-4 rounded-md" value="{{.TraefikEntryPoint}}" required /> | |
| 62 | + | <!-- Proxy Port --> | |
| 63 | + | <div class="mb-6"> | |
| 64 | + | <label class="block">Container Proxy Port (Required)</label> | |
| 65 | + | <div class="text-xs text-gray-500 mb-2"> | |
| 66 | + | Specify the internal container port that the proxy will route traffic to | |
| 67 | + | </div> | |
| 68 | + | <input type="text" name="proxy_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> | |
| 69 | 69 | </div> | |
| 70 | 70 | ||
| 71 | 71 | <!-- Volumes --> | |
| @@ -59,13 +59,13 @@ | |||||
| 59 | <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" /> | 59 | <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" /> | ||
| 60 | </div> | 60 | </div> | ||
| 61 | 61 | ||||
| 62 | - | <!-- Traefik Entry Point --> | 62 | + | <!-- Proxy Port --> |
| 63 | - | <div class="pb-4"> | 63 | + | <div class="mb-6"> |
| 64 | - | <label class="block">Traefik Entry Point (Required)</label> | 64 | + | <label class="block">Container Proxy Port (Required)</label> |
| 65 | - | <span class="text-gray-500"> | 65 | + | <div class="text-xs text-gray-500 mb-2"> |
| 66 | - | Specify the internal container port used in Traefik's loadbalancer labels | 66 | + | Specify the internal container port that the proxy will route traffic to |
| 67 | - | </span> | 67 | + | </div> |
| 68 | - | <input type="text" name="traefik_entry_point" class="w-full p-4 rounded-md" value="{{.TraefikEntryPoint}}" required /> | 68 | + | <input type="text" name="proxy_port" class="w-full p-4 rounded-md" value="{{.ProxyPort}}" required /> |
| 69 | </div> | 69 | </div> | ||
| 70 | 70 | ||||
| 71 | <!-- Volumes --> | 71 | <!-- Volumes --> | ||
M internal/templating/models/txt/locstrings.yml +4 -4
| @@ -52,8 +52,8 @@ en: | |||
| 52 | 52 | domain: Domain | |
| 53 | 53 | exposed_ports: Exposed Ports | |
| 54 | 54 | exposed_ports_hint: "eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)" | |
| 55 | - | traefik_entry_point: Traefik Entry Point | |
| 56 | - | traefik_entry_point_hint: Specify the internal container port used in Traefik's loadbalancer labels | |
| 55 | + | proxy_port: Proxy Port | |
| 56 | + | proxy_port_hint: Specify the internal container port that the proxy will route traffic to | |
| 57 | 57 | volumes: Volumes | |
| 58 | 58 | volumes_hint: "e.g. ./data:/container/data" | |
| 59 | 59 | environment_variables: Environment Variables | |
| @@ -127,8 +127,8 @@ fr: | |||
| 127 | 127 | domain: Domaine | |
| 128 | 128 | exposed_ports: Ports exposés | |
| 129 | 129 | exposed_ports_hint: "ex: 8080:1887/tcp (Vous pouvez spécifier plusieurs ports en les séparant par une virgule)" | |
| 130 | - | traefik_entry_point: Point d'entrée Traefik | |
| 131 | - | traefik_entry_point_hint: Spécifiez le port interne du conteneur utilisé dans le label du loadbalancer de Traefik | |
| 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 | 132 | volumes: Volumes | |
| 133 | 133 | volumes_hint: "ex. ./data:/container/data" | |
| 134 | 134 | environment_variables: Variables d'environnement | |
| @@ -52,8 +52,8 @@ en: | |||||
| 52 | domain: Domain | 52 | domain: Domain | ||
| 53 | exposed_ports: Exposed Ports | 53 | exposed_ports: Exposed Ports | ||
| 54 | exposed_ports_hint: "eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)" | 54 | exposed_ports_hint: "eg: 8080:1887/tcp (You can specify multiple ports by separating them with a comma)" | ||
| 55 | - | traefik_entry_point: Traefik Entry Point | 55 | + | proxy_port: Proxy Port |
| 56 | - | traefik_entry_point_hint: Specify the internal container port used in Traefik's loadbalancer labels | 56 | + | proxy_port_hint: Specify the internal container port that the proxy will route traffic to |
| 57 | volumes: Volumes | 57 | volumes: Volumes | ||
| 58 | volumes_hint: "e.g. ./data:/container/data" | 58 | volumes_hint: "e.g. ./data:/container/data" | ||
| 59 | environment_variables: Environment Variables | 59 | environment_variables: Environment Variables | ||
| @@ -127,8 +127,8 @@ fr: | |||||
| 127 | domain: Domaine | 127 | domain: Domaine | ||
| 128 | exposed_ports: Ports exposés | 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)" | 129 | exposed_ports_hint: "ex: 8080:1887/tcp (Vous pouvez spécifier plusieurs ports en les séparant par une virgule)" | ||
| 130 | - | traefik_entry_point: Point d'entrée Traefik | 130 | + | proxy_port: Proxy Port |
| 131 | - | traefik_entry_point_hint: Spécifiez le port interne du conteneur utilisé dans le label du loadbalancer de Traefik | 131 | + | proxy_port_hint: Spécifiez le port interne du conteneur que le proxy va router le trafic vers |
| 132 | volumes: Volumes | 132 | volumes: Volumes | ||
| 133 | volumes_hint: "ex. ./data:/container/data" | 133 | volumes_hint: "ex. ./data:/container/data" | ||
| 134 | environment_variables: Variables d'environnement | 134 | environment_variables: Variables d'environnement | ||
M pkg/docker/containers.go +15 -15
| @@ -17,21 +17,21 @@ import ( | |||
| 17 | 17 | ) | |
| 18 | 18 | ||
| 19 | 19 | type ContainerCommandParams struct { | |
| 20 | - | ContainerName string | |
| 21 | - | ContainerHost string | |
| 22 | - | Domain string | |
| 23 | - | ServiceName string | |
| 24 | - | IsSSL bool | |
| 25 | - | EnvVar string | |
| 26 | - | ImageName string | |
| 27 | - | ImageID string | |
| 28 | - | PortMappings []PortMapping | |
| 29 | - | Volumes []string | |
| 30 | - | Labels []string | |
| 31 | - | Network string | |
| 32 | - | Restart string | |
| 33 | - | Environment []string | |
| 34 | - | TraefikEntryPoint string | |
| 20 | + | ContainerName string | |
| 21 | + | ContainerHost string | |
| 22 | + | Domain string | |
| 23 | + | ServiceName string | |
| 24 | + | IsSSL bool | |
| 25 | + | EnvVar string | |
| 26 | + | ImageName string | |
| 27 | + | ImageID string | |
| 28 | + | PortMappings []PortMapping | |
| 29 | + | Volumes []string | |
| 30 | + | Labels []string | |
| 31 | + | Network string | |
| 32 | + | Restart string | |
| 33 | + | Environment []string | |
| 34 | + | ProxyPort string // Container port to be used by the reverse proxy | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | 37 | type PortMapping struct { | |
| @@ -17,21 +17,21 @@ import ( | |||||
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | type ContainerCommandParams struct { | 19 | type ContainerCommandParams struct { | ||
| 20 | - | ContainerName string | 20 | + | ContainerName string |
| 21 | - | ContainerHost string | 21 | + | ContainerHost string |
| 22 | - | Domain string | 22 | + | Domain string |
| 23 | - | ServiceName string | 23 | + | ServiceName string |
| 24 | - | IsSSL bool | 24 | + | IsSSL bool |
| 25 | - | EnvVar string | 25 | + | EnvVar string |
| 26 | - | ImageName string | 26 | + | ImageName string |
| 27 | - | ImageID string | 27 | + | ImageID string |
| 28 | - | PortMappings []PortMapping | 28 | + | PortMappings []PortMapping |
| 29 | - | Volumes []string | 29 | + | Volumes []string |
| 30 | - | Labels []string | 30 | + | Labels []string |
| 31 | - | Network string | 31 | + | Network string |
| 32 | - | Restart string | 32 | + | Restart string |
| 33 | - | Environment []string | 33 | + | Environment []string |
| 34 | - | TraefikEntryPoint string | 34 | + | ProxyPort string // Container port to be used by the reverse proxy |
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | type PortMapping struct { | 37 | type PortMapping struct { | ||