detached a22ca16

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 @@
1111
1212 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.
1313
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-
1614 ## **Key Features**
1715
1816 - **Ease of use**:
@@ -28,7 +26,7 @@ Optionally, Gordon can coordinate domain routing through Traefik (which needs to
2826 - **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.
2927 - **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).
3028 - **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.
3230 - **One binary**: Gordon follows the Golang philosophy of keeping things simple, offering a single binary for all functionalities.
3331
3432 ## **Why ?**
@@ -39,7 +37,7 @@ I needed a simple tool that would automate the process of quickly spin up my web
3937
4038 For detailed instructions, please refer to the [wiki](https://github.com/bnema/gordon/wiki/)
4139
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)
4341 - [Setting up Gordon's Client](https://github.com/bnema/gordon/wiki/Setting-up-Gordon's-Client)
4442 - [Deploying your first app](https://github.com/bnema/gordon/wiki/First-deployment)
4543
@@ -75,3 +73,9 @@ Have suggestions? Feel free to open an issue!
7573 ## **License**
7674
7775 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) {
2626 rootCmd.AddCommand(cmd.NewUpdateCommand(client))
2727 rootCmd.AddCommand(cmd.NewPushCommand(client))
2828 rootCmd.AddCommand(cmd.NewVersionCommand(client))
29+ rootCmd.AddCommand(cmd.NewProxyCommand(server))
2930 }
3031
3132 func Execute(client *cli.App, server *server.App) {
@@ -52,8 +53,6 @@ func ExecuteCLI(build, commit, date string) {
5253 fmt.Println("Error initializing app:", err)
5354 }
5455
55- common.DockerInit(&s.Config.ContainerEngine)
56-
5756 // Start periodic version checking in the background
5857 go common.CheckVersionPeriodically(versionInfo, 3*time.Hour)
5958
@@ -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 background56 // 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 @@
11 module github.com/bnema/gordon
22
3-go 1.23
3+go 1.24.1
44
55 require (
66 github.com/AlecAivazis/survey/v2 v2.3.7
@@ -15,7 +15,7 @@ require (
1515 github.com/gorilla/sessions v1.4.0
1616 github.com/joho/godotenv v1.5.1
1717 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
1919 github.com/microcosm-cc/bluemonday v1.0.27
2020 github.com/pocketbase/pocketbase v0.22.19
2121 github.com/spf13/cobra v1.8.1
@@ -60,8 +60,8 @@ require (
6060 go.opentelemetry.io/otel/sdk v1.21.0 // indirect
6161 go.opentelemetry.io/otel/trace v1.29.0 // indirect
6262 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
6565 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
6666 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
6767 modernc.org/libc v1.59.9 // indirect
@@ -77,7 +77,6 @@ require (
7777 github.com/aymerick/douceur v0.2.0 // indirect
7878 github.com/docker/go-units v0.5.0 // indirect
7979 github.com/gogo/protobuf v1.3.2 // indirect
80- github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
8180 github.com/google/uuid v1.6.0 // direct
8281 github.com/gorilla/context v1.1.2 // indirect
8382 github.com/gorilla/css v1.0.1 // indirect
@@ -93,11 +92,11 @@ require (
9392 github.com/rogpeppe/go-internal v1.11.0 // indirect
9493 github.com/valyala/bytebufferpool v1.0.0 // indirect
9594 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
101100 golang.org/x/tools v0.24.0 // indirect
102101 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
103102 gotest.tools/v3 v3.5.0 // indirect
@@ -1,6 +1,6 @@
1 module github.com/bnema/gordon1 module github.com/bnema/gordon
2 2
3-go 1.233+go 1.24.1
4 4
5 require (5 require (
6 github.com/AlecAivazis/survey/v2 v2.3.76 github.com/AlecAivazis/survey/v2 v2.3.7
@@ -15,7 +15,7 @@ require (
15 github.com/gorilla/sessions v1.4.015 github.com/gorilla/sessions v1.4.0
16 github.com/joho/godotenv v1.5.116 github.com/joho/godotenv v1.5.1
17 github.com/labstack/echo-contrib v0.17.117 github.com/labstack/echo-contrib v0.17.1
18- github.com/labstack/echo/v4 v4.12.018+ github.com/labstack/echo/v4 v4.13.3
19 github.com/microcosm-cc/bluemonday v1.0.2719 github.com/microcosm-cc/bluemonday v1.0.27
20 github.com/pocketbase/pocketbase v0.22.1920 github.com/pocketbase/pocketbase v0.22.19
21 github.com/spf13/cobra v1.8.121 github.com/spf13/cobra v1.8.1
@@ -60,8 +60,8 @@ require (
60 go.opentelemetry.io/otel/sdk v1.21.0 // indirect60 go.opentelemetry.io/otel/sdk v1.21.0 // indirect
61 go.opentelemetry.io/otel/trace v1.29.0 // indirect61 go.opentelemetry.io/otel/trace v1.29.0 // indirect
62 golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct62 golang.org/x/exp v0.0.0-20231108232855-2478ac86f678 // direct
63- golang.org/x/sync v0.8.0 // indirect63+ golang.org/x/sync v0.12.0 // indirect
64- golang.org/x/term v0.23.0 // indirect64+ golang.org/x/term v0.30.0 // indirect
65 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect65 google.golang.org/genproto/googleapis/api v0.0.0-20240528184218-531527333157 // indirect
66 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect66 modernc.org/gc/v3 v3.0.0-20240801135723-a856999a2e4a // indirect
67 modernc.org/libc v1.59.9 // indirect67 modernc.org/libc v1.59.9 // indirect
@@ -77,7 +77,6 @@ require (
77 github.com/aymerick/douceur v0.2.0 // indirect77 github.com/aymerick/douceur v0.2.0 // indirect
78 github.com/docker/go-units v0.5.0 // indirect78 github.com/docker/go-units v0.5.0 // indirect
79 github.com/gogo/protobuf v1.3.2 // indirect79 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 // direct80 github.com/google/uuid v1.6.0 // direct
82 github.com/gorilla/context v1.1.2 // indirect81 github.com/gorilla/context v1.1.2 // indirect
83 github.com/gorilla/css v1.0.1 // indirect82 github.com/gorilla/css v1.0.1 // indirect
@@ -93,11 +92,11 @@ require (
93 github.com/rogpeppe/go-internal v1.11.0 // indirect92 github.com/rogpeppe/go-internal v1.11.0 // indirect
94 github.com/valyala/bytebufferpool v1.0.0 // indirect93 github.com/valyala/bytebufferpool v1.0.0 // indirect
95 github.com/valyala/fasttemplate v1.2.2 // indirect94 github.com/valyala/fasttemplate v1.2.2 // indirect
96- golang.org/x/crypto v0.26.0 // indirect95+ golang.org/x/crypto v0.36.0
97- golang.org/x/net v0.28.0 // indirect96+ golang.org/x/net v0.33.0 // indirect
98- golang.org/x/sys v0.24.0 // indirect97+ golang.org/x/sys v0.31.0 // indirect
99- golang.org/x/text v0.17.0 // indirect98+ golang.org/x/text v0.23.0 // indirect
100- golang.org/x/time v0.6.0 // indirect99+ golang.org/x/time v0.8.0 // indirect
101 golang.org/x/tools v0.24.0 // indirect100 golang.org/x/tools v0.24.0 // indirect
102 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect101 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
103 gotest.tools/v3 v3.5.0 // indirect102 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 {
6565 },
6666 }
6767
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")
7070
7171 return deployCmd
7272 }
@@ -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 deployCmd71 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 @@
33 package cmd
44
55 import (
6+ "fmt"
7+
68 "github.com/bnema/gordon/internal/cli/handler"
9+ "github.com/bnema/gordon/internal/common"
710 "github.com/bnema/gordon/internal/server"
811 "github.com/bnema/gordon/pkg/docker"
912 "github.com/spf13/cobra"
@@ -14,9 +17,10 @@ func NewServeCommand(a *server.App) *cobra.Command {
1417 defaultport := "1323"
1518 var port string
1619
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)
1822 if docker.IsRunningInContainer() {
19- defaultport = "80"
23+ defaultport = "8080"
2024 }
2125
2226 // if no flags -p or --port are specified, the default port is used
@@ -25,6 +29,12 @@ func NewServeCommand(a *server.App) *cobra.Command {
2529 Short: "Start a new Gordon server instance",
2630 Args: cobra.NoArgs,
2731 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+
2838 // handler.StartServer will use the value of port, which will be the default
2939 // value if no flag is specified
3040 a.Config.Http.Port = port
@@ -3,7 +3,10 @@
3 package cmd3 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 string18 var port string
16 19
17- // if the server is running in a docker container, the default port is 8020+ // 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 used26 // 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 default38 // handler.StartServer will use the value of port, which will be the default
29 // value if no flag is specified39 // value if no flag is specified
30 a.Config.Http.Port = port40 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 {
2929 log.Print(err)
3030 }
3131
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+
3239 // Setup a channel to capture termination signals
3340 sigs := make(chan os.Signal, 1)
3441 signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
3542
3643 go func() {
3744 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+
3954 os.Exit(0)
4055 }()
4156
@@ -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 signals39 // 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 := <-sigs44 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 (
77 "os"
88 "path/filepath"
99 "runtime"
10+ "strconv"
1011 "strings"
1112
1213 "github.com/bnema/gordon/pkg/docker"
@@ -18,7 +19,7 @@ type Config struct {
1819 Http HttpConfig `yaml:"Http"`
1920 Admin AdminConfig `yaml:"Admin"`
2021 ContainerEngine ContainerEngineConfig `yaml:"ContainerEngine"`
21- Traefik TraefikConfig `yaml:"Traefik"`
22+ ReverseProxy ReverseProxyConfig `yaml:"ReverseProxy"`
2223 Build BuildConfig `yaml:"-"`
2324 }
2425
@@ -53,19 +54,30 @@ type ContainerEngineConfig struct {
5354 Network string `yaml:"network"`
5455 }
5556
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
6067 }
6168
6269 // Default values
6370 var (
6471 sock = "/var/run/docker.sock"
6572 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
6981 )
7082
7183 func getConfigDir() (string, error) {
@@ -92,7 +104,7 @@ func getConfigDir() (string, error) {
92104 }
93105
94106 if docker.IsRunningInContainer() {
95- return "/.", nil
107+ return "/", nil
96108 }
97109
98110 // Check for XDG_CONFIG_HOME first
@@ -126,13 +138,135 @@ func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error {
126138 return nil
127139 }
128140
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+
129256 func (config *Config) LoadConfig() (*Config, error) {
130257 configDir, err := getConfigDir()
131258 if err != nil {
132259 return nil, fmt.Errorf("error getting configuration directory: %w", err)
133260 }
134261
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+ }
136270
137271 _, err = os.Stat(configFilePath)
138272 if errors.Is(err, fs.ErrNotExist) {
@@ -145,19 +279,54 @@ func (config *Config) LoadConfig() (*Config, error) {
145279 }
146280
147281 // 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+ },
149299 ContainerEngine: ContainerEngineConfig{
150300 Sock: sock,
151301 PodmanSock: podmansock,
152302 Podman: ReadUserInput("Are you using podman ? (y/n)") == "y",
303+ Network: "gordon",
153304 },
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,
158314 },
159315 }
160316
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+
161330 err = config.SaveConfig()
162331 if err != nil {
163332 return nil, fmt.Errorf("error saving new configuration: %w", err)
@@ -174,6 +343,12 @@ func (config *Config) LoadConfig() (*Config, error) {
174343 return nil, err
175344 }
176345
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+
177352 // Load run env
178353 config.Build.RunEnv = os.Getenv("RUN_ENV")
179354
@@ -181,6 +356,14 @@ func (config *Config) LoadConfig() (*Config, error) {
181356 config.Build.RunEnv = "prod"
182357 }
183358
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+
184367 return config, nil
185368 }
186369
@@ -189,7 +372,16 @@ func (config *Config) SaveConfig() error {
189372 if err != nil {
190373 return fmt.Errorf("error getting configuration directory: %w", err)
191374 }
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)
193385
194386 err = parser.WriteYAMLFile(configFilePath, config)
195387 if err != nil {
@@ -199,6 +391,12 @@ func (config *Config) SaveConfig() error {
199391 return nil
200392 }
201393
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+
202400 func (config *Config) GetToken() (string, error) {
203401 token := config.General.Token
204402 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 values69 // 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 "/.", nil107+ return "/", nil
96 }108 }
97 109
98 // Check for XDG_CONFIG_HOME first110 // Check for XDG_CONFIG_HOME first
@@ -126,13 +138,135 @@ func readAndUnmarshalConfig(fs fs.FS, filePath string, config *Config) error {
126 return nil138 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 ContainerEngineConfig281 // 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, err343 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 env352 // 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, nil367 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 nil391 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.Token401 token := config.General.Token
204 if token == "" {402 if token == "" {
M internal/common/init_dockerclient.go +82 -3
@@ -1,16 +1,95 @@
11 package common
22
33 import (
4+ "fmt"
45 "log"
6+ "os"
7+ "strings"
58
69 "github.com/bnema/gordon/pkg/docker"
710 )
811
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)
1124 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
1376 }
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")
1493 }
1594
1695 // NewDockerConfig creates and returns a new Docker client configuration
@@ -1,16 +1,95 @@
1 package common1 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 configuration95 // NewDockerConfig creates and returns a new Docker client configuration
M internal/db/db_struct.go +35 -0
@@ -42,3 +42,38 @@ type Clients struct {
4242 Hostname string `sql:"hostname"`
4343 Expires string `sql:"expires"`
4444 }
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 {
6262
6363 type HumanReadableContainer struct {
6464 *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
6577 Name string
66- Ports []string
67- ShortID string
78+ Ports []docker.PortMapping
79+ ProxyPort string
6880 CreatedStr string
69- SizeStr string
70- UpSince string
71- StateColor string
72- TraefikEntryPoint string
81+ Status string
82+ ImageName string
83+ IsGordonContainer bool
7384 }
7485
7586 // renderHTML is a generalized function to render HTML
@@ -180,14 +191,14 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
180191 for _, name := range container.Names {
181192 name = name[1:]
182193 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)),
191202 })
192203 }
193204 }
@@ -203,12 +214,19 @@ func ContainerManagerComponent(c echo.Context, a *server.App) error {
203214 return renderHTML(c, a, "html/fragments", "containerlist.gohtml", data)
204215 }
205216
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
207224 for key, value := range labels {
208- if strings.HasSuffix(key, ".loadbalancer.server.port") {
225+ if strings.Contains(key, "loadbalancer.server.port") {
209226 return value
210227 }
211228 }
229+
212230 return ""
213231 }
214232
@@ -62,14 +62,25 @@ type HumanReadableContainerImage struct {
62 62
63 type HumanReadableContainer struct {63 type HumanReadableContainer struct {
64 *types.Container64 *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 string77 Name string
66- Ports []string78+ Ports []docker.PortMapping
67- ShortID string79+ ProxyPort string
68 CreatedStr string80 CreatedStr string
69- SizeStr string81+ Status string
70- UpSince string82+ ImageName string
71- StateColor string83+ IsGordonContainer bool
72- TraefikEntryPoint string
73 }84 }
74 85
75 // renderHTML is a generalized function to render HTML86 // 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 value226 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 (
1212
1313 authToken "github.com/bnema/gordon/internal/cli/auth"
1414 "github.com/bnema/gordon/internal/common"
15+ "github.com/bnema/gordon/internal/proxy"
1516 "github.com/bnema/gordon/internal/server"
1617 "github.com/bnema/gordon/pkg/docker"
1718 "github.com/charmbracelet/log"
@@ -76,6 +77,17 @@ func PostDeploy(c echo.Context, a *server.App) error {
7677 })
7778 }
7879
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+
7991 // Arrived here means deployment was successful
8092 return sendJSONResponse(c, http.StatusOK, DeployResponse{
8193 Success: true,
@@ -316,6 +328,13 @@ func processCompleteChunkedDeployTansfert(c echo.Context, a *server.App, transfe
316328 "containerName", containerName,
317329 "domain", targetDomain)
318330
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+
319338 return sendJSONResponse(c, http.StatusOK, DeployResponse{
320339 Success: true,
321340 Message: "Deployment successful",
@@ -371,3 +390,27 @@ func sendDeployErrorResponse(c echo.Context, message string, err error) error {
371390
372391 // return fmt.Errorf("deployment not ready after %d attempts", maxRetries)
373392 // }
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 successful91 // 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 {
2626 }
2727
2828 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
3447 }
3548
3649 // 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 URL49 // GenerateOauthCallbackURL generates the OAuth callback URL
M internal/server/new_server.go +40 -0
@@ -2,9 +2,12 @@ package server
22
33 import (
44 "io"
5+ "os"
6+ "path/filepath"
57 "time"
68
79 "github.com/bnema/gordon/internal/common"
10+ "github.com/bnema/gordon/internal/proxy"
811 "github.com/bnema/gordon/internal/templating"
912 "github.com/bnema/gordon/internal/webui"
1013 "github.com/charmbracelet/log"
@@ -23,6 +26,24 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
2326 log.Fatalf("Failed to load configuration: %v", err)
2427 }
2528
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+
2647 // Open the strings.yml file containing the strings for the current language
2748 file, err := templating.TemplateFS.Open("txt/locstrings.yml")
2849 if err != nil {
@@ -55,3 +76,22 @@ func NewServerApp(buildConfig *common.BuildConfig) (*App, error) {
5576
5677 return a, nil
5778 }
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 language47 // 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, nil77 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 (
77 "path/filepath"
88
99 "github.com/bnema/gordon/internal/db"
10- pkgsqlite "github.com/bnema/gordon/pkg/sqlite"
10+ "github.com/charmbracelet/log"
1111 _ "modernc.org/sqlite"
1212 )
1313
@@ -18,12 +18,14 @@ const (
1818 func (a *App) getDiskDBFilePath() string {
1919 DiskDBfilepath := filepath.Join(a.DBDir, a.DBFilename)
2020 // if a.DBDir is empty return log "Set a path using storageDir in the config file"
21+ log.Debug("DB file path", "path", DiskDBfilepath)
2122 return DiskDBfilepath
2223 }
2324
2425 // ensureDBDir ensures that the database directory exists.
2526 func (a *App) ensureDBDir() error {
2627 if _, err := os.Stat(a.DBDir); os.IsNotExist(err) {
28+ log.Debug("Creating database directory", "dir", a.DBDir)
2729 if err := os.MkdirAll(a.DBDir, 0755); err != nil {
2830 return err
2931 }
@@ -34,20 +36,48 @@ func (a *App) ensureDBDir() error {
3436 // checkDBFile checks if the database file exists.
3537 func (a *App) checkDBFile(dbPath string) (bool, error) {
3638 if _, err := os.Stat(dbPath); os.IsNotExist(err) {
39+ log.Debug("Database file does not exist", "path", dbPath)
3740 return false, nil
3841 } else if err != nil {
3942 return false, err
4043 }
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)
4169 return true, nil
4270 }
4371
4472 // InitializeDB initializes the database
4573 func InitializeDB(a *App) (*sql.DB, error) {
74+ log.Debug("Initializing database")
4675 if err := a.ensureDBDir(); err != nil {
4776 return nil, fmt.Errorf("failed to ensure DB directory: %w", err)
4877 }
4978
5079 dbPath := a.getDiskDBFilePath()
80+ a.DBPath = dbPath
5181
5282 dbExists, err := a.checkDBFile(dbPath)
5383 if err != nil {
@@ -55,6 +85,7 @@ func InitializeDB(a *App) (*sql.DB, error) {
5585 }
5686
5787 if !dbExists {
88+ log.Debug("Database needs bootstrapping", "path", dbPath)
5889 if err := bootstrapDB(dbPath, a); err != nil {
5990 return nil, fmt.Errorf("failed to bootstrap DB: %w", err)
6091 }
@@ -65,42 +96,120 @@ func InitializeDB(a *App) (*sql.DB, error) {
6596 return nil, fmt.Errorf("failed to open disk DB: %w", err)
6697 }
6798
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+
68104 a.DB = db
69105
70106 if dbExists {
71107 // Populate the struct tables from the database
108+ log.Debug("Populating struct tables from existing database")
72109 if err := PopulateStructWithTables(a); err != nil {
73110 return nil, err
74111 }
112+ } else {
113+ log.Debug("Initialized new database")
75114 }
76115
77116 return db, nil
78117 }
79118
80119 func bootstrapDB(dbPath string, app *App) error {
120+ // Open the database file
81121 db, err := sql.Open("sqlite", dbPath)
82122 if err != nil {
83- return err
123+ return fmt.Errorf("error opening database: %w", err)
84124 }
85125 defer db.Close()
86126
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)
97131 }
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)
100208 }
101209
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)
104213 }
105214
106215 return nil
@@ -130,6 +239,18 @@ type ClientsWrapper struct {
130239 *db.Clients
131240 }
132241
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+
133254 func populateTableFromDB(a *App, query string, table DBTable) error {
134255 rows, err := a.DB.Query(query)
135256 if err != nil {
@@ -166,20 +287,66 @@ func (c *ClientsWrapper) ScanFromRows(rows *sql.Rows) error {
166287 return rows.Scan(&c.ID, &c.AccountID, &c.OS, &c.IP, &c.Hostname, &c.Expires)
167288 }
168289
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+
169302 // PopulateStructWithTables updates db struct with tables
170303 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
177308 }
178309
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
183350 }
184351
185352 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 DiskDBfilepath22 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 err30 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, nil40 return false, nil
38 } else if err != nil {41 } else if err != nil {
39 return false, err42 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, nil69 return true, nil
42 }70 }
43 71
44 // InitializeDB initializes the database72 // 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 = db104 a.DB = db
69 105
70 if dbExists {106 if dbExists {
71 // Populate the struct tables from the database107 // 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, err110 return nil, err
74 }111 }
112+ } else {
113+ log.Debug("Initialized new database")
75 }114 }
76 115
77 return db, nil116 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 err123+ return fmt.Errorf("error opening database: %w", err)
84 }124 }
85 defer db.Close()125 defer db.Close()
86 126
87- tables := app.DBTables127+ // 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 err130+ 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 err133+ // 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 err211+ if err := tx.Commit(); err != nil {
212+ return fmt.Errorf("failed to commit transaction: %w", err)
104 }213 }
105 214
106 return nil215 return nil
@@ -130,6 +239,18 @@ type ClientsWrapper struct {
130 *db.Clients239 *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 tables302 // 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 err312+ 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 nil352 return nil
M internal/templating/cmdparams/from_cli.go +13 -13
@@ -37,22 +37,22 @@ func FromPayloadStructToCmdParams(ppl *common.DeployPayload, a *server.App, imag
3737 containerDomain := strings.Join(hostParts[1:], ".")
3838
3939 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,
5151 }
5252
53- err = CreateTraefikLabels(&params, params.TraefikEntryPoint, a)
53+ err = SetupProxyRoute(&params, params.ProxyPort, a)
5454 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)
5656 }
5757
5858 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(&params, params.TraefikEntryPoint, a)53+ err = SetupProxyRoute(&params, 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, nil58 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
2424 }
2525
2626 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,
3939 }
4040
41- err = CreateTraefikLabels(&params, params.TraefikEntryPoint, a)
41+ err = SetupProxyRoute(&params, params.ProxyPort, a)
4242 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)
4444 }
4545
4646 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(&params, params.TraefikEntryPoint, a)41+ err = SetupProxyRoute(&params, 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, nil46 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 @@
5050
5151 <!-- Main Content -->
5252 <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>
13158 </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>
13272 </div>
13373 </div>
134- <!-- End Install Traefik -->
74+ <!-- End Built-in Proxy -->
13575 </div>
13676 </body>
13777 </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 Traefik57+ <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 @@
2727 >
2828 </td>
2929 <td class="port-container">{{ .Ports }}</td>
30- <td class="entrypoint-container">{{.TraefikEntryPoint}}</td>
30+ <td class="entrypoint-container">{{.ProxyPort}}</td>
3131 <td class="status-container">{{.Status}} (Up since {{.UpSince}})</td>
3232 <td style="color: {{.StateColor}};">{{.State}}</td>
3333 <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 @@
5959 <input type="text" name="ports" class="w-full p-4 rounded-md" value="{{.Ports}}" />
6060 </div>
6161
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 />
6969 </div>
7070
7171 <!-- 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 labels66+ 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:
5252 domain: Domain
5353 exposed_ports: Exposed Ports
5454 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
5757 volumes: Volumes
5858 volumes_hint: "e.g. ./data:/container/data"
5959 environment_variables: Environment Variables
@@ -127,8 +127,8 @@ fr:
127127 domain: Domaine
128128 exposed_ports: Ports exposés
129129 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
132132 volumes: Volumes
133133 volumes_hint: "ex. ./data:/container/data"
134134 environment_variables: Variables d'environnement
@@ -52,8 +52,8 @@ en:
52 domain: Domain52 domain: Domain
53 exposed_ports: Exposed Ports53 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 Point55+ proxy_port: Proxy Port
56- traefik_entry_point_hint: Specify the internal container port used in Traefik's loadbalancer labels56+ proxy_port_hint: Specify the internal container port that the proxy will route traffic to
57 volumes: Volumes57 volumes: Volumes
58 volumes_hint: "e.g. ./data:/container/data"58 volumes_hint: "e.g. ./data:/container/data"
59 environment_variables: Environment Variables59 environment_variables: Environment Variables
@@ -127,8 +127,8 @@ fr:
127 domain: Domaine127 domain: Domaine
128 exposed_ports: Ports exposés128 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 Traefik130+ proxy_port: Proxy Port
131- traefik_entry_point_hint: Spécifiez le port interne du conteneur utilisé dans le label du loadbalancer de Traefik131+ proxy_port_hint: Spécifiez le port interne du conteneur que le proxy va router le trafic vers
132 volumes: Volumes132 volumes: Volumes
133 volumes_hint: "ex. ./data:/container/data"133 volumes_hint: "ex. ./data:/container/data"
134 environment_variables: Variables d'environnement134 environment_variables: Variables d'environnement
M pkg/docker/containers.go +15 -15
@@ -17,21 +17,21 @@ import (
1717 )
1818
1919 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
3535 }
3636
3737 type PortMapping struct {
@@ -17,21 +17,21 @@ import (
17 )17 )
18 18
19 type ContainerCommandParams struct {19 type ContainerCommandParams struct {
20- ContainerName string20+ ContainerName string
21- ContainerHost string21+ ContainerHost string
22- Domain string22+ Domain string
23- ServiceName string23+ ServiceName string
24- IsSSL bool24+ IsSSL bool
25- EnvVar string25+ EnvVar string
26- ImageName string26+ ImageName string
27- ImageID string27+ ImageID string
28- PortMappings []PortMapping28+ PortMappings []PortMapping
29- Volumes []string29+ Volumes []string
30- Labels []string30+ Labels []string
31- Network string31+ Network string
32- Restart string32+ Restart string
33- Environment []string33+ Environment []string
34- TraefikEntryPoint string34+ ProxyPort string // Container port to be used by the reverse proxy
35 }35 }
36 36
37 type PortMapping struct {37 type PortMapping struct {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close