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

+110 -52
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"time"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/diagnostics"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/hetzner"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/ipresolve"
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/model"
@@ -21,7 +22,10 @@ var (
ErrDisabled = errors.New("the updater is disabled")
)
const defaultRetryInitial = 5 * time.Second
const (
defaultRetryInitial = 5 * time.Second
defaultRetryMaximum = time.Minute
)
type ipResolver interface {
ResolveWithIPv6(ctx context.Context, ipv4Endpoints, ipv6Endpoints []string, ipv6Enabled bool) ipresolve.Result
@@ -37,6 +41,8 @@ type Scheduler struct {
running atomic.Bool
nextRun atomic.Int64
wake chan struct{}
changes chan struct{}
done chan struct{}
retryMin time.Duration
retryMax time.Duration
}
@@ -49,14 +55,18 @@ func New(database *store.Store, provider *hetzner.Client, resolver *ipresolve.Re
cipher: cipher,
logger: logger,
wake: make(chan struct{}, 1),
changes: make(chan struct{}, 1),
done: make(chan struct{}),
retryMin: defaultRetryInitial,
}
}
func (s *Scheduler) Start(ctx context.Context) {
go func() {
defer close(s.done)
defer s.nextRun.Store(0)
consecutiveFailures := 0
var failureSince, networkLoggedAt time.Time
for {
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -86,10 +96,24 @@ func (s *Scheduler) Start(ctx context.Context) {
if settings.UpdaterEnabled {
_, runErr := s.Run(ctx)
if runErr == nil {
if consecutiveFailures > 0 {
diagnostics.Record(diagnostics.Event{Code: "update_recovered", Target: "updater", Attempt: consecutiveFailures, DurationMS: time.Since(failureSince).Milliseconds()})
}
consecutiveFailures = 0
} else if !errors.Is(runErr, ErrRunInProgress) && !errors.Is(runErr, ErrDisabled) {
} else if errors.Is(runErr, ErrRunInProgress) {
retryAfter = time.Second
} else if !errors.Is(runErr, ErrDisabled) {
consecutiveFailures++
if consecutiveFailures == 1 {
failureSince = time.Now()
}
retryAfter = s.retryDelay(consecutiveFailures, wait)
event := diagnostics.Event{Code: "update_failed", Target: "updater", Failure: diagnostics.Failure(runErr), Attempt: consecutiveFailures, RetrySeconds: int64(retryAfter / time.Second)}
if consecutiveFailures == 1 || time.Since(networkLoggedAt) >= 15*time.Minute {
event.Network = diagnostics.CurrentNetwork()
networkLoggedAt = time.Now()
}
diagnostics.Record(event)
s.logger.Warn("scheduled update run failed", "error", runErr, "retry_in", retryAfter)
}
} else {
@@ -112,7 +136,6 @@ func (s *Scheduler) Start(ctx context.Context) {
timer.Stop()
return
case <-s.wake:
consecutiveFailures = 0
if !timer.Stop() {
select {
case <-timer.C:
@@ -130,8 +153,12 @@ func (s *Scheduler) retryDelay(failures int, scheduledWait time.Duration) time.D
if minimum <= 0 {
minimum = defaultRetryInitial
}
if s.retryMax > 0 && s.retryMax < maximum {
maximum = s.retryMax
retryMax := s.retryMax
if retryMax <= 0 {
retryMax = defaultRetryMaximum
}
if retryMax < maximum {
maximum = retryMax
}
if maximum < minimum {
maximum = minimum
@@ -167,11 +194,40 @@ func (s *Scheduler) NextRunAt() time.Time {
return time.Unix(value, 0).UTC()
}
// LockChanges serializes a complete WebUI mutation (provider and database)
// with updater snapshots. Callers must hold it until the local change is saved.
func (s *Scheduler) LockChanges(ctx context.Context) (func(), error) {
if err := ctx.Err(); err != nil {
return nil, err
}
select {
case s.changes <- struct{}{}:
return func() { <-s.changes }, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
func (s *Scheduler) Wait(ctx context.Context) error {
select {
case <-s.done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s *Scheduler) Run(ctx context.Context) (string, error) {
if !s.running.CompareAndSwap(false, true) {
return "", ErrRunInProgress
}
defer s.running.Store(false)
select {
case s.changes <- struct{}{}:
defer func() { <-s.changes }()
default:
return "", ErrRunInProgress
}
settings, err := s.store.GetSettings(ctx)
if err != nil {
@@ -234,6 +290,7 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
}
if err := s.syncZoneTargets(ctx, group, ipResult.IPv4, ipResult.IPv6, settings.IPv6Enabled, &updated, &failed); err != nil {
failed += len(group)
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", RunID: runID, Failure: diagnostics.Failure(err), HTTPStatus: diagnostics.HTTPStatus(err)})
s.logger.Warn("sync DNS zone", "zone", group[0].Zone.Name, "error", err)
for _, target := range group {
_ = s.store.UpdateTargetResult(ctx, target.Target.ID, "Fehler", safeError(err), false)
@@ -261,11 +318,16 @@ func (s *Scheduler) Run(ctx context.Context) (string, error) {
}
func (s *Scheduler) finishRun(ctx context.Context, runID int64, status, ipv4, ipv6, summary string, runErr error) (string, error) {
_ = s.store.FinishUpdateRun(ctx, runID, status, ipv4, ipv6, summary)
_ = s.store.UpdateRunState(ctx, ipv4, ipv6, status)
if status != "ok" {
_ = s.store.AddAudit(ctx, "update."+status, summary)
// Persist cancellation too; using the canceled run context leaves history
// stuck at "running". Keep cleanup bounded during shutdown.
finishCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
if err := s.store.CompleteUpdateRun(finishCtx, runID, status, ipv4, ipv6, summary); err != nil {
s.logger.Error("persist update result", "run_id", runID, "error", err)
runErr = errors.Join(runErr, fmt.Errorf("Updater-Ergebnis konnte nicht gespeichert werden: %w", err))
status = "error"
}
diagnostics.Record(diagnostics.Event{Code: "update_run_finished", Target: "updater", RunID: runID, Status: status, Failure: diagnostics.Failure(runErr), HTTPStatus: diagnostics.HTTPStatus(runErr)})
return summary, runErr
}
@@ -287,46 +349,53 @@ func (s *Scheduler) syncZoneTargets(ctx context.Context, targets []store.Managed
}
for _, item := range targets {
targetUpdated, status, targetErr := s.syncTarget(ctx, token, zone, item.Target, byKey, ipv4, ipv6, ipv6Enabled)
if targetErr != nil {
*failed = *failed + 1
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), false)
continue
}
if targetUpdated {
*updated = *updated + 1
}
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated)
if targetErr != nil {
*failed = *failed + 1
diagnostics.Record(diagnostics.Event{Code: "provider_update_failed", Target: "api.hetzner.cloud", TargetID: item.Target.ID, ActionID: diagnostics.ActionID(targetErr), Failure: diagnostics.Failure(targetErr), HTTPStatus: diagnostics.HTTPStatus(targetErr)})
_ = s.store.UpdateTargetResult(ctx, item.Target.ID, "Fehler", safeError(targetErr), targetUpdated)
continue
}
if err := s.store.UpdateTargetResult(ctx, item.Target.ID, status, "", targetUpdated); err != nil {
return err
}
}
return s.store.MarkZoneSynced(ctx, zone.ID, "")
}
func (s *Scheduler) syncTarget(ctx context.Context, token string, zone model.Zone, target model.Target, rrsets map[string]hetzner.RRSet, ipv4, ipv6 string, ipv6Enabled bool) (bool, string, error) {
var states []string
var familyErrors []error
updated := false
if target.UpdateIPv4 {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "A")], "A", ipv4)
if err != nil {
return false, "", err
for _, family := range []struct {
enabled bool
recordType, ip string
}{
{target.UpdateIPv4, "A", ipv4},
{target.UpdateIPv6, "AAAA", ipv6},
} {
if !family.enabled {
continue
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 {
if !ipv6Enabled {
if family.recordType == "AAAA" && !ipv6Enabled {
states = append(states, "AAAA global deaktiviert")
continue
}
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, family.recordType)], family.recordType, family.ip)
updated = updated || changed
if err != nil {
familyErrors = append(familyErrors, fmt.Errorf("%s: %w", family.recordType, err))
states = append(states, family.recordType+" Fehler")
} else {
changed, state, err := s.syncFamily(ctx, token, zone, target, rrsets[rrKey(target.RecordName, "AAAA")], "AAAA", ipv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
}
if len(states) == 0 {
return false, "Deaktivierte IP-Familien", nil
}
return updated, strings.Join(states, " · "), nil
return updated, strings.Join(states, " · "), errors.Join(familyErrors...)
}
func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zone, target model.Target, rrset hetzner.RRSet, recordType, ip string) (bool, string, error) {
@@ -360,7 +429,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
if current.Value == ip {
if current.Comment != target.Description {
if err := s.provider.UpdateRecordComments(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: current.Value, Comment: target.Description}}); err != nil {
return false, "", err
return ttlChanged, "", err
}
status := recordType + " Kommentar synchronisiert"
if ttlChanged {
@@ -374,7 +443,7 @@ func (s *Scheduler) syncFamily(ctx context.Context, token string, zone model.Zon
return false, recordType + " unverändert", nil
}
if err := s.provider.SetRRSetRecords(ctx, token, zone.ProviderID, target.RecordName, recordType, []hetzner.Record{{Value: ip, Comment: target.Description}}); err != nil {
return false, "", err
return ttlChanged, "", err
}
status := recordType + " aktualisiert"
if ttlChanged {
@@ -551,28 +620,12 @@ func (s *Scheduler) SyncStaticTarget(ctx context.Context, target model.Target) (
byKey[rrKey(rrset.Name, rrset.Type)] = rrset
}
}
updated := false
var states []string
if target.UpdateIPv4 && target.StaticIPv4 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "A")], "A", target.StaticIPv4)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if target.UpdateIPv6 && target.StaticIPv6 != "" {
changed, state, err := s.syncFamily(ctx, token, zone, target, byKey[rrKey(target.RecordName, "AAAA")], "AAAA", target.StaticIPv6)
if err != nil {
return false, "", err
}
updated = updated || changed
states = append(states, state)
}
if len(states) == 0 {
target.UpdateIPv4 = target.UpdateIPv4 && target.StaticIPv4 != ""
target.UpdateIPv6 = target.UpdateIPv6 && target.StaticIPv6 != ""
if !target.UpdateIPv4 && !target.UpdateIPv6 {
return false, "Keine feste IP eingetragen", nil
}
return updated, strings.Join(states, " · "), nil
return s.syncTarget(ctx, token, zone, target, byKey, target.StaticIPv4, target.StaticIPv6, true)
}
// DeleteTargetFamilies removes selected A/AAAA RRSets while keeping the local
@@ -859,6 +912,11 @@ func (s *Scheduler) zoneToken(ctx context.Context, zoneID int64) (model.Zone, st
}
func (s *Scheduler) ValidateEndpoints(ctx context.Context, endpoints []string) error {
ctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
if len(endpoints) > 8 {
return errors.New("höchstens acht IP-Endpoints pro Familie erlaubt")
}
for _, endpoint := range endpoints {
if err := s.resolver.ValidateEndpoint(ctx, endpoint); err != nil {
return fmt.Errorf("%s: %w", endpoint, err)
+191 -4
View File
@@ -11,6 +11,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -22,6 +23,187 @@ import (
"github.com/root-kdw12/hetzner-api-dyndns-records-webinterface/internal/store"
)
type resolverFunc func(context.Context) ipresolve.Result
func (f resolverFunc) ResolveWithIPv6(ctx context.Context, _, _ []string, _ bool) ipresolve.Result {
return f(ctx)
}
func (f resolverFunc) ValidateEndpoint(context.Context, string) error { return nil }
func TestOneFailedFamilyDoesNotBlockTheOther(t *testing.T) {
for _, protectedFamily := range []string{"A", "AAAA"} {
t.Run(protectedFamily, func(t *testing.T) {
var writes atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/"+protectedFamily+"/") {
t.Error("wrote protected family")
}
writes.Add(1)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}))
defer api.Close()
ttl := 60
rrsets := map[string]hetzner.RRSet{}
for _, family := range []string{"A", "AAAA"} {
rrset := hetzner.RRSet{Name: "home", Type: family, TTL: &ttl, Records: []hetzner.Record{{Value: "old"}}}
rrset.Protection.Change = family == protectedFamily
rrsets[rrKey("home", family)] = rrset
}
schedule := New(nil, hetzner.New(api.URL), nil, nil, nil)
changed, status, err := schedule.syncTarget(context.Background(), "token", model.Zone{ProviderID: "123"}, model.Target{RecordName: "home", TTL: 60, UpdateIPv4: true, UpdateIPv6: true}, rrsets, "198.51.100.42", "2001:db8::42", true)
if !changed || err == nil || !strings.Contains(err.Error(), protectedFamily+":") || !strings.Contains(status, "aktualisiert") || writes.Load() != 1 {
t.Fatalf("partial update lost: changed=%v status=%q err=%v writes=%d", changed, status, err, writes.Load())
}
})
}
}
func TestRunPersistsConfirmedPartialTargetChanges(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
database, err := store.Open(ctx, filepath.Join(dir, "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
cipher, err := secure.LoadOrCreateCipher(filepath.Join(dir, "secrets", "key"))
if err != nil {
t.Fatal(err)
}
ciphertext, nonce, err := cipher.Encrypt("token")
if err != nil {
t.Fatal(err)
}
credential, err := database.CreateCredential(ctx, "test", ciphertext, nonce, cipher.Fingerprint("token"))
if err != nil {
t.Fatal(err)
}
zone, err := database.CreateZone(ctx, credential.ID, "123", "example.org")
if err != nil {
t.Fatal(err)
}
target, err := database.CreateTarget(ctx, model.Target{ZoneID: zone.ID, RecordName: "home", FQDN: "home.example.org", Active: true, UpdateIPv4: true, UpdateIPv6: true, TTL: 60})
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(ctx, 300, true, true, nil, nil); err != nil {
t.Fatal(err)
}
var writes atomic.Int32
api := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
_, _ = w.Write([]byte(`{"rrsets":[{"name":"home","type":"A","ttl":60,"protection":{"change":true},"records":[{"value":"198.51.100.1"}]},{"name":"home","type":"AAAA","ttl":60,"records":[{"value":"2001:db8::1"}]}]}`))
} else {
if r.URL.Path != "/zones/123/rrsets/home/AAAA/actions/set_records" {
t.Errorf("unexpected write: %s", r.URL.Path)
}
writes.Add(1)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
}
}))
defer api.Close()
worker := New(database, hetzner.New(api.URL), nil, cipher, slog.New(slog.NewTextHandler(io.Discard, nil)))
worker.resolver = resolverFunc(func(context.Context) ipresolve.Result {
return ipresolve.Result{IPv4: "198.51.100.42", IPv6: "2001:db8::42"}
})
summary, err := worker.Run(ctx)
if err == nil || summary != "1 Ziel(e) aktualisiert, 1 Fehler" || writes.Load() != 1 {
t.Fatalf("incorrect partial result: %q %v writes=%d", summary, err, writes.Load())
}
target, err = database.GetTarget(ctx, target.ID)
if err != nil || target.LastUpdatedAt.IsZero() || target.LastError == "" {
t.Fatalf("confirmed change or error lost: %#v %v", target, err)
}
runs, err := database.RecentRuns(ctx, 1)
if err != nil || len(runs) != 1 || runs[0].Status != "partial" {
t.Fatalf("wrong partial run status: %#v %v", runs, err)
}
}
func TestCanceledRunFinishesHistoryAndReleasesWorker(t *testing.T) {
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
ctx, cancel := context.WithCancel(context.Background())
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { cancel(); return ipresolve.Result{IPv4Err: context.Canceled} })
if _, err := schedule.Run(ctx); !errors.Is(err, context.Canceled) {
t.Fatalf("lost cancellation: %v", err)
}
runs, err := database.RecentRuns(context.Background(), 10)
if err != nil || len(runs) != 1 || runs[0].Status != "error" || runs[0].FinishedAt.IsZero() || schedule.IsRunning() {
t.Fatalf("canceled run stuck: %#v err=%v", runs, err)
}
schedule.resolver = resolverFunc(func(context.Context) ipresolve.Result { return ipresolve.Result{IPv4: "198.51.100.42"} })
if _, err := schedule.Run(context.Background()); err != nil {
t.Fatalf("worker did not recover: %v", err)
}
}
func TestDNSChangesAndUpdaterCannotOverlap(t *testing.T) {
database, err := store.Open(context.Background(), filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
settings, err := database.GetSettings(context.Background())
if err != nil {
t.Fatal(err)
}
if err := database.UpdateSettings(context.Background(), 300, true, false, settings.IPv4Endpoints, settings.IPv6Endpoints); err != nil {
t.Fatal(err)
}
schedule := New(database, nil, nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
unlock, err := schedule.LockChanges(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := schedule.Run(context.Background()); !errors.Is(err, ErrRunInProgress) {
t.Fatalf("updater entered active edit: %v", err)
}
unlock()
entered := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
schedule.resolver = resolverFunc(func(ctx context.Context) ipresolve.Result {
close(entered)
<-ctx.Done()
return ipresolve.Result{IPv4Err: ctx.Err()}
})
schedule.Start(ctx)
select {
case <-entered:
case <-time.After(time.Second):
t.Fatal("worker did not start")
}
waitCtx, stop := context.WithTimeout(context.Background(), 10*time.Millisecond)
defer stop()
if release, err := schedule.LockChanges(waitCtx); err == nil {
release()
t.Fatal("edit entered active updater")
}
cancel()
shutdownCtx, stopShutdown := context.WithTimeout(context.Background(), time.Second)
defer stopShutdown()
if err := schedule.Wait(shutdownCtx); err != nil {
t.Fatal(err)
}
release, err := schedule.LockChanges(shutdownCtx)
if err != nil {
t.Fatalf("shutdown left lock held: %v", err)
}
release()
}
type retryResolver struct {
calls atomic.Int32
recovered chan struct{}
@@ -238,7 +420,7 @@ func TestSchedulerRetriesFailedStartupResolutionWithoutWaitingForFullInterval(t
}
}
func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
func TestRetryDelayStaysWithinOneMinuteEvenWithLongSchedule(t *testing.T) {
t.Parallel()
schedule := &Scheduler{retryMin: 5 * time.Second}
tests := []struct {
@@ -253,7 +435,7 @@ func TestRetryDelayBacksOffToTheConfiguredSchedule(t *testing.T) {
{failures: 20, want: time.Minute},
}
for _, test := range tests {
if got := schedule.retryDelay(test.failures, time.Minute); got != test.want {
if got := schedule.retryDelay(test.failures, time.Hour); got != test.want {
t.Errorf("retry delay after %d failures = %s, want %s", test.failures, got, test.want)
}
}
@@ -296,6 +478,7 @@ func TestSyncTargetTTLUpdatesExistingRRSet(t *testing.T) {
}
changes <- body.TTL
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
@@ -359,6 +542,7 @@ func TestSyncStaticTargetWritesManualAddress(t *testing.T) {
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/home/A/actions/set_records":
actions <- r.URL.Path
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
@@ -424,6 +608,7 @@ func TestSyncZoneTTLUpdatesManagedAddressAndCNAMERecords(t *testing.T) {
case "/zones/123/rrsets/home/A/actions/change_ttl", "/zones/123/rrsets/www/CNAME/actions/change_ttl":
actions <- r.URL.Path
w.WriteHeader(http.StatusCreated)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
@@ -492,7 +677,8 @@ func TestDeleteTargetFamiliesAndGlobalIPv6Cleanup(t *testing.T) {
return
}
deletions <- r.URL.Path
w.WriteHeader(http.StatusNoContent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}
@@ -573,7 +759,8 @@ func TestDeleteZoneRecordsRemovesOnlyLocallyRepresentedRRSets(t *testing.T) {
return
}
deletions <- r.URL.Path
w.WriteHeader(http.StatusNoContent)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"action":{"id":42,"status":"success"}}`))
default:
http.Error(w, "unexpected request", http.StatusBadRequest)
}