feat: establish secure update foundation
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
Container-Image veröffentlichen / container (push) v1.4.0 wurde manuell veröffentlicht und verifiziert
This commit is contained in:
1 parent
f02ec9977c
commit
bb60c06c0e
28 files changed
+1403
-280
No files matched your search
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
@@ -26,7 +27,16 @@ type Controller struct {
|
||||
}
|
||||
|
||||
func NewController(runner Runner, logger *slog.Logger) *Controller {
|
||||
return &Controller{runner: runner, logger: logger, status: updateprotocol.Status{Phase: updateprotocol.PhaseIdle, UpdatedAt: time.Now().UTC()}}
|
||||
return &Controller{runner: runner, logger: logger, status: agentStatus(updateprotocol.PhaseIdle)}
|
||||
}
|
||||
|
||||
func agentStatus(phase string) updateprotocol.Status {
|
||||
return updateprotocol.Status{
|
||||
Phase: phase,
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
AgentVersion: buildinfo.Version,
|
||||
Protocol: buildinfo.UpdateProtocolVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Controller) Handler() http.Handler {
|
||||
@@ -57,9 +67,10 @@ func (c *Controller) updateHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
request.Version = strings.TrimSpace(request.Version)
|
||||
request.CurrentVersion = strings.TrimSpace(request.CurrentVersion)
|
||||
request.RegistryUsername = strings.TrimSpace(request.RegistryUsername)
|
||||
request.RegistryToken = strings.TrimSpace(request.RegistryToken)
|
||||
if !updateprotocol.ValidVersion(request.Version) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
||||
if !updateprotocol.ValidVersion(request.Version) || !updateprotocol.ValidVersion(request.CurrentVersion) || request.RegistryUsername == "" || len(request.RegistryUsername) > 128 || len(request.RegistryToken) < 8 || len(request.RegistryToken) > 1024 {
|
||||
http.Error(w, "invalid update request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -80,7 +91,7 @@ func (c *Controller) begin(version string) bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now}
|
||||
c.status = updateprotocol.Status{Phase: updateprotocol.PhaseQueued, TargetVersion: version, Message: "Update eingereiht", StartedAt: now, UpdatedAt: now, AgentVersion: buildinfo.Version, Protocol: buildinfo.UpdateProtocolVersion}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,19 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||
if trailing.Code != http.StatusBadRequest {
|
||||
t.Fatalf("request with trailing JSON accepted: status=%d", trailing.Code)
|
||||
}
|
||||
invalidCurrent := httptest.NewRecorder()
|
||||
handler.ServeHTTP(invalidCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","current_version":"1.3","registry_username":"user","registry_token":"secret-token"}`)))
|
||||
if invalidCurrent.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid current version accepted: status=%d", invalidCurrent.Code)
|
||||
}
|
||||
missingCurrent := httptest.NewRecorder()
|
||||
handler.ServeHTTP(missingCurrent, httptest.NewRequest(http.MethodPost, "/v1/update", strings.NewReader(`{"version":"v2.0.0","registry_username":"user","registry_token":"secret-token"}`)))
|
||||
if missingCurrent.Code != http.StatusBadRequest {
|
||||
t.Fatalf("request without current version accepted: status=%d", missingCurrent.Code)
|
||||
}
|
||||
|
||||
const secret = "registry-token-must-not-leak"
|
||||
body := []byte(`{"version":"v1.2.3","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||
body := []byte(`{"version":"v2.0.0","current_version":"v1.4.0","registry_username":"user","registry_token":"` + secret + `"}`)
|
||||
accepted := httptest.NewRecorder()
|
||||
handler.ServeHTTP(accepted, httptest.NewRequest(http.MethodPost, "/v1/update", bytes.NewReader(body)))
|
||||
if accepted.Code != http.StatusAccepted {
|
||||
@@ -66,7 +76,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||
}
|
||||
select {
|
||||
case request := <-runner.started:
|
||||
if request.Version != "v1.2.3" || request.RegistryToken != secret {
|
||||
if request.Version != "v2.0.0" || request.CurrentVersion != "v1.4.0" || request.RegistryToken != secret {
|
||||
t.Fatalf("runner received an altered request: %#v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
@@ -80,7 +90,7 @@ func TestControllerValidatesRequestsAndRunsOnlyOneUpdate(t *testing.T) {
|
||||
}
|
||||
status := httptest.NewRecorder()
|
||||
handler.ServeHTTP(status, httptest.NewRequest(http.MethodGet, "/v1/status", nil))
|
||||
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) {
|
||||
if status.Code != http.StatusOK || strings.Contains(status.Body.String(), secret) || !strings.Contains(status.Body.String(), `"phase":"pulling"`) || !strings.Contains(status.Body.String(), `"protocol_version":2`) {
|
||||
t.Fatalf("unsafe or invalid public status: status=%d body=%s", status.Code, status.Body.String())
|
||||
}
|
||||
|
||||
|
||||
+442
-17
@@ -13,14 +13,30 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
const defaultImageRepository = "git.j-mahler.de/admin/domain-control-webinterface-prod"
|
||||
|
||||
const (
|
||||
labelImageVersion = "org.opencontainers.image.version"
|
||||
labelMinimumVersion = "de.j-mahler.domain-control.minimum-update-version"
|
||||
labelMinimumAgent = "de.j-mahler.domain-control.minimum-agent-protocol"
|
||||
labelRuntimeContract = "de.j-mahler.domain-control.runtime-contract"
|
||||
labelDatabaseContract = "de.j-mahler.domain-control.database-contract"
|
||||
labelSchemaVersion = "de.j-mahler.domain-control.schema-version"
|
||||
runtimeEntrypoint = "/usr/local/bin/domain-control"
|
||||
runtimeCommand = "serve"
|
||||
runtimeUser = "dyndns:dyndns"
|
||||
runtimeWorkingDirectory = "/data"
|
||||
)
|
||||
|
||||
type DockerRunner struct {
|
||||
docker *dockerClient
|
||||
imageRepository string
|
||||
@@ -51,10 +67,6 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
if err := r.docker.negotiate(ctx); err != nil {
|
||||
return &updateError{message: "Containerverwaltung ist nicht erreichbar; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
progress(updateprotocol.PhasePulling, "Container-Image wird geladen")
|
||||
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
|
||||
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
target, err := r.docker.findTarget(ctx, r.targetLabel)
|
||||
if err != nil {
|
||||
return &updateError{message: "Laufender Anwendungscontainer wurde nicht eindeutig gefunden", err: err}
|
||||
@@ -63,11 +75,42 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if inspected.State.Health == nil || inspected.State.Health.Status != "healthy" {
|
||||
return &updateError{message: "Die laufende Instanz ist nicht healthy; das Update wurde sicherheitshalber nicht gestartet", err: errors.New("current container is not healthy")}
|
||||
}
|
||||
currentVersion, err := resolveCurrentVersion(request.CurrentVersion, inspected.Config, r.imageRepository)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die laufende Version konnte nicht sicher bestimmt werden; bitte einmal mit Docker Compose aktualisieren", err: err}
|
||||
}
|
||||
if comparison, compareErr := updateprotocol.CompareVersions(currentVersion, request.Version); compareErr != nil || comparison >= 0 {
|
||||
return &updateError{message: "Die Zielversion ist nicht neuer als die laufende Version", err: fmt.Errorf("invalid version transition %s to %s", currentVersion, request.Version)}
|
||||
}
|
||||
currentImage, err := r.docker.inspectImage(ctx, inspected.Image)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die laufende Container-Basis konnte nicht geprüft werden", err: err}
|
||||
}
|
||||
|
||||
targetReference := r.imageRepository + ":" + request.Version
|
||||
progress(updateprotocol.PhasePulling, "Container-Image wird geladen und geprüft")
|
||||
if err := r.docker.pullImage(ctx, r.imageRepository, request.Version, request.RegistryUsername, request.RegistryToken); err != nil {
|
||||
return &updateError{message: "Image konnte nicht geladen werden; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
targetImage, err := r.docker.inspectImage(ctx, targetReference)
|
||||
if err != nil {
|
||||
return &updateError{message: "Das geladene Image konnte nicht geprüft werden; der Dienst läuft unverändert weiter", err: err}
|
||||
}
|
||||
if err := validateUpdateCompatibility(currentVersion, request.Version, currentImage, targetImage); err != nil {
|
||||
return &updateError{message: "Dieses Release unterstützt kein sicheres Direktupdate von der laufenden Version; bitte Docker Compose verwenden", err: err}
|
||||
}
|
||||
replacementConfig, err := buildReplacementConfig(inspected, currentImage.Config, targetImage.Config, targetReference)
|
||||
if err != nil {
|
||||
return &updateError{message: "Die neue Container-Konfiguration konnte nicht sicher vorbereitet werden", err: err}
|
||||
}
|
||||
originalName := strings.TrimPrefix(inspected.Name, "/")
|
||||
if originalName == "" {
|
||||
return errors.New("target container has no name")
|
||||
}
|
||||
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().Unix())
|
||||
backupName := originalName + "-rollback-" + fmt.Sprint(time.Now().UTC().UnixNano())
|
||||
var oldStopped, oldRenamed bool
|
||||
var newID string
|
||||
defer func() {
|
||||
@@ -76,7 +119,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
}
|
||||
rollbackCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, oldStopped, oldRenamed, newID); rollbackErr != nil {
|
||||
if rollbackErr := r.rollback(rollbackCtx, target.ID, originalName, inspected.Image, oldStopped, oldRenamed, newID); rollbackErr != nil {
|
||||
r.logger.Error("software update rollback failed", "error", rollbackErr)
|
||||
resultErr = &updateError{message: "Update und automatische Wiederherstellung sind fehlgeschlagen; Containerstatus am Host prüfen", err: fmt.Errorf("%w; rollback: %v", resultErr, rollbackErr)}
|
||||
}
|
||||
@@ -91,7 +134,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
return err
|
||||
}
|
||||
oldRenamed = true
|
||||
newID, err = r.docker.createReplacement(ctx, inspected, originalName, r.imageRepository+":"+request.Version)
|
||||
newID, err = r.docker.createReplacement(ctx, inspected, originalName, replacementConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,7 +142,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
return err
|
||||
}
|
||||
progress(updateprotocol.PhaseVerifying, "Neuer Container wird geprüft")
|
||||
if err := r.docker.waitHealthy(ctx, newID, 100*time.Second); err != nil {
|
||||
if err := r.docker.waitHealthy(ctx, newID, targetImage.ID, request.Version, 100*time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.docker.removeContainer(ctx, target.ID, false); err != nil {
|
||||
@@ -109,7 +152,7 @@ func (r *DockerRunner) Update(ctx context.Context, request updateprotocol.Reques
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string, oldStopped, oldRenamed bool, newID string) error {
|
||||
func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName, oldImageID string, oldStopped, oldRenamed bool, newID string) error {
|
||||
var failures []string
|
||||
if newID != "" {
|
||||
if err := r.docker.stopContainer(ctx, newID, 5); err != nil && !isDockerNotModified(err) {
|
||||
@@ -127,6 +170,8 @@ func (r *DockerRunner) rollback(ctx context.Context, oldID, originalName string,
|
||||
if oldStopped {
|
||||
if err := r.docker.startContainer(ctx, oldID); err != nil && !isDockerNotModified(err) {
|
||||
failures = append(failures, "restart previous container: "+err.Error())
|
||||
} else if err := r.docker.waitHealthy(ctx, oldID, oldImageID, "", 60*time.Second); err != nil {
|
||||
failures = append(failures, "verify previous container: "+err.Error())
|
||||
}
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
@@ -149,6 +194,7 @@ type containerSummary struct {
|
||||
|
||||
type containerInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Image string `json:"Image"`
|
||||
Name string `json:"Name"`
|
||||
Config json.RawMessage `json:"Config"`
|
||||
HostConfig json.RawMessage `json:"HostConfig"`
|
||||
@@ -158,6 +204,23 @@ type containerInspect struct {
|
||||
State containerState `json:"State"`
|
||||
}
|
||||
|
||||
type imageInspect struct {
|
||||
ID string `json:"Id"`
|
||||
Config json.RawMessage `json:"Config"`
|
||||
}
|
||||
|
||||
type imageRuntimeConfig struct {
|
||||
User string `json:"User"`
|
||||
Entrypoint []string `json:"Entrypoint"`
|
||||
Cmd []string `json:"Cmd"`
|
||||
WorkingDir string `json:"WorkingDir"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
ExposedPorts map[string]any `json:"ExposedPorts"`
|
||||
Healthcheck *struct {
|
||||
Test []string `json:"Test"`
|
||||
} `json:"Healthcheck"`
|
||||
}
|
||||
|
||||
type endpointSettings struct {
|
||||
IPAMConfig json.RawMessage `json:"IPAMConfig,omitempty"`
|
||||
Links []string `json:"Links,omitempty"`
|
||||
@@ -282,6 +345,28 @@ func (c *dockerClient) inspectContainer(ctx context.Context, id string) (contain
|
||||
return inspected, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) inspectImage(ctx context.Context, reference string) (imageInspect, error) {
|
||||
if strings.TrimSpace(reference) == "" {
|
||||
return imageInspect{}, errors.New("image reference is empty")
|
||||
}
|
||||
response, err := c.request(ctx, http.MethodGet, "/images/"+url.PathEscape(reference)+"/json", nil, nil, true)
|
||||
if err != nil {
|
||||
return imageInspect{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return imageInspect{}, dockerStatusError(response)
|
||||
}
|
||||
var inspected imageInspect
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&inspected); err != nil {
|
||||
return imageInspect{}, err
|
||||
}
|
||||
if inspected.ID == "" || len(inspected.Config) == 0 {
|
||||
return imageInspect{}, errors.New("Docker returned incomplete image metadata")
|
||||
}
|
||||
return inspected, nil
|
||||
}
|
||||
|
||||
func (c *dockerClient) stopContainer(ctx context.Context, id string, seconds int) error {
|
||||
query := url.Values{"t": {fmt.Sprint(seconds)}}
|
||||
response, err := c.request(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?"+query.Encode(), nil, nil, true)
|
||||
@@ -308,13 +393,338 @@ func (c *dockerClient) renameContainer(ctx context.Context, id, name string) err
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name, image string) (string, error) {
|
||||
var body map[string]json.RawMessage
|
||||
if err := json.Unmarshal(inspected.Config, &body); err != nil {
|
||||
return "", fmt.Errorf("decode container config: %w", err)
|
||||
func resolveCurrentVersion(reported string, rawConfig json.RawMessage, repository string) (string, error) {
|
||||
var config struct {
|
||||
Image string `json:"Image"`
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
imageJSON, _ := json.Marshal(image)
|
||||
body["Image"] = imageJSON
|
||||
if err := json.Unmarshal(rawConfig, &config); err != nil {
|
||||
return "", fmt.Errorf("decode running container version: %w", err)
|
||||
}
|
||||
prefix := strings.TrimSuffix(repository, ":") + ":"
|
||||
var imageVersion string
|
||||
if strings.HasPrefix(config.Image, prefix) {
|
||||
if version := strings.TrimPrefix(config.Image, prefix); updateprotocol.ValidVersion(version) {
|
||||
imageVersion = version
|
||||
}
|
||||
}
|
||||
labelVersion := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||
if labelVersion != "" && !updateprotocol.ValidVersion(labelVersion) {
|
||||
return "", errors.New("running container has an invalid version label")
|
||||
}
|
||||
if imageVersion == "" || labelVersion == "" {
|
||||
return "", errors.New("running container has no trustworthy version metadata")
|
||||
}
|
||||
if imageVersion != labelVersion {
|
||||
return "", fmt.Errorf("running image reference %s does not match version label %s", imageVersion, labelVersion)
|
||||
}
|
||||
actualVersion := imageVersion
|
||||
if reported != "" {
|
||||
if !updateprotocol.ValidVersion(reported) || reported != actualVersion {
|
||||
return "", fmt.Errorf("reported version %q does not match running version %q", reported, actualVersion)
|
||||
}
|
||||
}
|
||||
return actualVersion, nil
|
||||
}
|
||||
|
||||
func validateUpdateCompatibility(currentVersion, targetVersion string, currentImage, targetImage imageInspect) error {
|
||||
if comparison, err := updateprotocol.CompareVersions(currentVersion, targetVersion); err != nil || comparison >= 0 {
|
||||
return fmt.Errorf("target %s is not newer than %s", targetVersion, currentVersion)
|
||||
}
|
||||
var current, target imageRuntimeConfig
|
||||
if err := json.Unmarshal(currentImage.Config, ¤t); err != nil {
|
||||
return fmt.Errorf("decode current image config: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(targetImage.Config, &target); err != nil {
|
||||
return fmt.Errorf("decode target image config: %w", err)
|
||||
}
|
||||
if current.Labels[labelImageVersion] != currentVersion {
|
||||
return fmt.Errorf("current image version label is %q", current.Labels[labelImageVersion])
|
||||
}
|
||||
if target.Labels[labelImageVersion] != targetVersion {
|
||||
return fmt.Errorf("target image version label is %q", target.Labels[labelImageVersion])
|
||||
}
|
||||
minimumVersion := target.Labels[labelMinimumVersion]
|
||||
if !updateprotocol.ValidVersion(minimumVersion) {
|
||||
return errors.New("target image has no valid minimum update version")
|
||||
}
|
||||
if comparison, err := updateprotocol.CompareVersions(currentVersion, minimumVersion); err != nil || comparison < 0 {
|
||||
return fmt.Errorf("target requires at least %s", minimumVersion)
|
||||
}
|
||||
minimumAgent, err := strconv.Atoi(target.Labels[labelMinimumAgent])
|
||||
if err != nil || minimumAgent < buildinfo.MinimumAgentProtocol || minimumAgent > buildinfo.UpdateProtocolVersion {
|
||||
return fmt.Errorf("target requires unsupported update protocol %q", target.Labels[labelMinimumAgent])
|
||||
}
|
||||
runtimeContract := strconv.Itoa(buildinfo.RuntimeContractVersion)
|
||||
if current.Labels[labelRuntimeContract] != runtimeContract || target.Labels[labelRuntimeContract] != runtimeContract {
|
||||
return fmt.Errorf("unsupported runtime contract transition %q to %q", current.Labels[labelRuntimeContract], target.Labels[labelRuntimeContract])
|
||||
}
|
||||
databaseContract := strconv.Itoa(buildinfo.DatabaseContractVersion)
|
||||
if current.Labels[labelDatabaseContract] != databaseContract || target.Labels[labelDatabaseContract] != databaseContract {
|
||||
return fmt.Errorf("unsupported database contract transition %q to %q", current.Labels[labelDatabaseContract], target.Labels[labelDatabaseContract])
|
||||
}
|
||||
currentSchema, currentSchemaErr := strconv.Atoi(current.Labels[labelSchemaVersion])
|
||||
targetSchema, targetSchemaErr := strconv.Atoi(target.Labels[labelSchemaVersion])
|
||||
if currentSchemaErr != nil || targetSchemaErr != nil || currentSchema < buildinfo.CurrentSchemaVersion || targetSchema < currentSchema {
|
||||
return fmt.Errorf("unsupported schema transition %q to %q", current.Labels[labelSchemaVersion], target.Labels[labelSchemaVersion])
|
||||
}
|
||||
if !compatibleRuntimeConfig(current) {
|
||||
return errors.New("current image violates the stable runtime contract")
|
||||
}
|
||||
if !compatibleRuntimeConfig(target) {
|
||||
return errors.New("target image violates the stable runtime contract")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compatibleRuntimeConfig(config imageRuntimeConfig) bool {
|
||||
if config.User != runtimeUser || config.WorkingDir != runtimeWorkingDirectory || len(config.Entrypoint) != 1 || config.Entrypoint[0] != runtimeEntrypoint || len(config.Cmd) != 1 || config.Cmd[0] != runtimeCommand {
|
||||
return false
|
||||
}
|
||||
if _, ok := config.ExposedPorts["8080/tcp"]; !ok {
|
||||
return false
|
||||
}
|
||||
return config.Healthcheck != nil && strings.Join(config.Healthcheck.Test, "\x00") == strings.Join([]string{"CMD", runtimeEntrypoint, "healthcheck"}, "\x00")
|
||||
}
|
||||
|
||||
func buildReplacementConfig(inspected containerInspect, oldImageConfig, targetImageConfig json.RawMessage, targetReference string) (map[string]json.RawMessage, error) {
|
||||
oldContainer, err := decodeConfigMap(inspected.Config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode running container config: %w", err)
|
||||
}
|
||||
oldImage, err := decodeConfigMap(oldImageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode running image config: %w", err)
|
||||
}
|
||||
target, err := decodeConfigMap(targetImageConfig)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode target image config: %w", err)
|
||||
}
|
||||
|
||||
for _, field := range []string{
|
||||
"Domainname", "AttachStdin", "AttachStdout", "AttachStderr", "Tty", "OpenStdin", "StdinOnce",
|
||||
"User", "Cmd", "Entrypoint", "WorkingDir", "Healthcheck", "StopSignal", "StopTimeout", "Shell",
|
||||
"NetworkDisabled", "MacAddress",
|
||||
} {
|
||||
preserveConfigOverride(target, oldContainer, oldImage, field)
|
||||
}
|
||||
preserveCustomHostname(target, oldContainer, inspected.ID)
|
||||
if err := mergeEnvironment(target, oldContainer, oldImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mergeLabels(target, oldContainer, oldImage); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, field := range []string{"ExposedPorts", "Volumes"} {
|
||||
if err := mergeAddedMapKeys(target, oldContainer, oldImage, field); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
encodedReference, _ := json.Marshal(targetReference)
|
||||
target["Image"] = encodedReference
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func decodeConfigMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||
var config map[string]json.RawMessage
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, errors.New("empty config")
|
||||
}
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func preserveConfigOverride(target, container, image map[string]json.RawMessage, field string) {
|
||||
containerValue, exists := container[field]
|
||||
if !exists || rawJSONEqual(containerValue, image[field]) {
|
||||
return
|
||||
}
|
||||
target[field] = append(json.RawMessage(nil), containerValue...)
|
||||
}
|
||||
|
||||
func preserveCustomHostname(target, container map[string]json.RawMessage, containerID string) {
|
||||
var hostname string
|
||||
if err := json.Unmarshal(container["Hostname"], &hostname); err != nil || hostname == "" {
|
||||
return
|
||||
}
|
||||
shortID := containerID
|
||||
if len(shortID) > 12 {
|
||||
shortID = shortID[:12]
|
||||
}
|
||||
if hostname == containerID || hostname == shortID {
|
||||
delete(target, "Hostname")
|
||||
return
|
||||
}
|
||||
target["Hostname"] = append(json.RawMessage(nil), container["Hostname"]...)
|
||||
}
|
||||
|
||||
func mergeEnvironment(target, container, image map[string]json.RawMessage) error {
|
||||
targetValues, err := decodeStringSlice(target["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target environment: %w", err)
|
||||
}
|
||||
containerValues, err := decodeStringSlice(container["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container environment: %w", err)
|
||||
}
|
||||
imageValues, err := decodeStringSlice(image["Env"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image environment: %w", err)
|
||||
}
|
||||
result, positions := indexedEnvironment(targetValues)
|
||||
imageEnvironment := make(map[string]string, len(imageValues))
|
||||
for _, value := range imageValues {
|
||||
imageEnvironment[environmentKey(value)] = value
|
||||
}
|
||||
for _, value := range containerValues {
|
||||
key := environmentKey(value)
|
||||
if previous, exists := imageEnvironment[key]; exists && previous == value {
|
||||
continue
|
||||
}
|
||||
if position, exists := positions[key]; exists {
|
||||
result[position] = value
|
||||
} else {
|
||||
positions[key] = len(result)
|
||||
result = append(result, value)
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(result)
|
||||
target["Env"] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeStringSlice(raw json.RawMessage) ([]string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func indexedEnvironment(values []string) ([]string, map[string]int) {
|
||||
result := append([]string(nil), values...)
|
||||
positions := make(map[string]int, len(values))
|
||||
for index, value := range values {
|
||||
positions[environmentKey(value)] = index
|
||||
}
|
||||
return result, positions
|
||||
}
|
||||
|
||||
func environmentKey(value string) string {
|
||||
if index := strings.IndexByte(value, '='); index >= 0 {
|
||||
return value[:index]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func mergeLabels(target, container, image map[string]json.RawMessage) error {
|
||||
targetLabels, err := decodeStringMap(target["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target labels: %w", err)
|
||||
}
|
||||
containerLabels, err := decodeStringMap(container["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container labels: %w", err)
|
||||
}
|
||||
imageLabels, err := decodeStringMap(image["Labels"])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image labels: %w", err)
|
||||
}
|
||||
if targetLabels == nil {
|
||||
targetLabels = make(map[string]string)
|
||||
}
|
||||
for key, value := range containerLabels {
|
||||
if protectedImageLabel(key) {
|
||||
continue
|
||||
}
|
||||
if imageValue, exists := imageLabels[key]; exists && imageValue == value {
|
||||
continue
|
||||
}
|
||||
targetLabels[key] = value
|
||||
}
|
||||
encoded, _ := json.Marshal(targetLabels)
|
||||
target["Labels"] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func protectedImageLabel(key string) bool {
|
||||
switch key {
|
||||
case labelImageVersion, labelMinimumVersion, labelMinimumAgent, labelRuntimeContract, labelDatabaseContract, labelSchemaVersion:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStringMap(raw json.RawMessage) (map[string]string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values map[string]string
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func mergeAddedMapKeys(target, container, image map[string]json.RawMessage, field string) error {
|
||||
targetValues, err := decodeRawMap(target[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode target %s: %w", field, err)
|
||||
}
|
||||
containerValues, err := decodeRawMap(container[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode container %s: %w", field, err)
|
||||
}
|
||||
imageValues, err := decodeRawMap(image[field])
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode image %s: %w", field, err)
|
||||
}
|
||||
if targetValues == nil {
|
||||
targetValues = make(map[string]json.RawMessage)
|
||||
}
|
||||
for key, value := range containerValues {
|
||||
if imageValue, exists := imageValues[key]; exists && rawJSONEqual(value, imageValue) {
|
||||
continue
|
||||
}
|
||||
targetValues[key] = value
|
||||
}
|
||||
encoded, _ := json.Marshal(targetValues)
|
||||
target[field] = encoded
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeRawMap(raw json.RawMessage) (map[string]json.RawMessage, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil
|
||||
}
|
||||
var values map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func rawJSONEqual(left, right json.RawMessage) bool {
|
||||
if len(left) == 0 && len(right) == 0 {
|
||||
return true
|
||||
}
|
||||
var a, b any
|
||||
if err := json.Unmarshal(left, &a); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(right, &b); err != nil {
|
||||
return false
|
||||
}
|
||||
return reflect.DeepEqual(a, b)
|
||||
}
|
||||
|
||||
func (c *dockerClient) createReplacement(ctx context.Context, inspected containerInspect, name string, body map[string]json.RawMessage) (string, error) {
|
||||
body["HostConfig"] = inspected.HostConfig
|
||||
endpoints := make(map[string]endpointSettings, len(inspected.NetworkSettings.Networks))
|
||||
oldIDPrefix := inspected.ID
|
||||
@@ -383,7 +793,7 @@ func (c *dockerClient) removeContainer(ctx context.Context, id string, force boo
|
||||
return dockerStatusError(response)
|
||||
}
|
||||
|
||||
func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.Duration) error {
|
||||
func (c *dockerClient) waitHealthy(ctx context.Context, id, expectedImageID, expectedVersion string, timeout time.Duration) error {
|
||||
deadline := time.NewTimer(timeout)
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer deadline.Stop()
|
||||
@@ -404,9 +814,24 @@ func (c *dockerClient) waitHealthy(ctx context.Context, id string, timeout time.
|
||||
if !inspected.State.Running {
|
||||
return fmt.Errorf("replacement container stopped with exit code %d", inspected.State.ExitCode)
|
||||
}
|
||||
if expectedImageID != "" && inspected.Image != expectedImageID {
|
||||
return fmt.Errorf("container uses image %s instead of %s", inspected.Image, expectedImageID)
|
||||
}
|
||||
if expectedVersion != "" {
|
||||
var config struct {
|
||||
Labels map[string]string `json:"Labels"`
|
||||
}
|
||||
if err := json.Unmarshal(inspected.Config, &config); err != nil {
|
||||
return fmt.Errorf("decode replacement version: %w", err)
|
||||
}
|
||||
version := strings.TrimSpace(config.Labels[labelImageVersion])
|
||||
if version != expectedVersion {
|
||||
return fmt.Errorf("container version is %q instead of %q", version, expectedVersion)
|
||||
}
|
||||
}
|
||||
if inspected.State.Health == nil {
|
||||
if time.Since(startedAt) >= 8*time.Second {
|
||||
return nil
|
||||
return errors.New("replacement container has no healthcheck")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
)
|
||||
|
||||
func TestValidateUpdateCompatibilityAllowsDirectMajorJump(t *testing.T) {
|
||||
t.Parallel()
|
||||
current := compatibleImage("v1.4.0", "v1.4.0")
|
||||
target := compatibleImage("v2.0.0", "v1.4.0")
|
||||
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", current, target); err != nil {
|
||||
t.Fatalf("compatible direct update was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCurrentVersionRequiresRuntimeAndImageMetadataToAgree(t *testing.T) {
|
||||
t.Parallel()
|
||||
config := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.4.0"}}`)
|
||||
version, err := resolveCurrentVersion("", config, "registry.example/app")
|
||||
if err != nil || version != "v1.4.0" {
|
||||
t.Fatalf("exact image version was not resolved: version=%q err=%v", version, err)
|
||||
}
|
||||
if _, err := resolveCurrentVersion("v1.5.0", config, "registry.example/app"); err == nil {
|
||||
t.Fatal("untrusted reported version overrode the running image metadata")
|
||||
}
|
||||
mismatch := json.RawMessage(`{"Image":"registry.example/app:v1.4.0","Labels":{"org.opencontainers.image.version":"v1.3.0"}}`)
|
||||
if _, err := resolveCurrentVersion("", mismatch, "registry.example/app"); err == nil {
|
||||
t.Fatal("mismatching image reference and label were accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateCompatibilityRejectsUnsupportedJump(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(map[string]any)
|
||||
}{
|
||||
{name: "minimum version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelMinimumVersion] = "v1.5.0" }},
|
||||
{name: "runtime contract", mutate: func(config map[string]any) {
|
||||
config["Labels"].(map[string]string)[labelRuntimeContract] = strconv.Itoa(buildinfo.RuntimeContractVersion + 1)
|
||||
}},
|
||||
{name: "database contract", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelDatabaseContract] = "2" }},
|
||||
{name: "agent protocol", mutate: func(config map[string]any) {
|
||||
config["Labels"].(map[string]string)[labelMinimumAgent] = strconv.Itoa(buildinfo.UpdateProtocolVersion + 1)
|
||||
}},
|
||||
{name: "wrong image version", mutate: func(config map[string]any) { config["Labels"].(map[string]string)[labelImageVersion] = "v1.9.9" }},
|
||||
{name: "missing healthcheck", mutate: func(config map[string]any) { delete(config, "Healthcheck") }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
image := compatibleImage("v2.0.0", "v1.4.0")
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(image.Config, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels := make(map[string]string)
|
||||
for key, value := range config["Labels"].(map[string]any) {
|
||||
labels[key] = value.(string)
|
||||
}
|
||||
config["Labels"] = labels
|
||||
test.mutate(config)
|
||||
image.Config, _ = json.Marshal(config)
|
||||
if err := validateUpdateCompatibility("v1.4.0", "v2.0.0", compatibleImage("v1.4.0", "v1.4.0"), image); err == nil {
|
||||
t.Fatal("incompatible image was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateCompatibilityRejectsSchemaRegressionAfterEarlierSkip(t *testing.T) {
|
||||
t.Parallel()
|
||||
current := compatibleImage("v2.0.0", "v1.4.0")
|
||||
target := compatibleImage("v2.1.0", "v1.4.0")
|
||||
current.Config = withImageLabel(t, current.Config, labelSchemaVersion, "20")
|
||||
target.Config = withImageLabel(t, target.Config, labelSchemaVersion, "19")
|
||||
if err := validateUpdateCompatibility("v2.0.0", "v2.1.0", current, target); err == nil {
|
||||
t.Fatal("target with an older schema contract was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildReplacementConfigUsesNewImageDefaultsAndKeepsDeploymentOverrides(t *testing.T) {
|
||||
t.Parallel()
|
||||
oldImage := json.RawMessage(`{
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/old-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/old","OLD_DEFAULT=1"],
|
||||
"Labels":{"org.opencontainers.image.version":"v1.3.0","image-owned":"old"},
|
||||
"ExposedPorts":{"8080/tcp":{}},
|
||||
"Volumes":{"/data":{}}
|
||||
}`)
|
||||
targetImage := json.RawMessage(`{
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/new-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/new","NEW_DEFAULT=1"],
|
||||
"Labels":{"org.opencontainers.image.version":"v2.0.0","image-owned":"new"},
|
||||
"ExposedPorts":{"8080/tcp":{}},
|
||||
"Volumes":{"/data":{}}
|
||||
}`)
|
||||
container := containerInspect{
|
||||
ID: "0123456789abcdef",
|
||||
Config: json.RawMessage(`{
|
||||
"Hostname":"0123456789ab",
|
||||
"User":"dyndns:dyndns",
|
||||
"Entrypoint":["/old-entrypoint"],
|
||||
"WorkingDir":"/data",
|
||||
"Env":["PATH=/old","OLD_DEFAULT=1","CUSTOM=yes"],
|
||||
"Labels":{"org.opencontainers.image.version":"v0.0.0","image-owned":"old","com.docker.compose.project":"domain-control","de.j-mahler.domain-control.role":"app"},
|
||||
"ExposedPorts":{"8080/tcp":{},"9091/tcp":{}},
|
||||
"Volumes":{"/data":{},"/custom":{}}
|
||||
}`),
|
||||
}
|
||||
config, err := buildReplacementConfig(container, oldImage, targetImage, "registry.example/app:v2.0.0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, exists := config["Hostname"]; exists {
|
||||
t.Fatal("generated container hostname was copied")
|
||||
}
|
||||
var image, user string
|
||||
var entrypoint, environment []string
|
||||
var labels map[string]string
|
||||
var ports, volumes map[string]any
|
||||
_ = json.Unmarshal(config["Image"], &image)
|
||||
_ = json.Unmarshal(config["User"], &user)
|
||||
_ = json.Unmarshal(config["Entrypoint"], &entrypoint)
|
||||
_ = json.Unmarshal(config["Env"], &environment)
|
||||
_ = json.Unmarshal(config["Labels"], &labels)
|
||||
_ = json.Unmarshal(config["ExposedPorts"], &ports)
|
||||
_ = json.Unmarshal(config["Volumes"], &volumes)
|
||||
if image != "registry.example/app:v2.0.0" || user != runtimeUser || len(entrypoint) != 1 || entrypoint[0] != "/new-entrypoint" {
|
||||
t.Fatalf("new image defaults were not retained: image=%q user=%q entrypoint=%v", image, user, entrypoint)
|
||||
}
|
||||
if !contains(environment, "PATH=/new") || !contains(environment, "NEW_DEFAULT=1") || !contains(environment, "CUSTOM=yes") || contains(environment, "OLD_DEFAULT=1") {
|
||||
t.Fatalf("environment was merged incorrectly: %v", environment)
|
||||
}
|
||||
if labels[labelImageVersion] != "v2.0.0" || labels["image-owned"] != "new" || labels["com.docker.compose.project"] != "domain-control" || labels["de.j-mahler.domain-control.role"] != "app" {
|
||||
t.Fatalf("labels were merged incorrectly: %#v", labels)
|
||||
}
|
||||
if _, exists := ports["9091/tcp"]; !exists {
|
||||
t.Fatalf("custom exposed port was lost: %#v", ports)
|
||||
}
|
||||
if _, exists := volumes["/custom"]; !exists {
|
||||
t.Fatalf("custom volume was lost: %#v", volumes)
|
||||
}
|
||||
}
|
||||
|
||||
func compatibleImage(version, minimum string) imageInspect {
|
||||
config := map[string]any{
|
||||
"User": runtimeUser,
|
||||
"Entrypoint": []string{runtimeEntrypoint},
|
||||
"Cmd": []string{runtimeCommand},
|
||||
"WorkingDir": runtimeWorkingDirectory,
|
||||
"ExposedPorts": map[string]any{
|
||||
"8080/tcp": map[string]any{},
|
||||
},
|
||||
"Healthcheck": map[string]any{
|
||||
"Test": []string{"CMD", runtimeEntrypoint, "healthcheck"},
|
||||
},
|
||||
"Labels": map[string]string{
|
||||
labelImageVersion: version,
|
||||
labelMinimumVersion: minimum,
|
||||
labelMinimumAgent: strconv.Itoa(buildinfo.MinimumAgentProtocol),
|
||||
labelRuntimeContract: strconv.Itoa(buildinfo.RuntimeContractVersion),
|
||||
labelDatabaseContract: strconv.Itoa(buildinfo.DatabaseContractVersion),
|
||||
labelSchemaVersion: strconv.Itoa(buildinfo.CurrentSchemaVersion),
|
||||
},
|
||||
}
|
||||
encoded, _ := json.Marshal(config)
|
||||
return imageInspect{ID: "sha256:target", Config: encoded}
|
||||
}
|
||||
|
||||
func withImageLabel(t *testing.T, raw json.RawMessage, key, value string) json.RawMessage {
|
||||
t.Helper()
|
||||
var config map[string]any
|
||||
if err := json.Unmarshal(raw, &config); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
labels, ok := config["Labels"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("image labels are missing")
|
||||
}
|
||||
labels[key] = value
|
||||
encoded, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
|
||||
func contains(values []string, expected string) bool {
|
||||
for _, value := range values {
|
||||
if value == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
Socket string
|
||||
DockerSocket string
|
||||
ImageRepository string
|
||||
TargetLabel string
|
||||
}
|
||||
|
||||
func ServerConfigFromEnvironment() ServerConfig {
|
||||
return ServerConfig{
|
||||
Socket: environmentValue("UPDATE_AGENT_SOCKET", "/run/domain-control-update/agent.sock"),
|
||||
DockerSocket: environmentValue("UPDATE_DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||
ImageRepository: environmentValue("UPDATE_IMAGE_REPOSITORY", defaultImageRepository),
|
||||
TargetLabel: environmentValue("UPDATE_TARGET_LABEL", "de.j-mahler.domain-control.role=app"),
|
||||
}
|
||||
}
|
||||
|
||||
func Serve(ctx context.Context, config ServerConfig, logger *slog.Logger) error {
|
||||
if err := validateServerConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
directory := filepath.Dir(config.Socket)
|
||||
if err := os.MkdirAll(directory, 0o711); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Lstat(directory)
|
||||
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
||||
return errors.New("update agent socket directory is not a regular directory")
|
||||
}
|
||||
if err := os.Chmod(directory, 0o711); err != nil {
|
||||
return err
|
||||
}
|
||||
if info, err := os.Lstat(config.Socket); err == nil {
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return errors.New("update agent socket path is occupied by a non-socket file")
|
||||
}
|
||||
if err := os.Remove(config.Socket); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
listener, err := net.Listen("unix", config.Socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
defer os.Remove(config.Socket)
|
||||
// The agent container runs as uid 0 with gid 10001. Group-only access lets
|
||||
// the unprivileged web process connect without exposing the control socket.
|
||||
if err := os.Chmod(config.Socket, 0o660); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runner := NewDockerRunner(config.DockerSocket, config.ImageRepository, config.TargetLabel, logger)
|
||||
controller := NewController(runner, logger)
|
||||
server := &http.Server{
|
||||
Handler: controller.Handler(),
|
||||
ReadHeaderTimeout: 3 * time.Second,
|
||||
ReadTimeout: 5 * time.Second,
|
||||
WriteTimeout: 5 * time.Second,
|
||||
IdleTimeout: 15 * time.Second,
|
||||
MaxHeaderBytes: 8 << 10,
|
||||
}
|
||||
serverErrors := make(chan error, 1)
|
||||
go func() {
|
||||
logger.Info("update agent ready", "socket", config.Socket)
|
||||
serverErrors <- server.Serve(listener)
|
||||
}()
|
||||
select {
|
||||
case err := <-serverErrors:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return server.Shutdown(shutdownCtx)
|
||||
}
|
||||
}
|
||||
|
||||
func validateServerConfig(config ServerConfig) error {
|
||||
socket := filepath.Clean(config.Socket)
|
||||
dockerSocket := filepath.Clean(config.DockerSocket)
|
||||
if !filepath.IsAbs(socket) || filepath.Dir(socket) == "/" {
|
||||
return errors.New("update agent socket must use a dedicated absolute directory")
|
||||
}
|
||||
if !filepath.IsAbs(dockerSocket) || socket == dockerSocket {
|
||||
return errors.New("Docker socket path is invalid")
|
||||
}
|
||||
if config.ImageRepository == "" || strings.ContainsAny(config.ImageRepository, " \t\r\n") {
|
||||
return errors.New("update image repository is invalid")
|
||||
}
|
||||
if len(config.TargetLabel) == 0 || len(config.TargetLabel) > 256 || !strings.Contains(config.TargetLabel, "=") {
|
||||
return errors.New("update target label is invalid")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func environmentValue(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package updateagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateServerConfigRejectsUnsafeSocketPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
valid := ServerConfig{
|
||||
Socket: "/run/domain-control-update/agent.sock",
|
||||
DockerSocket: "/var/run/docker.sock",
|
||||
ImageRepository: defaultImageRepository,
|
||||
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||
}
|
||||
if err := validateServerConfig(valid); err != nil {
|
||||
t.Fatalf("valid config rejected: %v", err)
|
||||
}
|
||||
for _, socket := range []string{"agent.sock", "/agent.sock", "/var/run/docker.sock"} {
|
||||
candidate := valid
|
||||
candidate.Socket = socket
|
||||
if err := validateServerConfig(candidate); err == nil {
|
||||
t.Errorf("unsafe socket path %q was accepted", socket)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeDoesNotDeleteRegularFileAtSocketPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
socket := filepath.Join(directory, "agent.sock")
|
||||
if err := os.WriteFile(socket, []byte("keep"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := ServerConfig{
|
||||
Socket: socket,
|
||||
DockerSocket: "/var/run/docker.sock",
|
||||
ImageRepository: defaultImageRepository,
|
||||
TargetLabel: "de.j-mahler.domain-control.role=app",
|
||||
}
|
||||
err := Serve(context.Background(), config, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err == nil {
|
||||
t.Fatal("regular file at the socket path was silently replaced")
|
||||
}
|
||||
content, readErr := os.ReadFile(socket)
|
||||
if readErr != nil || string(content) != "keep" {
|
||||
t.Fatalf("regular file was changed: content=%q err=%v", content, readErr)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user