fix: harden DynDNS recovery and publish v1.5.1
This commit is contained in:
1 parent
3383b91e49
commit
81def77e3c
37 files changed
+2415
-250
No files matched your search
@@ -14,13 +14,14 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/buildinfo"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/secure"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
|
||||
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/updateprotocol"
|
||||
)
|
||||
|
||||
var ErrCredentialMissing = errors.New("Update-Zugang ist noch nicht eingerichtet")
|
||||
var ErrCredentialMissing = errors.New("Release ist nicht öffentlich abrufbar (privates Repository oder kein veröffentlichtes Release); für ein privates Repository den Update-Zugang hinterlegen")
|
||||
|
||||
const (
|
||||
agentUnavailableRetryInterval = 2 * time.Second
|
||||
@@ -198,18 +199,19 @@ func (m *Manager) CheckNow(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if settings.RegistryUsername == "" || len(settings.TokenCiphertext) == 0 || len(settings.TokenNonce) == 0 {
|
||||
m.setStoredState(settings)
|
||||
return ErrCredentialMissing
|
||||
// Public releases must remain usable even with missing or stale credentials.
|
||||
release, err := m.fetchLatest(ctx, "", "")
|
||||
if errors.Is(err, ErrCredentialMissing) && settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||
credential, decryptErr := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if decryptErr != nil {
|
||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||
}
|
||||
release, err = m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
||||
}
|
||||
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err != nil {
|
||||
return m.recordFailure(ctx, errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden"))
|
||||
}
|
||||
release, err := m.fetchLatest(ctx, settings.RegistryUsername, credential)
|
||||
if err != nil {
|
||||
return m.recordFailure(ctx, err)
|
||||
}
|
||||
diagnostics.Record(diagnostics.Event{Code: "release_check_ok", Target: "git"})
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateSuccess(ctx, release.TagName, release.HTMLURL, checkedAt); err != nil {
|
||||
return err
|
||||
@@ -247,14 +249,22 @@ func (m *Manager) RequestUpdate(ctx context.Context, version string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
credential, err := m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err != nil || settings.RegistryUsername == "" {
|
||||
return ErrCredentialMissing
|
||||
var username, credential string
|
||||
if settings.RegistryUsername != "" && len(settings.TokenCiphertext) > 0 {
|
||||
credential, err = m.cipher.Decrypt(settings.TokenCiphertext, settings.TokenNonce)
|
||||
if err == nil {
|
||||
username = settings.RegistryUsername
|
||||
} else if !status.AnonymousPull {
|
||||
return errors.New("gespeicherter Update-Zugang konnte nicht entschlüsselt werden")
|
||||
}
|
||||
}
|
||||
if username == "" && !status.AnonymousPull {
|
||||
return errors.New("für Updates ohne Anmeldung muss der Update-Agent einmal mit Docker Compose aktualisiert werden")
|
||||
}
|
||||
if err := m.store.SetDesiredSoftwareVersion(ctx, version); err != nil {
|
||||
return err
|
||||
}
|
||||
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: settings.RegistryUsername, RegistryToken: credential}
|
||||
request := updateprotocol.Request{Version: version, CurrentVersion: state.CurrentVersion, RegistryUsername: username, RegistryToken: credential}
|
||||
if err := m.agent.Start(ctx, request); err != nil {
|
||||
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
m.clearDesiredVersion(cleanupCtx, version)
|
||||
@@ -278,7 +288,7 @@ func (m *Manager) UpdateStatus(ctx context.Context) (updateprotocol.Status, erro
|
||||
m.state.AgentVersion = status.AgentVersion
|
||||
m.state.AgentProtocol = status.Protocol
|
||||
m.state.AgentCompatible = agentCompatible(status)
|
||||
m.state.ComposeSyncRequired = !m.state.AgentCompatible
|
||||
m.state.ComposeSyncRequired = !m.state.AgentCompatible || (!m.state.CredentialConfigured && !status.AnonymousPull)
|
||||
} else {
|
||||
m.state.AgentCompatible = false
|
||||
m.state.ComposeSyncRequired = false
|
||||
@@ -299,7 +309,7 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string)
|
||||
if err != nil {
|
||||
return releaseResponse{}, err
|
||||
}
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||
if needsAuthentication(response.StatusCode) && username != "" && credential != "" {
|
||||
response.Body.Close()
|
||||
response, err = m.fetchLatestWithAuth(ctx, username, credential, false)
|
||||
if err != nil {
|
||||
@@ -307,11 +317,14 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string)
|
||||
}
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden {
|
||||
return releaseResponse{}, errors.New("Gitea-Zugang wurde abgelehnt")
|
||||
if needsAuthentication(response.StatusCode) {
|
||||
if username == "" || credential == "" {
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: ErrCredentialMissing}
|
||||
}
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: errors.New("Gitea-Zugang abgelehnt oder kein sichtbares veröffentlichtes Release vorhanden")}
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return releaseResponse{}, fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)
|
||||
return releaseResponse{}, &releaseStatusError{status: response.StatusCode, err: fmt.Errorf("Git-Server antwortet mit HTTP %d", response.StatusCode)}
|
||||
}
|
||||
var release releaseResponse
|
||||
if err := json.NewDecoder(io.LimitReader(response.Body, 128<<10)).Decode(&release); err != nil {
|
||||
@@ -324,25 +337,46 @@ func (m *Manager) fetchLatest(ctx context.Context, username, credential string)
|
||||
return release, nil
|
||||
}
|
||||
|
||||
func needsAuthentication(status int) bool {
|
||||
// Gitea also conceals private repositories using HTTP 404.
|
||||
return status == http.StatusUnauthorized || status == http.StatusForbidden || status == http.StatusNotFound
|
||||
}
|
||||
|
||||
func (m *Manager) fetchLatestWithAuth(ctx context.Context, username, credential string, basic bool) (*http.Response, error) {
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, m.apiURL, nil)
|
||||
if err != nil {
|
||||
return nil, errors.New("Update-Endpunkt ist ungültig")
|
||||
}
|
||||
request.Header.Set("Accept", "application/json")
|
||||
if basic {
|
||||
request.SetBasicAuth(username, credential)
|
||||
} else {
|
||||
request.Header.Set("Authorization", "token "+credential)
|
||||
if username != "" && credential != "" {
|
||||
if basic {
|
||||
request.SetBasicAuth(username, credential)
|
||||
} else {
|
||||
request.Header.Set("Authorization", "token "+credential)
|
||||
}
|
||||
}
|
||||
request.Header.Set("User-Agent", "domain-control-update-checker/"+buildinfo.Version)
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, errors.New("Git-Server ist momentan nicht erreichbar")
|
||||
return nil, &releaseFetchError{err: err}
|
||||
}
|
||||
return response, nil
|
||||
}
|
||||
|
||||
type releaseFetchError struct{ err error }
|
||||
|
||||
func (e *releaseFetchError) Error() string { return "Git-Server ist momentan nicht erreichbar" }
|
||||
func (e *releaseFetchError) Unwrap() error { return e.err }
|
||||
|
||||
type releaseStatusError struct {
|
||||
status int
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *releaseStatusError) Error() string { return e.err.Error() }
|
||||
func (e *releaseStatusError) Unwrap() error { return e.err }
|
||||
func (e *releaseStatusError) HTTPStatusCode() int { return e.status }
|
||||
|
||||
func (m *Manager) safeReleaseURL(raw string) string {
|
||||
apiURL, apiErr := url.Parse(m.apiURL)
|
||||
releaseURL, releaseErr := url.Parse(raw)
|
||||
@@ -353,6 +387,7 @@ func (m *Manager) safeReleaseURL(raw string) string {
|
||||
}
|
||||
|
||||
func (m *Manager) recordFailure(ctx context.Context, checkErr error) error {
|
||||
diagnostics.Record(diagnostics.Event{Code: "release_check_failed", Target: "git", Failure: diagnostics.Failure(checkErr), HTTPStatus: diagnostics.HTTPStatus(checkErr)})
|
||||
checkedAt := time.Now().UTC()
|
||||
if err := m.store.RecordSoftwareUpdateFailure(ctx, checkErr.Error(), checkedAt); err != nil {
|
||||
return err
|
||||
@@ -383,7 +418,7 @@ func (m *Manager) setStoredState(settings model.SoftwareUpdateSettings) {
|
||||
state.AgentStatus = m.state.AgentStatus
|
||||
state.AgentVersion = m.state.AgentVersion
|
||||
state.AgentProtocol = m.state.AgentProtocol
|
||||
state.ComposeSyncRequired = state.AgentAvailable && !state.AgentCompatible
|
||||
state.ComposeSyncRequired = state.AgentAvailable && (!state.AgentCompatible || (!state.CredentialConfigured && !state.AgentStatus.AnonymousPull))
|
||||
m.state = state
|
||||
m.stateMu.Unlock()
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -65,6 +67,7 @@ func (a *fakeUpdateAgent) counts() (status, start int) {
|
||||
|
||||
func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
const credential = " test-password-or-token-that-must-stay-secret "
|
||||
var unavailable atomic.Bool
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -78,7 +81,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"tag_name":"v1.5.1","html_url":"https://example.invalid/releases/v1.5.1","draft":false,"prerelease":false}`))
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"html_url":%q,"draft":false,"prerelease":false}`, nextVersion, "https://example.invalid/releases/"+nextVersion)
|
||||
}))
|
||||
defer api.Close()
|
||||
|
||||
@@ -111,7 +114,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
||||
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError != "" || state.CredentialUsername != "registry-user" {
|
||||
t.Fatalf("new release was not detected: %#v", state)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
@@ -127,7 +130,7 @@ func TestManagerKeepsUpdateReminderWhenGiteaBecomesUnavailable(t *testing.T) {
|
||||
t.Fatal("unavailable Gitea endpoint was reported as successful")
|
||||
}
|
||||
state = manager.State()
|
||||
if !state.UpdateAvailable || state.LatestVersion != "v1.5.1" || state.LastError == "" {
|
||||
if !state.UpdateAvailable || state.LatestVersion != nextVersion || state.LastError == "" {
|
||||
t.Fatalf("temporary outage removed the last known update reminder: %#v", state)
|
||||
}
|
||||
}
|
||||
@@ -184,12 +187,13 @@ func TestAgentCompatibilityStartsAtStableV14Protocol(t *testing.T) {
|
||||
|
||||
func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||
manager, database := updateReadyManager(t, agent)
|
||||
if manager.State().AgentAvailable {
|
||||
t.Fatal("test precondition failed: cached agent state is already available")
|
||||
}
|
||||
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err != nil {
|
||||
if err := manager.RequestUpdate(context.Background(), nextVersion); err != nil {
|
||||
t.Fatalf("live agent was rejected because of stale cached state: %v", err)
|
||||
}
|
||||
statusCalls, startCalls := agent.counts()
|
||||
@@ -200,13 +204,14 @@ func TestRequestUpdateRefreshesStaleAgentStateBeforeStarting(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if settings.DesiredVersion != "v1.5.1" {
|
||||
if settings.DesiredVersion != nextVersion {
|
||||
t.Fatalf("target version was not persisted: %#v", settings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
status updateprotocol.Status
|
||||
@@ -222,7 +227,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
|
||||
t.Parallel()
|
||||
agent := &fakeUpdateAgent{status: test.status, statusErr: test.statusErr}
|
||||
manager, database := updateReadyManager(t, agent)
|
||||
if err := manager.RequestUpdate(context.Background(), "v1.5.1"); err == nil {
|
||||
if err := manager.RequestUpdate(context.Background(), nextVersion); err == nil {
|
||||
t.Fatal("unsafe update request was accepted")
|
||||
}
|
||||
_, startCalls := agent.counts()
|
||||
@@ -242,6 +247,7 @@ func TestRequestUpdateRejectsUnavailableIncompatibleAndBusyAgents(t *testing.T)
|
||||
|
||||
func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
agent := &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)}
|
||||
manager, _ := updateReadyManager(t, agent)
|
||||
results := make(chan error, 2)
|
||||
@@ -249,7 +255,7 @@ func TestRequestUpdateSerializesParallelClicks(t *testing.T) {
|
||||
for index := 0; index < 2; index++ {
|
||||
go func() {
|
||||
<-start
|
||||
results <- manager.RequestUpdate(context.Background(), "v1.5.1")
|
||||
results <- manager.RequestUpdate(context.Background(), nextVersion)
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
@@ -337,6 +343,7 @@ func compatibleAgentStatus(phase string) updateprotocol.Status {
|
||||
|
||||
func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store) {
|
||||
t.Helper()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
ctx := context.Background()
|
||||
database, err := store.Open(ctx, filepath.Join(t.TempDir(), "app.db"))
|
||||
if err != nil {
|
||||
@@ -363,7 +370,7 @@ func updateReadyManager(t *testing.T, agent updateAgent) (*Manager, *store.Store
|
||||
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.RecordSoftwareUpdateSuccess(ctx, "v1.5.1", "https://git.example/releases/v1.5.1", time.Now()); err != nil {
|
||||
if err := database.RecordSoftwareUpdateSuccess(ctx, nextVersion, "https://git.example/releases/"+nextVersion, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(ctx)
|
||||
@@ -385,3 +392,110 @@ func waitForCondition(t *testing.T, timeout time.Duration, condition func() bool
|
||||
}
|
||||
t.Fatal("condition did not become true in time")
|
||||
}
|
||||
|
||||
func TestRepositoryCanSwitchPublicPrivateAndBack(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
var private atomic.Bool
|
||||
var authenticated atomic.Int32
|
||||
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !private.Load() {
|
||||
if r.Header.Get("Authorization") != "" {
|
||||
t.Error("credentials sent to public repository")
|
||||
}
|
||||
} else {
|
||||
user, password, ok := r.BasicAuth()
|
||||
if !ok || user != "registry-user" || password != "registry-password" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
authenticated.Add(1)
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `{"tag_name":%q,"draft":false,"prerelease":false}`, nextVersion)
|
||||
}))
|
||||
defer api.Close()
|
||||
manager, database := updateReadyManager(t, &fakeUpdateAgent{status: compatibleAgentStatus(updateprotocol.PhaseIdle)})
|
||||
manager.apiURL = api.URL
|
||||
manager.httpClient = api.Client()
|
||||
ctx := context.Background()
|
||||
if err := database.SaveSoftwareUpdateCredential(ctx, "", []byte{}, []byte{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.CheckNow(ctx); err != nil {
|
||||
t.Fatalf("public release requires login: %v", err)
|
||||
}
|
||||
private.Store(true)
|
||||
if err := manager.CheckNow(ctx); !errors.Is(err, ErrCredentialMissing) {
|
||||
t.Fatalf("missing private access not explained: %v", err)
|
||||
}
|
||||
if !manager.State().UpdateAvailable {
|
||||
t.Fatal("visibility change removed last known release")
|
||||
}
|
||||
if err := manager.SaveCredential(ctx, "registry-user", "registry-password"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.CheckNow(ctx); err != nil {
|
||||
t.Fatalf("private 404 did not retry credentials: %v", err)
|
||||
}
|
||||
private.Store(false)
|
||||
if err := database.SaveSoftwareUpdateCredential(ctx, "registry-user", []byte("corrupt"), []byte("corrupt")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.CheckNow(ctx); err != nil {
|
||||
t.Fatalf("public check depends on old secret: %v", err)
|
||||
}
|
||||
if authenticated.Load() != 1 {
|
||||
t.Fatalf("unexpected authenticated requests: %d", authenticated.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnonymousUpdateRequiresCapableAgent(t *testing.T) {
|
||||
t.Parallel()
|
||||
nextVersion := nextReleaseVersion(t)
|
||||
for _, capable := range []bool{false, true} {
|
||||
t.Run(strconv.FormatBool(capable), func(t *testing.T) {
|
||||
status := compatibleAgentStatus(updateprotocol.PhaseIdle)
|
||||
status.AnonymousPull = capable
|
||||
agent := &fakeUpdateAgent{status: status}
|
||||
manager, database := updateReadyManager(t, agent)
|
||||
if err := database.SaveSoftwareUpdateCredential(context.Background(), "", []byte{}, []byte{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings, err := database.GetSoftwareUpdateSettings(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager.setStoredState(settings)
|
||||
err = manager.RequestUpdate(context.Background(), nextVersion)
|
||||
if !capable {
|
||||
if err == nil || !strings.Contains(err.Error(), "Compose") || !manager.State().ComposeSyncRequired {
|
||||
t.Fatalf("legacy agent not handled: %v", err)
|
||||
}
|
||||
if len(agent.requests) != 0 {
|
||||
t.Fatal("legacy agent received incompatible anonymous request")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(agent.requests) != 1 || agent.requests[0].RegistryUsername != "" || agent.requests[0].RegistryToken != "" {
|
||||
t.Fatal("anonymous request not forwarded")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Keep update scenarios newer than the source fallback after every release bump.
|
||||
func nextReleaseVersion(t *testing.T) string {
|
||||
t.Helper()
|
||||
parts := strings.Split(strings.TrimPrefix(buildinfo.Version, "v"), ".")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("unexpected build version %q", buildinfo.Version)
|
||||
}
|
||||
patch, err := strconv.Atoi(parts[2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fmt.Sprintf("v%s.%s.%d", parts[0], parts[1], patch+1)
|
||||
}
|
||||
Reference in new issue
Block a user