fix: harden DynDNS recovery and publish v1.5.1

This commit is contained in:
admin committed 2026-10-10 02:26:47 +02:00
1 parent 3383b91e49
commit 81def77e3c
37 files changed
+2415 -250

No files matched your search

+59 -24
View File
@@ -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()
}