added opt-in managed nginx/TLS container (ADR 0005, plan step 13): server.nginx.* config serves GitTally over HTTPS on hosts without a reverse proxy — two-phase startup (ACME webroot via certbot container, then full HTTPS config), daily certificate renewal with nginx reload, labelled container removed on shutdown; all failures are non-fatal, the plain HTTP server keeps running
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fdbbd0516a
commit
b104eeee05
@@ -32,6 +32,26 @@ server:
|
|||||||
bindAddress: 0.0.0.0
|
bindAddress: 0.0.0.0
|
||||||
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
|
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
|
||||||
impressumUrl: ""
|
impressumUrl: ""
|
||||||
|
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
|
||||||
|
# a usable reverse proxy (ADR 0005; see notes below and deployment.md).
|
||||||
|
nginx:
|
||||||
|
# manage an nginx Docker container with Let's Encrypt certificates
|
||||||
|
enabled: false
|
||||||
|
# public DNS name served by nginx and used for the certificate; required when enabled
|
||||||
|
serverName: ""
|
||||||
|
# host port published as nginx port 80 (ACME challenge + HTTPS redirect)
|
||||||
|
httpPort: 8080
|
||||||
|
# host port published as nginx port 443
|
||||||
|
httpsPort: 8443
|
||||||
|
# host nginx proxies to; empty = serverName (the container cannot reach localhost)
|
||||||
|
upstreamHost: ""
|
||||||
|
# name of the managed container; empty = gittally-nginx-<repo-name>
|
||||||
|
containerName: ""
|
||||||
|
# directory for nginx config, certificates, and logs;
|
||||||
|
# empty = $XDG_STATE_HOME (or ~/.local/state) plus /gittally/nginx/<repo-key>
|
||||||
|
stateDir: ""
|
||||||
|
# e-mail for the Let's Encrypt account; empty registers without one
|
||||||
|
letsencryptEmail: ""
|
||||||
|
|
||||||
# Gitea integration for fetching commits and posting build statuses.
|
# Gitea integration for fetching commits and posting build statuses.
|
||||||
gitea:
|
gitea:
|
||||||
@@ -127,6 +147,16 @@ branches:
|
|||||||
- "04:00"
|
- "04:00"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Notes on `server.nginx`
|
||||||
|
|
||||||
|
With `nginx.enabled`, the `server` subcommand also starts a managed nginx Docker container that serves GitTally over HTTPS (ADR 0005).
|
||||||
|
This is meant for hosts that provide Docker but no usable reverse proxy (e.g. Hostsharing managed containers); otherwise prefer the reverse-proxy setup in [deployment.md](deployment.md).
|
||||||
|
Certificates are obtained and renewed via Let's Encrypt (certbot Docker container, webroot mode), so `serverName` must be a public DNS name pointing at the host and `httpPort` must be reachable from the internet as port 80 (or via a port forward).
|
||||||
|
When `server.publicBaseUrl` is empty and `serverName` is set, it defaults to `https://<serverName>/`.
|
||||||
|
All nginx/certificate failures are non-fatal warnings; the plain HTTP server keeps running without the proxy.
|
||||||
|
The container is labelled `org.hoennig.gittally`; stale nginx containers of the repository are removed before each start, and the container is removed on shutdown.
|
||||||
|
`server.port` must differ from `httpPort` and `httpsPort`.
|
||||||
|
|
||||||
### Notes on `branches.<name>.requirePullRequest`
|
### Notes on `branches.<name>.requirePullRequest`
|
||||||
|
|
||||||
The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds).
|
The gate applies to all watcher-triggered builds (push-triggered and scheduled auto builds).
|
||||||
|
|||||||
+31
-2
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
This document describes how to run GitTally as a permanent service.
|
This document describes how to run GitTally as a permanent service.
|
||||||
The recommended setup is a systemd user service behind an existing reverse proxy.
|
The recommended setup is a systemd user service behind an existing reverse proxy.
|
||||||
GitTally does not manage nginx or TLS certificates itself (unlike the legacy script); it relies on the host's existing web server and certbot.
|
By default GitTally does not manage nginx or TLS certificates itself; it relies on the host's existing web server and certbot.
|
||||||
|
For hosts without one, an opt-in managed nginx/TLS container is available, see [Hosts Without a Reverse Proxy](#hosts-without-a-reverse-proxy-managed-nginxtls).
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@@ -122,4 +123,32 @@ sudo certbot --nginx -d ci.example.org
|
|||||||
```
|
```
|
||||||
|
|
||||||
This replaces the legacy script's managed nginx/Let's Encrypt Docker container for hosts that have their own web server.
|
This replaces the legacy script's managed nginx/Let's Encrypt Docker container for hosts that have their own web server.
|
||||||
For hosts without a usable reverse proxy (e.g. Hostsharing managed containers), an opt-in managed nginx/TLS container is planned (see `docs/plan/13-nginx-tls.md`, ADR 0005).
|
|
||||||
|
## Hosts Without a Reverse Proxy (Managed nginx/TLS)
|
||||||
|
|
||||||
|
Some hosts provide Docker but no root access and no host web server, e.g. Hostsharing managed container environments.
|
||||||
|
For these, GitTally can manage its own nginx+certbot Docker container (ADR 0005).
|
||||||
|
This is opt-in; where a host web server exists, prefer the reverse-proxy setup above.
|
||||||
|
|
||||||
|
Enable it in the server section of the configuration:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
port: 18080
|
||||||
|
nginx:
|
||||||
|
enabled: true
|
||||||
|
serverName: ci.example.org
|
||||||
|
httpPort: 8080
|
||||||
|
httpsPort: 8443
|
||||||
|
letsencryptEmail: admin@example.org
|
||||||
|
```
|
||||||
|
|
||||||
|
On server start, GitTally writes the nginx configuration, starts a labelled nginx container publishing `httpPort` and `httpsPort`, obtains a Let's Encrypt certificate via a certbot container (webroot mode), and restarts nginx with the full HTTPS configuration.
|
||||||
|
A renewal check runs daily; certificates and nginx state persist in `server.nginx.stateDir` across restarts.
|
||||||
|
On shutdown the container is removed.
|
||||||
|
All nginx and certificate failures are non-fatal warnings — the plain HTTP server keeps running without the proxy.
|
||||||
|
|
||||||
|
`serverName` must be a public DNS name pointing at the host, reachable from the internet on port 80/443 (directly or via a port forward to `httpPort`/`httpsPort`), otherwise the ACME challenge fails.
|
||||||
|
The nginx container cannot reach `localhost` of the host, so the proxy upstream defaults to `serverName`; set `server.nginx.upstreamHost` if the host is reachable under a different name from inside containers.
|
||||||
|
With the managed nginx, keep `server.bindAddress: 0.0.0.0` (or an address reachable from the Docker network) — binding GitTally to `127.0.0.1` would make it unreachable for the proxy.
|
||||||
|
See [configuration.md](configuration.md) for all `server.nginx.*` keys.
|
||||||
|
|||||||
@@ -37,13 +37,18 @@ Branch-level keys below live under `branches.<name>`; use `branches.default` for
|
|||||||
| `GITTALLY_GITEA_STATUS_CONTEXT` | `gitea.statusContext` |
|
| `GITTALLY_GITEA_STATUS_CONTEXT` | `gitea.statusContext` |
|
||||||
| `GITTALLY_GITEA_GIT_USERNAME` | `git.account` — in `.git/gittally/.gittally.yml` |
|
| `GITTALLY_GITEA_GIT_USERNAME` | `git.account` — in `.git/gittally/.gittally.yml` |
|
||||||
| `GITTALLY_GITEA_TOKEN` | `git.token` — in `.git/gittally/.gittally.yml`, never committed |
|
| `GITTALLY_GITEA_TOKEN` | `git.token` — in `.git/gittally/.gittally.yml`, never committed |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_SERVER_NAME` | `server.nginx.serverName` — also set `server.nginx.enabled: true` (replaces the `--nginx` flag) |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_HTTP_PORT` | `server.nginx.httpPort` |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_HTTPS_PORT` | `server.nginx.httpsPort` |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST` | `server.nginx.upstreamHost` |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME` | `server.nginx.containerName` |
|
||||||
|
| `GITTALLY_ARTIFACT_NGINX_STATE_DIR` | `server.nginx.stateDir` |
|
||||||
|
| `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` | `server.nginx.letsencryptEmail` |
|
||||||
|
|
||||||
New keys without a legacy counterpart: `builds.maxConcurrent`, `artifacts.rootDir`, and `watcher.pollInterval`.
|
New keys without a legacy counterpart: `builds.maxConcurrent`, `artifacts.rootDir`, and `watcher.pollInterval`.
|
||||||
|
|
||||||
## Intentionally Not Ported
|
## Intentionally Not Ported
|
||||||
|
|
||||||
- Managed nginx/Let's Encrypt container (`GITTALLY_ARTIFACT_NGINX_*`, `GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL`) — not ported yet, but planned as an opt-in feature for hosts without a reverse proxy (see `docs/plan/13-nginx-tls.md`).
|
|
||||||
Until then, use the host's reverse proxy, see [deployment.md](deployment.md).
|
|
||||||
- Self-install and self-update (`--install`, `--pull`, `GITTALLY_INSTALL_DIR`) — replaced by jar deployment plus `init --systemd`.
|
- Self-install and self-update (`--install`, `--pull`, `GITTALLY_INSTALL_DIR`) — replaced by jar deployment plus `init --systemd`.
|
||||||
- `GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND` and `GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — hsadmin-ng-specific; use `branches.<name>.docker.env` if needed.
|
- `GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND` and `GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS` — hsadmin-ng-specific; use `branches.<name>.docker.env` if needed.
|
||||||
- `HSADMIN_NG_*` environment-variable fallbacks.
|
- `HSADMIN_NG_*` environment-variable fallbacks.
|
||||||
|
|||||||
@@ -39,3 +39,25 @@ Port the legacy nginx subsystem (functions `configure_artifact_nginx_defaults` ~
|
|||||||
- Manual walkthrough on a Docker host: nginx container starts with the init config and proxies HTTP to GitTally.
|
- Manual walkthrough on a Docker host: nginx container starts with the init config and proxies HTTP to GitTally.
|
||||||
Full ACME issuance needs a public DNS name; if none is available, verify the certbot argv and the full-config path against the legacy script and document that in this file.
|
Full ACME issuance needs a public DNS name; if none is available, verify the certbot argv and the full-config path against the legacy script and document that in this file.
|
||||||
- `docs/deployment.md` gains a section for hosts without a reverse proxy; `docs/migration-from-legacy.md` maps the `GITTALLY_ARTIFACT_NGINX_*`/`GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` variables.
|
- `docs/deployment.md` gains a section for hosts without a reverse proxy; `docs/migration-from-legacy.md` maps the `GITTALLY_ARTIFACT_NGINX_*`/`GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL` variables.
|
||||||
|
|
||||||
|
## Result (2026-07-08)
|
||||||
|
|
||||||
|
Implemented as `NginxConfigFiles` (config generation), `NginxProxyManager` (docker orchestration), and `ServerNginxLifecycle` (server-profile startup, daily renewal, shutdown cleanup).
|
||||||
|
|
||||||
|
Deviations from the design above and from legacy:
|
||||||
|
|
||||||
|
- Renewal reloads nginx via `docker exec <container> nginx -s reload` after `certbot renew` instead of restarting the container (no dropped connections).
|
||||||
|
- Legacy auto-moved the artifact server port on a collision with the nginx ports; the rewrite refuses to start the proxy with a warning instead — the Spring port cannot move after startup.
|
||||||
|
- Legacy derived a missing `serverName` from the public base URL host; the rewrite requires `serverName` explicitly (the config default direction is only publicBaseUrl ← serverName).
|
||||||
|
- `serverName` and `upstreamHost` are validated against a host-name pattern instead of substituting raw values, so no nginx directives can be injected via config.
|
||||||
|
- The container label namespace is `org.hoennig.gittally` (like the Docker build runner), not `org.hostsharing.gittally`; port cleanup still also matches legacy-named containers.
|
||||||
|
- The legacy `--nginx` CLI flag is not ported; enablement is `server.nginx.enabled` only.
|
||||||
|
- `ssl-dhparams.pem` is downloaded via the Java HTTP client instead of `curl` (replaceable seam for tests).
|
||||||
|
|
||||||
|
Manual walkthrough (dev machine, Rancher Desktop Docker, no public DNS name — so no real ACME issuance):
|
||||||
|
|
||||||
|
- Fresh state dir: nginx container started with the init config; port 80 served the ACME challenge location and answered `301 https://<serverName>/...`; `certonly` then failed as expected without public DNS and the HTTP server kept running (non-fatal path).
|
||||||
|
- Pre-seeded certificate (self-signed): manager took the full-config path, `certbot renew` ran (exit 0), and HTTPS end-to-end worked — `/api/branches` JSON served through the TLS proxy with the configured no-cache headers.
|
||||||
|
- `SIGTERM` removed the container (lifecycle `@PreDestroy`).
|
||||||
|
- The certbot `certonly`/`renew` argv and both config modes are asserted verbatim against the legacy script in `NginxProxyManagerTest`/`NginxConfigFilesTest`.
|
||||||
|
- Environment note: the nginx container cannot reach the host's `localhost`; on the walkthrough machine the upstream had to be `host.docker.internal` (documented in `deployment.md` as `upstreamHost`).
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ Completion:
|
|||||||
|
|
||||||
Added after the initial plan (ADR 0005):
|
Added after the initial plan (ADR 0005):
|
||||||
|
|
||||||
- [ ] `13-nginx-tls.md` — opt-in managed nginx/TLS container for hosts without a reverse proxy
|
- [x] `13-nginx-tls.md` — opt-in managed nginx/TLS container for hosts without a reverse proxy
|
||||||
|
|
||||||
Steps 01–03 are independent of each other.
|
Steps 01–03 are independent of each other.
|
||||||
Steps 04–06 depend on 01–03.
|
Steps 04–06 depend on 01–03.
|
||||||
|
|||||||
@@ -126,6 +126,17 @@ class InitCommand(
|
|||||||
bindAddress: 0.0.0.0
|
bindAddress: 0.0.0.0
|
||||||
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
|
# optional Impressum (legal disclosure) link in the web UI footer; empty hides the link
|
||||||
impressumUrl: ""
|
impressumUrl: ""
|
||||||
|
# Opt-in managed nginx+certbot Docker container for HTTPS, for hosts without
|
||||||
|
# a usable reverse proxy (see docs/deployment.md). Off by default.
|
||||||
|
nginx:
|
||||||
|
enabled: false # manage an nginx container with Let's Encrypt certificates
|
||||||
|
serverName: "" # public DNS name served by nginx; required when enabled
|
||||||
|
httpPort: 8080 # host port published as nginx port 80
|
||||||
|
httpsPort: 8443 # host port published as nginx port 443
|
||||||
|
upstreamHost: "" # host nginx proxies to; empty = serverName
|
||||||
|
containerName: "" # empty = gittally-nginx-<repo-name>
|
||||||
|
stateDir: "" # empty = XDG_STATE_HOME (or ~/.local/state) + /gittally/nginx/<repo-key>
|
||||||
|
letsencryptEmail: "" # e-mail for the Let's Encrypt account; empty registers without one
|
||||||
|
|
||||||
# Gitea integration for fetching commits and posting build statuses.
|
# Gitea integration for fetching commits and posting build statuses.
|
||||||
gitea:
|
gitea:
|
||||||
|
|||||||
@@ -21,11 +21,24 @@ class ConfigLoader {
|
|||||||
|
|
||||||
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig {
|
fun load(workingDir: Path = Paths.get(".")): GitTallyConfig {
|
||||||
val raw = loadRaw(workingDir)
|
val raw = loadRaw(workingDir)
|
||||||
return if (raw.isEmpty()) {
|
val config =
|
||||||
|
if (raw.isEmpty()) {
|
||||||
GitTallyConfig()
|
GitTallyConfig()
|
||||||
} else {
|
} else {
|
||||||
yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java)
|
yaml.convertValue(mergeBranchDefaults(raw), GitTallyConfig::class.java)
|
||||||
}
|
}
|
||||||
|
return defaultPublicBaseUrl(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy default: an empty `server.publicBaseUrl` becomes `https://<nginx.serverName>/`. */
|
||||||
|
private fun defaultPublicBaseUrl(config: GitTallyConfig): GitTallyConfig {
|
||||||
|
if (config.server.publicBaseUrl.isNotBlank() ||
|
||||||
|
config.server.nginx.serverName
|
||||||
|
.isBlank()
|
||||||
|
) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
return config.copy(server = config.server.copy(publicBaseUrl = "https://${config.server.nginx.serverName}/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
|
fun loadRaw(workingDir: Path = Paths.get(".")): Map<String, Any?> {
|
||||||
|
|||||||
@@ -11,12 +11,44 @@ data class GitTallyConfig(
|
|||||||
)
|
)
|
||||||
|
|
||||||
data class ServerConfig(
|
data class ServerConfig(
|
||||||
|
/**
|
||||||
|
* Public base URL of this installation; empty defaults to `https://<nginx.serverName>/`
|
||||||
|
* when [NginxConfig.serverName] is set (applied by [ConfigLoader]).
|
||||||
|
*/
|
||||||
val publicBaseUrl: String = "",
|
val publicBaseUrl: String = "",
|
||||||
/** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */
|
/** HTTP port of the `server` subcommand; 18080 like the legacy artifact server. */
|
||||||
val port: Int = 18080,
|
val port: Int = 18080,
|
||||||
val bindAddress: String = "0.0.0.0",
|
val bindAddress: String = "0.0.0.0",
|
||||||
/** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */
|
/** Optional Impressum (legal disclosure) link shown in the web UI footer; empty hides the link. */
|
||||||
val impressumUrl: String = "",
|
val impressumUrl: String = "",
|
||||||
|
val nginx: NginxConfig = NginxConfig(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opt-in managed nginx+certbot Docker container serving GitTally over HTTPS,
|
||||||
|
* for hosts without a usable reverse proxy (ADR 0005). Off by default; the
|
||||||
|
* reverse-proxy deployment from `docs/deployment.md` stays the recommended setup.
|
||||||
|
*/
|
||||||
|
data class NginxConfig(
|
||||||
|
/** Manage an nginx Docker container with Let's Encrypt certificates. */
|
||||||
|
val enabled: Boolean = false,
|
||||||
|
/** Public DNS name served by nginx and used for the certificate; required when [enabled]. */
|
||||||
|
val serverName: String = "",
|
||||||
|
/** Host port published as nginx port 80 (ACME challenge + HTTPS redirect). */
|
||||||
|
val httpPort: Int = 8080,
|
||||||
|
/** Host port published as nginx port 443. */
|
||||||
|
val httpsPort: Int = 8443,
|
||||||
|
/** Host nginx proxies to; empty uses [serverName] (the container cannot reach `localhost`). */
|
||||||
|
val upstreamHost: String = "",
|
||||||
|
/** Name of the managed container; empty means `gittally-nginx-<repo-name>`. */
|
||||||
|
val containerName: String = "",
|
||||||
|
/**
|
||||||
|
* Directory for nginx config, certificates, and logs; empty means the platform
|
||||||
|
* default `XDG_STATE_HOME` (or `~/.local/state`) + `/gittally/nginx/<repo-key>`.
|
||||||
|
*/
|
||||||
|
val stateDir: String = "",
|
||||||
|
/** E-mail for the Let's Encrypt account; empty registers without one. */
|
||||||
|
val letsencryptEmail: String = "",
|
||||||
)
|
)
|
||||||
|
|
||||||
data class GitConfig(
|
data class GitConfig(
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the nginx configuration for the managed proxy container, ported from
|
||||||
|
* the legacy `artifact_nginx_write_config`/`artifact_nginx_write_ssl_options`.
|
||||||
|
* Values are substituted verbatim — callers must validate the server name and
|
||||||
|
* upstream host (see [NginxProxyManager.HOST_NAME_PATTERN]) so no nginx directives
|
||||||
|
* can be injected.
|
||||||
|
*/
|
||||||
|
object NginxConfigFiles {
|
||||||
|
/**
|
||||||
|
* The `nginx.conf` content. Without [full] it is the init config for the
|
||||||
|
* two-phase startup: HTTP only, serving the ACME webroot challenge and
|
||||||
|
* redirecting everything else to HTTPS. With [full] an HTTPS server block
|
||||||
|
* with the Let's Encrypt certificate and the proxy to GitTally is added.
|
||||||
|
*/
|
||||||
|
fun nginxConf(
|
||||||
|
serverName: String,
|
||||||
|
upstreamHost: String,
|
||||||
|
upstreamPort: Int,
|
||||||
|
full: Boolean,
|
||||||
|
): String {
|
||||||
|
val httpServer =
|
||||||
|
"""
|
||||||
|
| server {
|
||||||
|
| listen 80;
|
||||||
|
| server_name $serverName;
|
||||||
|
|
|
||||||
|
| location /.well-known/acme-challenge/ {
|
||||||
|
| root /var/www/certbot;
|
||||||
|
| }
|
||||||
|
|
|
||||||
|
| location / {
|
||||||
|
| return 301 https://${'$'}host${'$'}request_uri;
|
||||||
|
| }
|
||||||
|
| }
|
||||||
|
""".trimMargin()
|
||||||
|
val httpsServer =
|
||||||
|
"""
|
||||||
|
|
|
||||||
|
| server {
|
||||||
|
| listen 443 ssl;
|
||||||
|
| server_name $serverName;
|
||||||
|
|
|
||||||
|
| ssl_certificate /etc/letsencrypt/live/$serverName/fullchain.pem;
|
||||||
|
| ssl_certificate_key /etc/letsencrypt/live/$serverName/privkey.pem;
|
||||||
|
| include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
|
|
||||||
|
| location /.well-known/acme-challenge/ {
|
||||||
|
| root /var/www/certbot;
|
||||||
|
| }
|
||||||
|
|
|
||||||
|
| location / {
|
||||||
|
| proxy_pass http://$upstreamHost:$upstreamPort;
|
||||||
|
| proxy_set_header Host ${'$'}host;
|
||||||
|
| proxy_set_header X-Real-IP ${'$'}remote_addr;
|
||||||
|
| proxy_set_header X-Forwarded-For ${'$'}proxy_add_x_forwarded_for;
|
||||||
|
| proxy_set_header X-Forwarded-Proto ${'$'}scheme;
|
||||||
|
| add_header Cache-Control "no-store, max-age=0" always;
|
||||||
|
| add_header Pragma "no-cache" always;
|
||||||
|
| add_header Expires "0" always;
|
||||||
|
| }
|
||||||
|
| }
|
||||||
|
""".trimMargin()
|
||||||
|
return "events {}\n\nhttp {\n" + httpServer + (if (full) httpsServer else "") + "\n}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The certbot-recommended `options-ssl-nginx.conf`, verbatim from legacy. */
|
||||||
|
const val SSL_OPTIONS: String =
|
||||||
|
"ssl_session_cache shared:le_nginx_SSL:1m;\n" +
|
||||||
|
"ssl_session_timeout 1440m;\n" +
|
||||||
|
"ssl_protocols TLSv1.2 TLSv1.3;\n" +
|
||||||
|
"ssl_prefer_server_ciphers off;\n" +
|
||||||
|
"\n" +
|
||||||
|
"ssl_ciphers \"ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" +
|
||||||
|
"ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" +
|
||||||
|
"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" +
|
||||||
|
"DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384\";\n" +
|
||||||
|
"ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;\n"
|
||||||
|
|
||||||
|
/** Certbot's pinned DH parameters, downloaded once into the state dir (like legacy). */
|
||||||
|
const val DH_PARAMS_URL: String = "https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem"
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
import de.hoennig.gittally.build.ArtifactKeys
|
||||||
|
import de.hoennig.gittally.build.DockerBuildRunner.Companion.GITTALLY_LABEL
|
||||||
|
import de.hoennig.gittally.config.ConfigLoader
|
||||||
|
import de.hoennig.gittally.git.GitCommandRunner
|
||||||
|
import org.slf4j.LoggerFactory
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.http.HttpClient
|
||||||
|
import java.net.http.HttpRequest
|
||||||
|
import java.net.http.HttpResponse
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.time.Duration
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages the opt-in nginx+certbot Docker container that serves GitTally over
|
||||||
|
* HTTPS on hosts without a reverse proxy (ADR 0005), ported from the legacy
|
||||||
|
* `start_artifact_nginx` subsystem. Shells out to the `docker` CLI via the
|
||||||
|
* generic [GitCommandRunner] process wrapper, like [de.hoennig.gittally.build.DockerBuildRunner].
|
||||||
|
*
|
||||||
|
* Startup is two-phase: an HTTP-only init config serves the ACME webroot
|
||||||
|
* challenge, the certificate is obtained via a certbot container, then nginx is
|
||||||
|
* restarted with the full HTTPS config. All failures are non-fatal warnings —
|
||||||
|
* the plain HTTP server keeps running without the proxy (legacy behavior).
|
||||||
|
* Nothing runs unless [start] is called (server profile only, see [ServerNginxLifecycle]).
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
class NginxProxyManager(
|
||||||
|
private val commandRunner: GitCommandRunner,
|
||||||
|
private val configLoader: ConfigLoader,
|
||||||
|
) {
|
||||||
|
private val log = LoggerFactory.getLogger(NginxProxyManager::class.java)
|
||||||
|
|
||||||
|
var workingDir: Path = Paths.get(".")
|
||||||
|
|
||||||
|
/** Replaceable for tests: fetches certbot's pinned DH parameters into [target]; throws on failure. */
|
||||||
|
internal var dhParamsDownloader: (target: Path) -> Unit = ::downloadDhParams
|
||||||
|
|
||||||
|
/** Replaceable for tests: the wait between port-conflict re-checks. */
|
||||||
|
internal var sleeper: (millis: Long) -> Unit = Thread::sleep
|
||||||
|
|
||||||
|
/** The settings the running container was started with; null while no container is managed. */
|
||||||
|
private var running: NginxSettings? = null
|
||||||
|
|
||||||
|
fun isEnabled(): Boolean =
|
||||||
|
configLoader
|
||||||
|
.load(workingDir)
|
||||||
|
.server.nginx.enabled
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy `start_artifact_nginx`: prepare the state dir, remove stale containers,
|
||||||
|
* run nginx (init config first when no certificate exists yet), obtain or renew
|
||||||
|
* the certificate, then restart with the full HTTPS config.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun start() {
|
||||||
|
try {
|
||||||
|
val settings = resolveSettings() ?: return
|
||||||
|
prepareStateDirs(settings)
|
||||||
|
writeSslOptions(settings)
|
||||||
|
cleanupStaleContainers(settings)
|
||||||
|
if (!waitForPortsFree(settings)) {
|
||||||
|
log.warn("managed nginx was not started because a configured port is still in use")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNginxConf(settings, full = Files.exists(settings.certFile))
|
||||||
|
if (!runContainer(settings)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!obtainOrRenewCertificate(settings)) {
|
||||||
|
log.warn("managed nginx is running, but Let's Encrypt certificate setup failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeNginxConf(settings, full = true)
|
||||||
|
if (!runContainer(settings)) {
|
||||||
|
log.warn("certificate is available, but restarting managed nginx with HTTPS failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.info(
|
||||||
|
"managed nginx proxy: https://{}:{}/ -> http://{}:{}/ (state: {})",
|
||||||
|
settings.serverName,
|
||||||
|
settings.httpsPort,
|
||||||
|
settings.upstreamHost,
|
||||||
|
settings.upstreamPort,
|
||||||
|
settings.stateDir,
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("could not start managed nginx: {}", e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renews the certificate and reloads nginx; scheduled daily by [ServerNginxLifecycle].
|
||||||
|
* Improvement over legacy, which renewed only at process start and relied on
|
||||||
|
* frequent self-update restarts. No-op while no container is managed.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun renewCertificateAndReload() {
|
||||||
|
val settings = running ?: return
|
||||||
|
try {
|
||||||
|
val renew = commandRunner.run(certbotArgs(settings) + listOf("renew", "-q"), workingDir)
|
||||||
|
if (!renew.isSuccess) {
|
||||||
|
log.warn("certificate renewal failed: {}", renew.stderr.trim())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val reload = commandRunner.run(listOf("docker", "exec", settings.containerName, "nginx", "-s", "reload"), workingDir)
|
||||||
|
if (!reload.isSuccess) {
|
||||||
|
log.warn("certificate renewed, but nginx reload failed: {}", reload.stderr.trim())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("certificate renewal check failed: {}", e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes the managed container (legacy shutdown cleanup); safe to call when none runs. */
|
||||||
|
@Synchronized
|
||||||
|
fun stop() {
|
||||||
|
val settings = running ?: return
|
||||||
|
running = null
|
||||||
|
try {
|
||||||
|
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
log.warn("could not remove managed nginx container {}: {}", settings.containerName, e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy `configure_artifact_nginx_defaults` plus validation: resolves the
|
||||||
|
* effective settings or returns null (with a warning) when misconfigured.
|
||||||
|
* Host names are restricted to [HOST_NAME_PATTERN] so they substitute safely
|
||||||
|
* into the nginx config.
|
||||||
|
*/
|
||||||
|
internal fun resolveSettings(): NginxSettings? {
|
||||||
|
val config = configLoader.load(workingDir)
|
||||||
|
val nginx = config.server.nginx
|
||||||
|
if (!nginx.enabled) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (nginx.serverName.isBlank()) {
|
||||||
|
log.warn("cannot start managed nginx because server.nginx.serverName is empty")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val upstreamHost = nginx.upstreamHost.ifBlank { nginx.serverName }
|
||||||
|
if (!HOST_NAME_PATTERN.matches(nginx.serverName) || !HOST_NAME_PATTERN.matches(upstreamHost)) {
|
||||||
|
log.warn("cannot start managed nginx because serverName or upstreamHost is not a valid host name")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (nginx.httpPort !in 1..65535 || nginx.httpsPort !in 1..65535 || nginx.httpPort == nginx.httpsPort) {
|
||||||
|
log.warn("cannot start managed nginx because the nginx ports are invalid")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (config.server.port == nginx.httpPort || config.server.port == nginx.httpsPort) {
|
||||||
|
log.warn("cannot start managed nginx because server.port {} collides with an nginx port", config.server.port)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
val repoDir = workingDir.toAbsolutePath().normalize()
|
||||||
|
val stateDir =
|
||||||
|
if (nginx.stateDir.isNotBlank()) {
|
||||||
|
repoDir.resolve(expandHome(nginx.stateDir)).normalize()
|
||||||
|
} else {
|
||||||
|
defaultStateDir(repoDir)
|
||||||
|
}
|
||||||
|
return NginxSettings(
|
||||||
|
serverName = nginx.serverName,
|
||||||
|
httpPort = nginx.httpPort,
|
||||||
|
httpsPort = nginx.httpsPort,
|
||||||
|
upstreamHost = upstreamHost,
|
||||||
|
upstreamPort = config.server.port,
|
||||||
|
containerName = nginx.containerName.ifBlank { defaultContainerName(repoDir) },
|
||||||
|
stateDir = stateDir,
|
||||||
|
letsencryptEmail = nginx.letsencryptEmail,
|
||||||
|
repoKey = ArtifactKeys.repoKey(repoDir),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun prepareStateDirs(settings: NginxSettings) {
|
||||||
|
Files.createDirectories(settings.certbotConf)
|
||||||
|
Files.createDirectories(settings.certbotWww)
|
||||||
|
Files.createDirectories(settings.certbotLog)
|
||||||
|
Files.createDirectories(settings.nginxLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy `artifact_nginx_write_ssl_options`: the certbot nginx snippet plus its pinned DH parameters. */
|
||||||
|
private fun writeSslOptions(settings: NginxSettings) {
|
||||||
|
Files.writeString(settings.certbotConf.resolve("options-ssl-nginx.conf"), NginxConfigFiles.SSL_OPTIONS)
|
||||||
|
val dhParams = settings.certbotConf.resolve("ssl-dhparams.pem")
|
||||||
|
if (!Files.exists(dhParams)) {
|
||||||
|
dhParamsDownloader(dhParams)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeNginxConf(
|
||||||
|
settings: NginxSettings,
|
||||||
|
full: Boolean,
|
||||||
|
) {
|
||||||
|
Files.writeString(
|
||||||
|
settings.nginxConf,
|
||||||
|
NginxConfigFiles.nginxConf(settings.serverName, settings.upstreamHost, settings.upstreamPort, full),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy `cleanup_stale_artifact_nginx_containers`: remove the container by
|
||||||
|
* name, all nginx-role containers of this repository by label, and any
|
||||||
|
* GitTally container still occupying the configured ports.
|
||||||
|
*/
|
||||||
|
private fun cleanupStaleContainers(settings: NginxSettings) {
|
||||||
|
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
|
||||||
|
val labelled =
|
||||||
|
commandRunner.run(
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-aq",
|
||||||
|
"--filter",
|
||||||
|
"label=$GITTALLY_LABEL=true",
|
||||||
|
"--filter",
|
||||||
|
"label=$GITTALLY_LABEL.repository=${settings.repoKey}",
|
||||||
|
"--filter",
|
||||||
|
"label=$GITTALLY_LABEL.role=nginx",
|
||||||
|
),
|
||||||
|
workingDir,
|
||||||
|
)
|
||||||
|
if (labelled.isSuccess && labelled.lines().isNotEmpty()) {
|
||||||
|
commandRunner.run(listOf("docker", "rm", "-f") + labelled.lines(), workingDir)
|
||||||
|
}
|
||||||
|
for (container in listContainersUsingPorts(settings)) {
|
||||||
|
if (container.labels.contains("$GITTALLY_LABEL=true") ||
|
||||||
|
container.name.startsWith("gittally-") ||
|
||||||
|
container.name.startsWith("git-watch-origin-and-test-nginx-")
|
||||||
|
) {
|
||||||
|
log.info("removing stale GitTally container using an nginx port: {}", container.name)
|
||||||
|
commandRunner.run(listOf("docker", "rm", "-f", container.id), workingDir)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy `wait_for_artifact_nginx_ports`: re-check up to four times, warning about foreign owners. */
|
||||||
|
private fun waitForPortsFree(settings: NginxSettings): Boolean {
|
||||||
|
repeat(4) {
|
||||||
|
val owners = listContainersUsingPorts(settings)
|
||||||
|
if (owners.isEmpty()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
owners.forEach { log.warn("nginx port is already used by Docker container {} ({})", it.name, it.id) }
|
||||||
|
sleeper(1000)
|
||||||
|
}
|
||||||
|
return listContainersUsingPorts(settings).isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ContainerInfo(
|
||||||
|
val id: String,
|
||||||
|
val name: String,
|
||||||
|
val ports: String,
|
||||||
|
val labels: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun listContainersUsingPorts(settings: NginxSettings): List<ContainerInfo> {
|
||||||
|
val listing =
|
||||||
|
commandRunner.run(
|
||||||
|
listOf("docker", "ps", "--format", "{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}"),
|
||||||
|
workingDir,
|
||||||
|
)
|
||||||
|
if (!listing.isSuccess) {
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
return listing
|
||||||
|
.lines()
|
||||||
|
.mapNotNull { line ->
|
||||||
|
val fields = line.split('\t')
|
||||||
|
if (fields.size < 3) null else ContainerInfo(fields[0], fields[1], fields[2], fields.getOrElse(3) { "" })
|
||||||
|
}.filter { container ->
|
||||||
|
listOf(settings.httpPort, settings.httpsPort).any { container.ports.contains(":$it->") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy `artifact_nginx_run_container`; replaces any previous instance of the container. */
|
||||||
|
private fun runContainer(settings: NginxSettings): Boolean {
|
||||||
|
commandRunner.run(listOf("docker", "rm", "-f", settings.containerName), workingDir)
|
||||||
|
val run = commandRunner.run(runContainerArgs(settings), workingDir)
|
||||||
|
if (!run.isSuccess) {
|
||||||
|
log.warn("could not start managed nginx container {}: {}", settings.containerName, run.stderr.trim())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
running = settings
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun runContainerArgs(settings: NginxSettings): List<String> =
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
settings.containerName,
|
||||||
|
"--publish",
|
||||||
|
"${settings.httpPort}:80",
|
||||||
|
"--publish",
|
||||||
|
"${settings.httpsPort}:443",
|
||||||
|
"--network",
|
||||||
|
"bridge",
|
||||||
|
"--volume",
|
||||||
|
"${settings.certbotConf}:/etc/letsencrypt",
|
||||||
|
"--volume",
|
||||||
|
"${settings.certbotWww}:/var/www/certbot",
|
||||||
|
"--volume",
|
||||||
|
"${settings.nginxLog}:/var/log/nginx",
|
||||||
|
"--volume",
|
||||||
|
"${settings.nginxConf}:/etc/nginx/nginx.conf:ro",
|
||||||
|
"--label",
|
||||||
|
"$GITTALLY_LABEL=true",
|
||||||
|
"--label",
|
||||||
|
"$GITTALLY_LABEL.repository=${settings.repoKey}",
|
||||||
|
"--label",
|
||||||
|
"$GITTALLY_LABEL.role=nginx",
|
||||||
|
"nginx",
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy `artifact_nginx_obtain_or_renew_certificate`: `certonly` in webroot
|
||||||
|
* mode for a first certificate, plain `renew` when one already exists (nginx
|
||||||
|
* is already running and serves the challenge directory).
|
||||||
|
*/
|
||||||
|
private fun obtainOrRenewCertificate(settings: NginxSettings): Boolean {
|
||||||
|
val args =
|
||||||
|
if (Files.exists(settings.certFile)) {
|
||||||
|
certbotArgs(settings) + listOf("renew", "-q")
|
||||||
|
} else {
|
||||||
|
obtainCertificateArgs(settings)
|
||||||
|
}
|
||||||
|
val result = commandRunner.run(args, workingDir)
|
||||||
|
if (!result.isSuccess) {
|
||||||
|
log.warn("certbot failed: {}", result.stderr.trim())
|
||||||
|
}
|
||||||
|
return result.isSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun obtainCertificateArgs(settings: NginxSettings): List<String> {
|
||||||
|
val emailArgs =
|
||||||
|
if (settings.letsencryptEmail.isNotBlank()) {
|
||||||
|
listOf("--email", settings.letsencryptEmail)
|
||||||
|
} else {
|
||||||
|
listOf("--register-unsafely-without-email")
|
||||||
|
}
|
||||||
|
return certbotArgs(settings) +
|
||||||
|
listOf(
|
||||||
|
"certonly",
|
||||||
|
"--webroot",
|
||||||
|
"--webroot-path",
|
||||||
|
"/var/www/certbot",
|
||||||
|
"--cert-name",
|
||||||
|
settings.serverName,
|
||||||
|
"-d",
|
||||||
|
settings.serverName,
|
||||||
|
"--rsa-key-size",
|
||||||
|
"4096",
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
) + emailArgs
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun certbotArgs(settings: NginxSettings): List<String> =
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--volume",
|
||||||
|
"${settings.certbotConf}:/etc/letsencrypt",
|
||||||
|
"--volume",
|
||||||
|
"${settings.certbotWww}:/var/www/certbot",
|
||||||
|
"--volume",
|
||||||
|
"${settings.certbotLog}:/var/log/letsencrypt",
|
||||||
|
"certbot/certbot",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun expandHome(path: String): Path =
|
||||||
|
if (path == "~" || path.startsWith("~/")) {
|
||||||
|
Paths.get(System.getProperty("user.home"), path.removePrefix("~"))
|
||||||
|
} else {
|
||||||
|
Paths.get(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The effective managed-nginx settings with all defaults resolved. */
|
||||||
|
internal data class NginxSettings(
|
||||||
|
val serverName: String,
|
||||||
|
val httpPort: Int,
|
||||||
|
val httpsPort: Int,
|
||||||
|
val upstreamHost: String,
|
||||||
|
val upstreamPort: Int,
|
||||||
|
val containerName: String,
|
||||||
|
val stateDir: Path,
|
||||||
|
val letsencryptEmail: String,
|
||||||
|
val repoKey: String,
|
||||||
|
) {
|
||||||
|
val certbotConf: Path get() = stateDir.resolve("certbot/conf")
|
||||||
|
val certbotWww: Path get() = stateDir.resolve("certbot/www")
|
||||||
|
val certbotLog: Path get() = stateDir.resolve("certbot/log")
|
||||||
|
val nginxLog: Path get() = stateDir.resolve("nginx/log")
|
||||||
|
val nginxConf: Path get() = stateDir.resolve("nginx/nginx.conf")
|
||||||
|
val certFile: Path get() = certbotConf.resolve("live/$serverName/fullchain.pem")
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Host names substitute unescaped into the nginx config, so only safe characters are allowed. */
|
||||||
|
internal val HOST_NAME_PATTERN = Regex("[A-Za-z0-9][A-Za-z0-9.-]*")
|
||||||
|
|
||||||
|
/** Legacy `artifact_nginx_default_state_dir`. */
|
||||||
|
fun defaultStateDir(repoDir: Path): Path {
|
||||||
|
val stateHome =
|
||||||
|
System.getenv("XDG_STATE_HOME")?.takeIf { it.isNotBlank() }?.let { Paths.get(it) }
|
||||||
|
?: Paths.get(System.getProperty("user.home"), ".local", "state")
|
||||||
|
return stateHome
|
||||||
|
.resolve("gittally")
|
||||||
|
.resolve("nginx")
|
||||||
|
.resolve(ArtifactKeys.repoKey(repoDir))
|
||||||
|
.toAbsolutePath()
|
||||||
|
.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy default `gittally-nginx-<repo-name>` with unsafe characters replaced. */
|
||||||
|
fun defaultContainerName(repoDir: Path): String =
|
||||||
|
"gittally-nginx-" + repoDir.fileName.toString().replace(Regex("[^A-Za-z0-9_.-]"), "-")
|
||||||
|
|
||||||
|
private fun downloadDhParams(target: Path) {
|
||||||
|
val response =
|
||||||
|
HttpClient
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(10))
|
||||||
|
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||||
|
.build()
|
||||||
|
.send(
|
||||||
|
HttpRequest.newBuilder(URI.create(NginxConfigFiles.DH_PARAMS_URL)).GET().build(),
|
||||||
|
HttpResponse.BodyHandlers.ofString(),
|
||||||
|
)
|
||||||
|
check(response.statusCode() == 200) { "downloading ssl-dhparams.pem failed with HTTP ${response.statusCode()}" }
|
||||||
|
Files.writeString(target, response.body())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import org.springframework.boot.context.event.ApplicationReadyEvent
|
||||||
|
import org.springframework.context.annotation.Profile
|
||||||
|
import org.springframework.context.event.EventListener
|
||||||
|
import org.springframework.stereotype.Component
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the managed nginx container once the web server is up and removes it on
|
||||||
|
* shutdown, plus a daily certificate-renewal check — an improvement over legacy,
|
||||||
|
* which renewed only at process start and relied on frequent self-update restarts.
|
||||||
|
* Only in the `server` profile, like [ServerWatcherLifecycle]; with
|
||||||
|
* `server.nginx.enabled: false` (the default) nothing is scheduled or touched.
|
||||||
|
* Startup runs on the scheduler thread, so a slow certificate issuance never
|
||||||
|
* blocks the web server; the single thread also serializes startup and renewals.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Profile("server")
|
||||||
|
class ServerNginxLifecycle(
|
||||||
|
private val nginxProxyManager: NginxProxyManager,
|
||||||
|
) {
|
||||||
|
/** Replaceable for tests: the scheduler running startup and renewal checks. */
|
||||||
|
internal var schedulerFactory: () -> ScheduledExecutorService = {
|
||||||
|
Executors.newSingleThreadScheduledExecutor { runnable ->
|
||||||
|
Thread(runnable, "gittally-nginx").apply { isDaemon = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduler: ScheduledExecutorService? = null
|
||||||
|
|
||||||
|
@EventListener(ApplicationReadyEvent::class)
|
||||||
|
fun onApplicationReady() {
|
||||||
|
if (!nginxProxyManager.isEnabled()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scheduler =
|
||||||
|
schedulerFactory().also {
|
||||||
|
it.execute(nginxProxyManager::start)
|
||||||
|
it.scheduleWithFixedDelay(
|
||||||
|
nginxProxyManager::renewCertificateAndReload,
|
||||||
|
RENEWAL_CHECK_INTERVAL_HOURS,
|
||||||
|
RENEWAL_CHECK_INTERVAL_HOURS,
|
||||||
|
TimeUnit.HOURS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
fun onShutdown() {
|
||||||
|
scheduler?.shutdownNow()
|
||||||
|
scheduler = null
|
||||||
|
nginxProxyManager.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Daily, like certbot's own systemd timer; `certbot renew` only acts when a certificate is due. */
|
||||||
|
const val RENEWAL_CHECK_INTERVAL_HOURS = 24L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -120,6 +120,36 @@ class ConfigLoaderTest : FunSpec() {
|
|||||||
config.branches["release"]!!.buildCommand shouldBe "./mvnw -P release test"
|
config.branches["release"]!!.buildCommand shouldBe "./mvnw -P release test"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test("empty publicBaseUrl defaults to https://<nginx.serverName>/ when set") {
|
||||||
|
val dir = Files.createTempDirectory("gittally-test")
|
||||||
|
dir.resolve(".gittally.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
nginx:
|
||||||
|
serverName: ci.example.org
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
loader.load(dir).server.publicBaseUrl shouldBe "https://ci.example.org/"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("explicit publicBaseUrl wins over the nginx.serverName default") {
|
||||||
|
val dir = Files.createTempDirectory("gittally-test")
|
||||||
|
dir.resolve(".gittally.yml").toFile().writeText(
|
||||||
|
"""
|
||||||
|
server:
|
||||||
|
publicBaseUrl: https://other.example.org/
|
||||||
|
nginx:
|
||||||
|
serverName: ci.example.org
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
loader.load(dir).server.publicBaseUrl shouldBe "https://other.example.org/"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("publicBaseUrl stays empty without an nginx.serverName") {
|
||||||
|
val dir = Files.createTempDirectory("gittally-test")
|
||||||
|
loader.load(dir).server.publicBaseUrl shouldBe ""
|
||||||
|
}
|
||||||
|
|
||||||
test("toYaml serializes GitTallyConfig with all sections") {
|
test("toYaml serializes GitTallyConfig with all sections") {
|
||||||
val yaml = loader.toYaml(GitTallyConfig())
|
val yaml = loader.toYaml(GitTallyConfig())
|
||||||
yaml shouldContain "server:"
|
yaml shouldContain "server:"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
|
|
||||||
|
class NginxConfigFilesTest : FunSpec() {
|
||||||
|
init {
|
||||||
|
test("init config serves the ACME challenge and redirects to HTTPS, without a TLS server") {
|
||||||
|
val conf = NginxConfigFiles.nginxConf("ci.example.org", "ci.example.org", 18080, full = false)
|
||||||
|
|
||||||
|
conf shouldContain "listen 80;"
|
||||||
|
conf shouldContain "server_name ci.example.org;"
|
||||||
|
conf shouldContain "location /.well-known/acme-challenge/"
|
||||||
|
conf shouldContain "root /var/www/certbot;"
|
||||||
|
conf shouldContain "return 301 https://\$host\$request_uri;"
|
||||||
|
conf shouldNotContain "listen 443"
|
||||||
|
conf shouldNotContain "proxy_pass"
|
||||||
|
conf shouldNotContain "ssl_certificate"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("full config adds the TLS server with certificate paths and the proxy") {
|
||||||
|
val conf = NginxConfigFiles.nginxConf("ci.example.org", "upstream.example.org", 18081, full = true)
|
||||||
|
|
||||||
|
conf shouldContain "listen 80;"
|
||||||
|
conf shouldContain "listen 443 ssl;"
|
||||||
|
conf shouldContain "ssl_certificate /etc/letsencrypt/live/ci.example.org/fullchain.pem;"
|
||||||
|
conf shouldContain "ssl_certificate_key /etc/letsencrypt/live/ci.example.org/privkey.pem;"
|
||||||
|
conf shouldContain "include /etc/letsencrypt/options-ssl-nginx.conf;"
|
||||||
|
conf shouldContain "proxy_pass http://upstream.example.org:18081;"
|
||||||
|
conf shouldContain "proxy_set_header Host \$host;"
|
||||||
|
conf shouldContain "proxy_set_header X-Forwarded-Proto \$scheme;"
|
||||||
|
conf shouldContain "add_header Cache-Control \"no-store, max-age=0\" always;"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("ssl options reference the mounted dhparams and modern protocols") {
|
||||||
|
NginxConfigFiles.SSL_OPTIONS shouldContain "ssl_protocols TLSv1.2 TLSv1.3;"
|
||||||
|
NginxConfigFiles.SSL_OPTIONS shouldContain "ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
import de.hoennig.gittally.build.ArtifactKeys
|
||||||
|
import de.hoennig.gittally.config.ConfigLoader
|
||||||
|
import de.hoennig.gittally.config.GitTallyConfig
|
||||||
|
import de.hoennig.gittally.config.NginxConfig
|
||||||
|
import de.hoennig.gittally.config.ServerConfig
|
||||||
|
import de.hoennig.gittally.git.GitCommandResult
|
||||||
|
import de.hoennig.gittally.git.GitCommandRunner
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.booleans.shouldBeTrue
|
||||||
|
import io.kotest.matchers.collections.shouldBeEmpty
|
||||||
|
import io.kotest.matchers.collections.shouldContain
|
||||||
|
import io.kotest.matchers.nulls.shouldBeNull
|
||||||
|
import io.kotest.matchers.nulls.shouldNotBeNull
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.kotest.matchers.string.shouldContain
|
||||||
|
import io.kotest.matchers.string.shouldEndWith
|
||||||
|
import io.kotest.matchers.string.shouldNotContain
|
||||||
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
class NginxProxyManagerTest : FunSpec() {
|
||||||
|
private val commandRunner = mockk<GitCommandRunner>()
|
||||||
|
private val configLoader = mockk<ConfigLoader>()
|
||||||
|
private lateinit var manager: NginxProxyManager
|
||||||
|
private lateinit var repoDir: Path
|
||||||
|
private lateinit var stateDir: Path
|
||||||
|
private val captured = mutableListOf<List<String>>()
|
||||||
|
private val configsAtContainerRun = mutableListOf<String>()
|
||||||
|
private var sleepCount = 0
|
||||||
|
|
||||||
|
private fun nginxConfig(
|
||||||
|
enabled: Boolean = true,
|
||||||
|
serverName: String = "ci.example.org",
|
||||||
|
letsencryptEmail: String = "",
|
||||||
|
containerName: String = "test-nginx",
|
||||||
|
httpPort: Int = 8080,
|
||||||
|
httpsPort: Int = 8443,
|
||||||
|
explicitStateDir: Boolean = true,
|
||||||
|
serverPort: Int = 18080,
|
||||||
|
): GitTallyConfig =
|
||||||
|
GitTallyConfig(
|
||||||
|
server =
|
||||||
|
ServerConfig(
|
||||||
|
port = serverPort,
|
||||||
|
nginx =
|
||||||
|
NginxConfig(
|
||||||
|
enabled = enabled,
|
||||||
|
serverName = serverName,
|
||||||
|
httpPort = httpPort,
|
||||||
|
httpsPort = httpsPort,
|
||||||
|
containerName = containerName,
|
||||||
|
stateDir = if (explicitStateDir) stateDir.toString() else "",
|
||||||
|
letsencryptEmail = letsencryptEmail,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun expectedRunArgs(): List<String> =
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
"test-nginx",
|
||||||
|
"--publish",
|
||||||
|
"8080:80",
|
||||||
|
"--publish",
|
||||||
|
"8443:443",
|
||||||
|
"--network",
|
||||||
|
"bridge",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/certbot/conf:/etc/letsencrypt",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/certbot/www:/var/www/certbot",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/nginx/log:/var/log/nginx",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/nginx/nginx.conf:/etc/nginx/nginx.conf:ro",
|
||||||
|
"--label",
|
||||||
|
"org.hoennig.gittally=true",
|
||||||
|
"--label",
|
||||||
|
"org.hoennig.gittally.repository=${ArtifactKeys.repoKey(repoDir)}",
|
||||||
|
"--label",
|
||||||
|
"org.hoennig.gittally.role=nginx",
|
||||||
|
"nginx",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun expectedCertbotPrefix(): List<String> =
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/certbot/conf:/etc/letsencrypt",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/certbot/www:/var/www/certbot",
|
||||||
|
"--volume",
|
||||||
|
"$stateDir/certbot/log:/var/log/letsencrypt",
|
||||||
|
"certbot/certbot",
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun preCreateCertificate() {
|
||||||
|
val certFile = stateDir.resolve("certbot/conf/live/ci.example.org/fullchain.pem")
|
||||||
|
Files.createDirectories(certFile.parent)
|
||||||
|
Files.writeString(certFile, "certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
beforeEach {
|
||||||
|
clearMocks(commandRunner, configLoader)
|
||||||
|
captured.clear()
|
||||||
|
configsAtContainerRun.clear()
|
||||||
|
sleepCount = 0
|
||||||
|
repoDir = Files.createTempDirectory("gittally-nginx-repo")
|
||||||
|
stateDir = Files.createTempDirectory("gittally-nginx-state")
|
||||||
|
every { commandRunner.run(capture(captured), any(), any()) } answers {
|
||||||
|
if (captured.last().take(3) == listOf("docker", "run", "-d")) {
|
||||||
|
configsAtContainerRun += Files.readString(stateDir.resolve("nginx/nginx.conf"))
|
||||||
|
}
|
||||||
|
GitCommandResult(0, "", "")
|
||||||
|
}
|
||||||
|
manager = NginxProxyManager(commandRunner, configLoader)
|
||||||
|
manager.workingDir = repoDir
|
||||||
|
manager.dhParamsDownloader = { Files.writeString(it, "dh-params") }
|
||||||
|
manager.sleeper = { sleepCount++ }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("start makes no docker calls when disabled") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig(enabled = false)
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
captured.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("resolves the legacy defaults for upstream host, container name, and state dir") {
|
||||||
|
every { configLoader.load(repoDir) } returns
|
||||||
|
nginxConfig(containerName = "", explicitStateDir = false)
|
||||||
|
|
||||||
|
val settings = manager.resolveSettings().shouldNotBeNull()
|
||||||
|
|
||||||
|
settings.upstreamHost shouldBe "ci.example.org"
|
||||||
|
settings.upstreamPort shouldBe 18080
|
||||||
|
settings.containerName shouldBe "gittally-nginx-${repoDir.fileName}"
|
||||||
|
settings.stateDir.toString() shouldEndWith "gittally/nginx/${ArtifactKeys.repoKey(repoDir)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rejects a server name that could inject nginx directives") {
|
||||||
|
every { configLoader.load(repoDir) } returns
|
||||||
|
nginxConfig(serverName = "evil.example.org;\n} inject {")
|
||||||
|
|
||||||
|
manager.resolveSettings().shouldBeNull()
|
||||||
|
manager.start()
|
||||||
|
captured.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rejects a missing server name") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig(serverName = "")
|
||||||
|
|
||||||
|
manager.resolveSettings().shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("rejects a server.port collision with the nginx ports") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig(serverPort = 8080)
|
||||||
|
|
||||||
|
manager.resolveSettings().shouldBeNull()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("first start runs the init config, obtains a certificate, and restarts with the full config") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
// phase 1 runs on the HTTP-only init config, phase 2 on the full HTTPS config
|
||||||
|
configsAtContainerRun.size shouldBe 2
|
||||||
|
configsAtContainerRun[0] shouldNotContain "listen 443"
|
||||||
|
configsAtContainerRun[1] shouldContain "listen 443 ssl;"
|
||||||
|
captured shouldContain expectedRunArgs()
|
||||||
|
captured shouldContain expectedCertbotPrefix() +
|
||||||
|
listOf(
|
||||||
|
"certonly",
|
||||||
|
"--webroot",
|
||||||
|
"--webroot-path",
|
||||||
|
"/var/www/certbot",
|
||||||
|
"--cert-name",
|
||||||
|
"ci.example.org",
|
||||||
|
"-d",
|
||||||
|
"ci.example.org",
|
||||||
|
"--rsa-key-size",
|
||||||
|
"4096",
|
||||||
|
"--non-interactive",
|
||||||
|
"--agree-tos",
|
||||||
|
"--register-unsafely-without-email",
|
||||||
|
)
|
||||||
|
Files.readString(stateDir.resolve("certbot/conf/options-ssl-nginx.conf")) shouldBe NginxConfigFiles.SSL_OPTIONS
|
||||||
|
Files.readString(stateDir.resolve("certbot/conf/ssl-dhparams.pem")) shouldBe "dh-params"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("start with an existing certificate uses the full config immediately and renews") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
preCreateCertificate()
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
configsAtContainerRun.size shouldBe 2
|
||||||
|
configsAtContainerRun[0] shouldContain "listen 443 ssl;"
|
||||||
|
captured shouldContain expectedCertbotPrefix() + listOf("renew", "-q")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a configured letsencryptEmail registers with --email instead of unsafely") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig(letsencryptEmail = "admin@example.org")
|
||||||
|
|
||||||
|
val settings = manager.resolveSettings().shouldNotBeNull()
|
||||||
|
val args = manager.obtainCertificateArgs(settings)
|
||||||
|
|
||||||
|
args shouldContain "--email"
|
||||||
|
args shouldContain "admin@example.org"
|
||||||
|
(args.contains("--register-unsafely-without-email")) shouldBe false
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stale nginx containers of this repository are removed by label before the start") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
captured shouldContain
|
||||||
|
listOf(
|
||||||
|
"docker",
|
||||||
|
"ps",
|
||||||
|
"-aq",
|
||||||
|
"--filter",
|
||||||
|
"label=org.hoennig.gittally=true",
|
||||||
|
"--filter",
|
||||||
|
"label=org.hoennig.gittally.repository=${ArtifactKeys.repoKey(repoDir)}",
|
||||||
|
"--filter",
|
||||||
|
"label=org.hoennig.gittally.role=nginx",
|
||||||
|
)
|
||||||
|
captured shouldContain listOf("docker", "rm", "-f", "test-nginx")
|
||||||
|
}
|
||||||
|
|
||||||
|
test("does not start while a foreign container occupies an nginx port") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
every {
|
||||||
|
commandRunner.run(match { it.take(2) == listOf("docker", "ps") && it.contains("--format") }, any(), any())
|
||||||
|
} returns GitCommandResult(0, "abc123\tother-app\t0.0.0.0:8080->80/tcp\tvendor=other", "")
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
captured.none { it.take(3) == listOf("docker", "run", "-d") }.shouldBeTrue()
|
||||||
|
sleepCount shouldBe 4
|
||||||
|
}
|
||||||
|
|
||||||
|
test("removes a stale gittally-named container occupying an nginx port") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
every {
|
||||||
|
commandRunner.run(match { it.take(2) == listOf("docker", "ps") && it.contains("--format") }, any(), any())
|
||||||
|
} returnsMany
|
||||||
|
listOf(
|
||||||
|
GitCommandResult(0, "abc123\tgittally-nginx-old\t0.0.0.0:8080->80/tcp\t", ""),
|
||||||
|
GitCommandResult(0, "", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
|
||||||
|
captured shouldContain listOf("docker", "rm", "-f", "abc123")
|
||||||
|
captured shouldContain expectedRunArgs()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renewCertificateAndReload renews the certificate and reloads nginx") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
manager.start()
|
||||||
|
captured.clear()
|
||||||
|
|
||||||
|
manager.renewCertificateAndReload()
|
||||||
|
|
||||||
|
captured shouldBe
|
||||||
|
listOf(
|
||||||
|
expectedCertbotPrefix() + listOf("renew", "-q"),
|
||||||
|
listOf("docker", "exec", "test-nginx", "nginx", "-s", "reload"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("renewCertificateAndReload is a no-op while no container is managed") {
|
||||||
|
manager.renewCertificateAndReload()
|
||||||
|
|
||||||
|
captured.shouldBeEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("stop removes the managed container exactly once") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
manager.start()
|
||||||
|
captured.clear()
|
||||||
|
|
||||||
|
manager.stop()
|
||||||
|
manager.stop()
|
||||||
|
|
||||||
|
captured shouldBe listOf(listOf("docker", "rm", "-f", "test-nginx"))
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a docker failure never throws — the HTTP server keeps running") {
|
||||||
|
every { configLoader.load(repoDir) } returns nginxConfig()
|
||||||
|
every { commandRunner.run(any(), any(), any()) } throws RuntimeException("docker: command not found")
|
||||||
|
|
||||||
|
manager.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.hoennig.gittally.server
|
||||||
|
|
||||||
|
import io.kotest.core.spec.style.FunSpec
|
||||||
|
import io.kotest.matchers.shouldBe
|
||||||
|
import io.mockk.clearMocks
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.slot
|
||||||
|
import io.mockk.verify
|
||||||
|
import java.util.concurrent.ScheduledExecutorService
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class ServerNginxLifecycleTest : FunSpec() {
|
||||||
|
private val manager = mockk<NginxProxyManager>(relaxUnitFun = true)
|
||||||
|
private val scheduler = mockk<ScheduledExecutorService>(relaxed = true)
|
||||||
|
private lateinit var lifecycle: ServerNginxLifecycle
|
||||||
|
private var schedulerCreated = 0
|
||||||
|
|
||||||
|
init {
|
||||||
|
beforeEach {
|
||||||
|
clearMocks(manager, scheduler)
|
||||||
|
schedulerCreated = 0
|
||||||
|
lifecycle = ServerNginxLifecycle(manager)
|
||||||
|
lifecycle.schedulerFactory = {
|
||||||
|
schedulerCreated++
|
||||||
|
scheduler
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nothing is scheduled and no container is touched when disabled") {
|
||||||
|
every { manager.isEnabled() } returns false
|
||||||
|
|
||||||
|
lifecycle.onApplicationReady()
|
||||||
|
|
||||||
|
schedulerCreated shouldBe 0
|
||||||
|
verify(exactly = 0) { manager.start() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("starts nginx on the scheduler thread and schedules the daily renewal check") {
|
||||||
|
every { manager.isEnabled() } returns true
|
||||||
|
every { scheduler.execute(any()) } answers { firstArg<Runnable>().run() }
|
||||||
|
val renewalTask = slot<Runnable>()
|
||||||
|
every { scheduler.scheduleWithFixedDelay(capture(renewalTask), 24L, 24L, TimeUnit.HOURS) } returns mockk()
|
||||||
|
|
||||||
|
lifecycle.onApplicationReady()
|
||||||
|
|
||||||
|
verify(exactly = 1) { manager.start() }
|
||||||
|
renewalTask.captured.run()
|
||||||
|
verify(exactly = 1) { manager.renewCertificateAndReload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shutdown stops the scheduler and removes the container") {
|
||||||
|
every { manager.isEnabled() } returns true
|
||||||
|
lifecycle.onApplicationReady()
|
||||||
|
|
||||||
|
lifecycle.onShutdown()
|
||||||
|
|
||||||
|
verify(exactly = 1) { scheduler.shutdownNow() }
|
||||||
|
verify(exactly = 1) { manager.stop() }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shutdown without a prior start still asks the manager to stop") {
|
||||||
|
lifecycle.onShutdown()
|
||||||
|
|
||||||
|
verify(exactly = 1) { manager.stop() }
|
||||||
|
verify(exactly = 0) { scheduler.shutdownNow() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user