certbot removed ssl-dhparams.pem from its repository, so the managed-nginx startup failed with HTTP 404 on fresh installations (the legacy script only kept working through its year-old state-dir cache). The RFC 7919 ffdhe2048 parameters are now a classpath resource; the download seam and NginxConfigFiles.DH_PARAMS_URL are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
432 lines
17 KiB
Kotlin
432 lines
17 KiB
Kotlin
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.nio.file.Files
|
|
import java.nio.file.Path
|
|
import java.nio.file.Paths
|
|
|
|
/**
|
|
* 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: 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)) {
|
|
writeBundledDhParams(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_.-]"), "-")
|
|
|
|
/**
|
|
* Certbot's pinned DH parameters (RFC 7919 ffdhe2048), bundled as a resource:
|
|
* certbot removed the file from its repository, so it can no longer be downloaded.
|
|
*/
|
|
private fun writeBundledDhParams(target: Path) {
|
|
val resource =
|
|
checkNotNull(NginxProxyManager::class.java.getResourceAsStream("/nginx/ssl-dhparams.pem")) {
|
|
"bundled nginx/ssl-dhparams.pem resource is missing"
|
|
}
|
|
resource.use { Files.copy(it, target) }
|
|
}
|
|
}
|
|
}
|