diff --git a/HOSTED_SERVICE.md b/HOSTED_SERVICE.md index 9f21e53..2b9df1a 100644 --- a/HOSTED_SERVICE.md +++ b/HOSTED_SERVICE.md @@ -259,3 +259,11 @@ The game persists each positive-tip event to an outbox and retries delivery to t When PayPal is disabled the public Customer Portal hides the purchase panel entirely. Its visible credit history is aggregated by UTC day/reason for readability; the raw ledger remains unchanged in the database. The private Customer Admin can also block/unblock accounts, stop individual/all workers, disable new logins, disable new registrations, configure registration Proof-of-Work and optionally enforce time-limited one-shot invite codes. Blocking an account deletes its active sessions and revokes Worker leases immediately. + +## Worker Recovery und Image-Rollout (V4.2.9) + +Hosted Worker erkennen stille/halb-offene Game-WebSockets nach spätestens etwa 90 Sekunden und verbinden sich mit Backoff neu. Ein abgelaufener Game-JWT wird mit derselben persistenten Worker-Identity automatisch erneuert. Auch ein noch nicht sofort verfügbarer Folge-Task wird wiederholt angefragt. + +Neue Worker-Container werden mit Docker `RestartPolicy=unless-stopped` erzeugt. Bewusstes Stoppen über das Portal/Admin bleibt gestoppt. Die interne Worker-Registrierung akzeptiert nur Worker mit aktivem `running`/`starting`-Lease, sodass die Restart-Policy keine Sperre umgeht. + +Im privaten Customer Admin kann das konfigurierte `CS_WORKER_IMAGE` explizit neu gepullt werden. Ein einzelner Worker oder alle Worker können anschließend mit dem neuen Image neu erzeugt werden. Named Identity Volumes werden dabei nicht entfernt; laufende Worker werden nach dem Update wieder gestartet, gestoppte bleiben gestoppt. diff --git a/README.md b/README.md index 8b4ffa5..fc18600 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +> **V4.2.9 Worker Recovery & Image Update:** Hosted Worker erkennen halb-offene Game-WebSockets per 90s Client-Deadline, erneuern abgelaufene Game-JWTs automatisch mit derselben persistenten P-256-Identity und wiederholen den Wechsel auf einen Folge-Task, bis er verfügbar ist. Hosted Worker fallen bei einem späteren Container-Neustart außerdem von einer inzwischen inaktiven gespeicherten Task-ID auf die serverseitig ausgewählte aktive Task zurück. Neue Worker-Container verwenden `RestartPolicy=unless-stopped`; eine explizite Lease-Sperre kann diese Recovery nicht umgehen. Im privaten Customer Admin kann `CS_WORKER_IMAGE` explizit neu gepullt und ein einzelner oder alle bestehenden Worker unter Beibehaltung ihrer Identity-Volumes neu erzeugt werden. Details: `V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md`. + > **V4.2.8 Admin Control & Ownership:** Customer-Admins können das Worker-Limit pro Benutzer aufheben und Worker direkt zuordnen. Server-Admins können Task-Serien als AUSLAUFEND markieren, Collectible-Eigentum ohne Umschreiben der historischen Gewinner-Provenienz übertragen und zufällige ADMIN-DROP-NFTs für bestehende Neural-Hunt-Identitäten erzeugen. Details: `V4.2.8_ADMIN_CONTROL_OWNERSHIP.md`. > **V4.2.7 Admin Live Guess Flash:** In der privaten Server-Admin-3D-Map kann `TIPPS %` optional den Score jedes tatsächlich ausgewerteten Tipps kurz am Client-Knoten einblenden – auch wenn er unterhalb des bisherigen Highscores liegt. Die flüchtigen Werte laufen ausschließlich über einen admin-authentifizierten WebSocket und werden nicht an Spieler oder das öffentliche Leaderboard gesendet. Details: `V4.2.7_ADMIN_GUESS_FLASH.md`. diff --git a/TESTING.md b/TESTING.md index b647acf..ea0453d 100644 --- a/TESTING.md +++ b/TESTING.md @@ -509,3 +509,13 @@ For production/open registration, do not rely on PoW as a one-human/one-account 3. Server Admin: set an active task to `AUSLAUFEND`, complete/close it, and verify there is no child task with `parent_task_id=`; if the active pool needs replacement, a new root task may appear. 4. Transfer a ready collectible to another client identity. Verify the new identity sees it under `MEINE NFTS`, the old identity no longer does, while the task's historical `winner_client_id` is unchanged. 5. Server Admin → Artifact: queue an `ADMIN NFT DROP` for a client. Verify the new `drop_*` task is `completed/pending`, artifact generation proceeds through the normal worker/circuit breaker, and the resulting collectible appears for the target identity without incrementing historical wins. + +## V4.2.9 Worker recovery / image rollout smoke tests + +1. Einen Hosted Worker starten und dessen Client-ID notieren. +2. Die Verbindung zwischen Worker und Game Server länger als 90 Sekunden unterbrechen. Nach Wiederherstellung muss derselbe Container/WebSocket ohne neue Identity wieder aktiv werden; bei einem abgelaufenen JWT erfolgt automatisch ein kryptografisches Re-Login. +3. Einen Task abschließen lassen und den Game Server kurz während der Successor-Erstellung/Abfrage stören. Der Worker muss den Taskwechsel weiter versuchen und darf nicht nach einem einzelnen Fehler ohne WebSocket stehen bleiben. +4. Einen Worker auf einer alten, inzwischen abgeschlossenen Task-ID neu starten. Für Hosted Worker muss der CLI auf die serverseitig ausgewählte aktive Task zurückfallen und dieselbe Identity weiterverwenden. +5. Im Customer Admin bei einem Worker `IMAGE UPDATE` ausführen. Vorher laufend -> danach wieder laufend; vorher gestoppt -> danach weiterhin gestoppt. `/identity/identity.json` und Worker-Client-ID müssen erhalten bleiben. +6. Bei einem mutable Tag (z. B. `worker_latest`) ein neues Registry-Image publizieren und `IMAGE LADEN · ALLE WORKER AKTUALISIEREN` verwenden. Docker muss den Tag erneut pullen, obwohl er lokal bereits existiert. +7. Einen Worker bewusst über Portal/Admin stoppen. Die Docker-Restart-Policy `unless-stopped` darf ihn nicht wieder starten. Bei gesperrtem Benutzer oder widerrufenem Lease muss `/internal/workers/register` mit 409 ablehnen. diff --git a/V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md b/V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md new file mode 100644 index 0000000..ac98c31 --- /dev/null +++ b/V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md @@ -0,0 +1,35 @@ +# V4.2.9 – Worker Recovery & Image Update + +## Automatische Wiederverbindung + +Hosted Worker erkennen jetzt auch halb-offene WebSocket-Verbindungen. Der Game Server sendet alle 30 Sekunden WebSocket-Pings; der CLI setzt zusätzlich eine 90-Sekunden-Read-Deadline. Bleibt eine Verbindung wegen VPN-, NAT-, WLAN- oder Netzwerkproblemen still, wird sie verworfen und mit exponentiellem Backoff neu aufgebaut. + +HTTP- und WebSocket-401 nach längerer Laufzeit lösen einmalig eine neue kryptografische Anmeldung mit derselben persistenten P-256-Identity aus. Dadurch kann ein abgelaufener JWT einen unattended Worker nicht dauerhaft blockieren. + +Wenn ein Task endet und der Folge-Task beim ersten Versuch noch nicht verfügbar ist, versucht der Worker den Wechsel jetzt weiter, statt nach einem einzigen fehlgeschlagenen Versuch ohne WebSocket stehen zu bleiben. + +Bei einem späteren Container-Neustart kann im Customer-Service-Datensatz noch die inzwischen abgeschlossene ursprüngliche Task-ID stehen. Ein Hosted Worker fällt in diesem Fall auf die vom Game Server markierte aktive Task (sonst die erste aktive Task) zurück, statt in einer `task not found`-Crash-Schleife zu landen. Die persistente Identity bleibt unverändert. + +Neu erzeugte Worker-Container verwenden außerdem die Docker-Restart-Policy `unless-stopped`. Ein Prozesscrash oder temporärer Startfehler kann damit automatisch heilen. Ein bewusstes Stoppen über Customer Portal/Admin bleibt gestoppt. Die interne Worker-Registrierung prüft den Lease-Status bereits vor dem Game-Start, damit die Restart-Policy keinen gesperrten oder gestoppten Worker wieder freischaltet. + +## Worker-Image aus Customer Admin aktualisieren + +Im privaten Customer-Service-Admin wird `CS_WORKER_IMAGE` angezeigt. + +- `IMAGE UPDATE` an einem Worker: zieht `CS_WORKER_IMAGE` explizit neu, entfernt nur den Container und erzeugt ihn mit demselben Named Identity Volume neu. Ein vorher laufender Worker wird wieder gestartet; ein gestoppter bleibt gestoppt. +- `IMAGE LADEN · ALLE WORKER AKTUALISIEREN`: zieht das Image einmal und migriert danach alle bestehenden Worker nacheinander. + +Das ist insbesondere für mutable Registry-Tags wie `worker_latest` gedacht. Anders als der normale Auto-Pull wird beim Admin-Update auch dann ein Pull ausgeführt, wenn derselbe Tag lokal bereits existiert. + +Die persistente Datei `/identity/identity.json` liegt weiterhin im Named Volume und wird beim Image-Update nicht gelöscht. Die Worker-Client-ID bleibt deshalb erhalten. + +## Deployment + +Für V4.2.9 müssen neu gebaut werden: + +- `Dockerfile.worker` +- `Dockerfile.customer-service` + +Der Game Server wurde für dieses Release nicht geändert. + +Bestehende Worker erhalten die neue Docker-Restart-Policy erst, nachdem sie über das Admin-Image-Update neu erzeugt wurden. Neu angelegte Worker besitzen sie automatisch. diff --git a/cmd/client/api.go b/cmd/client/api.go index 353d65d..5f59c86 100644 --- a/cmd/client/api.go +++ b/cmd/client/api.go @@ -18,6 +18,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/gorilla/websocket" @@ -64,12 +65,15 @@ func (e *apiError) Code() string { } type apiClient struct { - base string - hc *http.Client - token string - cid string - id identityFile - key *ecdsa.PrivateKey + base string + hc *http.Client + + authMu sync.Mutex + mu sync.RWMutex + token string + cid string + id identityFile + key *ecdsa.PrivateKey } func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient { @@ -81,7 +85,19 @@ func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient { } } -func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error { +func (c *apiClient) tokenValue() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.token +} + +func (c *apiClient) clientID() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.cid +} + +func (c *apiClient) doRaw(ctx context.Context, method, path string, body, out any) error { var rd io.Reader if body != nil { b, err := json.Marshal(body) @@ -97,8 +113,8 @@ func (c *apiClient) do(ctx context.Context, method, path string, body, out any) if body != nil { req.Header.Set("Content-Type", "application/json") } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) + if token := c.tokenValue(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) } resp, err := c.hc.Do(req) if err != nil { @@ -117,6 +133,21 @@ func (c *apiClient) do(ctx context.Context, method, path string, body, out any) return nil } +// do retries authenticated API calls once after a fresh cryptographic login. +// This matters for unattended hosted workers: a long network outage or an +// expired JWT must not leave a healthy process permanently stuck on 401. +func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error { + err := c.doRaw(ctx, method, path, body, out) + var ae *apiError + if strings.HasPrefix(path, "/api/auth/") || !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized { + return err + } + if loginErr := c.login(ctx); loginErr != nil { + return fmt.Errorf("session refresh after HTTP 401: %w", loginErr) + } + return c.doRaw(ctx, method, path, body, out) +} + func leadingZeroBitsClient(b []byte) int { n := 0 for _, x := range b { @@ -145,12 +176,15 @@ func solveProofClient(challenge, cid string, bits int) string { } } func (c *apiClient) login(ctx context.Context) error { + c.authMu.Lock() + defer c.authMu.Unlock() + var ch struct { ClientID string `json:"client_id"` Challenge string `json:"challenge"` ProofOfWorkBits int `json:"proof_of_work_bits"` } - if err := c.do(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil { + if err := c.doRaw(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil { return fmt.Errorf("challenge: %w", err) } sig, err := signRaw(c.key, "login|"+ch.Challenge+"|"+ch.ClientID) @@ -162,7 +196,7 @@ func (c *apiClient) login(ctx context.Context) error { ClientID string `json:"client_id"` } pow := solveProofClient(ch.Challenge, ch.ClientID, ch.ProofOfWorkBits) - if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{ + if err := c.doRaw(ctx, http.MethodPost, "/api/auth/login", map[string]any{ "public_jwk": c.id.PublicJWK, "challenge": ch.Challenge, "signature": sig, @@ -170,7 +204,12 @@ func (c *apiClient) login(ctx context.Context) error { }, &lg); err != nil { return fmt.Errorf("login: %w", err) } + if old := c.clientID(); old != "" && old != lg.ClientID { + return fmt.Errorf("server returned a different client identity after re-login: %s != %s", lg.ClientID, old) + } + c.mu.Lock() c.token, c.cid = lg.Token, lg.ClientID + c.mu.Unlock() return nil } @@ -318,7 +357,7 @@ func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtif } func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) { - guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits) + guess := expectedGuess(t.ID, t.PublicSeed, c.clientID(), seq, t.RangeBits) msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess) if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 { beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath)) @@ -333,7 +372,7 @@ func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath return correct, err } -func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) { +func (c *apiClient) dialWSOnce(ctx context.Context, maxNodes int) (*websocket.Conn, error) { u, err := url.Parse(c.base) if err != nil { return nil, err @@ -346,16 +385,30 @@ func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, q.Set("max_nodes", strconv.Itoa(maxNodes)) wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode() h := http.Header{} - h.Set("Authorization", "Bearer "+c.token) + if token := c.tokenValue(); token != "" { + h.Set("Authorization", "Bearer "+token) + } conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, h) if err != nil && resp != nil { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) _ = resp.Body.Close() - return nil, fmt.Errorf("websocket %s: %s", resp.Status, strings.TrimSpace(string(b))) + return nil, &apiError{Status: resp.StatusCode, Body: string(b)} } return conn, err } +func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) { + conn, err := c.dialWSOnce(ctx, maxNodes) + var ae *apiError + if !errors.As(err, &ae) || ae.Status != http.StatusUnauthorized { + return conn, err + } + if loginErr := c.login(ctx); loginErr != nil { + return nil, fmt.Errorf("websocket session refresh: %w", loginErr) + } + return c.dialWSOnce(ctx, maxNodes) +} + func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) error { path := "/api/public/artifacts/" + url.PathEscape(taskID) + "/preview" req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil) @@ -391,8 +444,8 @@ func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest stri if err != nil { return err } - if c.token != "" { - req.Header.Set("Authorization", "Bearer "+c.token) + if token := c.tokenValue(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) } resp, err := c.hc.Do(req) if err != nil { @@ -439,7 +492,7 @@ func (c *apiClient) registerHostedWorker(ctx context.Context) error { if registerURL == "" || workerID == "" || token == "" { return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN") } - body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid}) + body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.clientID()}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body)) if err != nil { return err @@ -492,6 +545,14 @@ func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.Ca failures = 0 continue } + // Explicit authentication/revocation responses are authoritative, + // not transient connectivity failures. Stop immediately; Docker's + // restart policy cannot bypass registration while the DB lease is + // revoked. + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusConflict { + cancel() + return + } } } failures++ diff --git a/cmd/client/main.go b/cmd/client/main.go index ad21e28..3e97039 100644 --- a/cmd/client/main.go +++ b/cmd/client/main.go @@ -61,13 +61,13 @@ func main() { fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath) } fmt.Println("NEURAL HUNT SHELL") - fmt.Println("Client-ID :", api.cid) + fmt.Println("Client-ID :", api.clientID()) fmt.Println("Identity-Datei:", *identityPath) fmt.Println("Server :", strings.TrimRight(*base, "/")) fmt.Println("Backup : identity export (verschlüsselt, im Browser importierbar)") fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.") - a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive) + a := newApp(ctx, api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive) a.beaconPathMode = strings.ToLower(strings.TrimSpace(*beaconPath)) initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive) if err != nil { diff --git a/cmd/client/ui.go b/cmd/client/ui.go index d25f10b..8ce4887 100644 --- a/cmd/client/ui.go +++ b/cmd/client/ui.go @@ -25,6 +25,7 @@ type wsEvent struct { } type app struct { + rootCtx context.Context api *apiClient identityPath string passphrase string @@ -47,14 +48,14 @@ type app struct { beaconPathMode string } -func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app { +func newApp(rootCtx context.Context, api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app { if maxNodes < 50 { maxNodes = 50 } if maxNodes > 10000 { maxNodes = 10000 } - return &app{api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)} + return &app{rootCtx: rootCtx, api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)} } func shortID(s string) string { @@ -301,6 +302,18 @@ func (a *app) forceWSReconnect() { } func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) { + // The server emits a WebSocket ping every 30 seconds. A client-side read + // deadline is equally important: without one, a half-open TCP connection + // (Wi-Fi/VPN/NAT outage) can block ReadMessage forever and wsConnected would + // incorrectly remain true. Receiving either data or a server ping refreshes + // the deadline; 90 seconds of silence forces the normal reconnect loop. + const idleTimeout = 90 * time.Second + _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) + defaultPing := conn.PingHandler() + conn.SetPingHandler(func(appData string) error { + _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) + return defaultPing(appData) + }) for { _, b, err := conn.ReadMessage() if err != nil { @@ -309,6 +322,7 @@ func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID strin } return false, err } + _ = conn.SetReadDeadline(time.Now().Add(idleTimeout)) var ev wsEvent if json.Unmarshal(b, &ev) != nil { continue @@ -340,18 +354,40 @@ func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID strin if !a.quiet { fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …") } - go func() { - time.Sleep(450 * time.Millisecond) - if err := a.startTask(context.Background(), ""); err != nil && !a.quiet { - fmt.Printf("[task] Folge-Task noch nicht bereit: %v\n", err) - } - }() + go a.retryCurrentTaskAfterCompletion(taskID) return true, nil } } } } +// retryCurrentTaskAfterCompletion closes a gap that previously left unattended +// workers idle forever when the successor task was not yet queryable on the +// first 450ms attempt. It keeps retrying with bounded backoff until either a +// new active task is available or the whole client is shutting down. +func (a *app) retryCurrentTaskAfterCompletion(completedTaskID string) { + backoff := 500 * time.Millisecond + for { + select { + case <-a.rootCtx.Done(): + return + case <-time.After(backoff): + } + if err := a.startTask(a.rootCtx, ""); err == nil { + return + } else if !errors.Is(err, errSwitching) && !a.quiet { + fmt.Printf("[task] Folge-Task noch nicht bereit: %v; neuer Versuch folgt\n", err) + } + backoff = time.Duration(minInt64(int64(15*time.Second), int64(float64(backoff)*1.7))) + a.mu.RLock() + currentID := a.task.ID + a.mu.RUnlock() + if currentID != "" && currentID != completedTaskID { + return + } + } +} + func (a *app) replacePoints(ps []point) { a.mu.Lock() defer a.mu.Unlock() @@ -368,7 +404,7 @@ func (a *app) upsertPoint(p point) { a.points = make(map[string]point) } a.points[p.ClientID] = p - if p.ClientID == a.api.cid { + if p.ClientID == a.api.clientID() { a.me.Score, a.me.Rank = p.Score, p.Rank } if len(a.points) > a.maxNodes*3 { @@ -390,7 +426,7 @@ func (a *app) trimPointsLocked() { for _, p := range ps[:keep] { next[p.ClientID] = p } - if own, ok := a.points[a.api.cid]; ok { + if own, ok := a.points[a.api.clientID()]; ok { next[own.ClientID] = own } a.points = next @@ -475,7 +511,7 @@ func (a *app) guessLoop(ctx context.Context, taskID string) { } path := "" if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 { - path = chooseBeaconPath(a.beaconPathMode, a.api.cid, seq) + path = chooseBeaconPath(a.beaconPathMode, a.api.clientID(), seq) if !a.quiet { fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight) } @@ -524,7 +560,7 @@ func (a *app) printStatus(ctx context.Context) { a.mu.RUnlock() fmt.Println("\nSTATUS") fmt.Println("────────────────────────────────────────────────────────") - fmt.Printf("Identity : %s\n", a.api.cid) + fmt.Printf("Identity : %s\n", a.api.clientID()) fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID) fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused) fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins) @@ -547,7 +583,7 @@ func (a *app) printMap(limit int) { for _, p := range a.points { ps = append(ps, p) } - cid := a.api.cid + cid := a.api.clientID() t := a.task a.mu.RUnlock() sort.Slice(ps, func(i, j int) bool { @@ -601,7 +637,7 @@ func (a *app) printLeaderboard(ctx context.Context, limit int) error { conn = "●" } self := "" - if l.ClientID == a.api.cid { + if l.ClientID == a.api.clientID() { self = "*" } fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount) @@ -806,7 +842,7 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error { fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2]) } } else { - fmt.Println("Client-ID:", a.api.cid) + fmt.Println("Client-ID:", a.api.clientID()) fmt.Println("Identity :", a.identityPath) } case "hosted-code", "hosted-link": @@ -843,7 +879,30 @@ func selectInitialTask(ctx context.Context, a *app, selector string, interactive } if selector != "" || !interactive { t, err := resolveTask(ts, selector) - return t.ID, err + if err == nil { + return t.ID, nil + } + // A hosted worker stores the task ID that was active when it was created. + // After that task completes, the game moves the identity to its successor, + // but the Customer-Service record may still contain the historical ID. On + // a later container restart, falling back to the server-selected/current + // active task lets the same persistent identity resume instead of crash- + // looping forever on "task not found". + if !interactive && strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID")) != "" { + for _, candidate := range ts { + if candidate.Selected { + if !a.quiet { + fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; setze mit %s fort\n", selector, taskName(candidate)) + } + return candidate.ID, nil + } + } + if !a.quiet { + fmt.Printf("[task] konfigurierte Task-ID %q ist nicht mehr aktiv; verwende %s\n", selector, taskName(ts[0])) + } + return ts[0].ID, nil + } + return "", err } fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ") line, _ := bufio.NewReader(os.Stdin).ReadString('\n') diff --git a/internal/customer/docker.go b/internal/customer/docker.go index f203765..701f579 100644 --- a/internal/customer/docker.go +++ b/internal/customer/docker.go @@ -227,7 +227,11 @@ func (d *DockerClient) CreateWorker(ctx context.Context, c WorkerContainerConfig "Mounts": []map[string]any{{"Type": "volume", "Source": c.Volume, "Target": "/identity"}}, "NetworkMode": c.Network, "ReadonlyRootfs": true, - "CapDrop": []string{"ALL"}, + // A managed unattended worker must survive process crashes and temporary + // game/network outages. Docker will restart it automatically, while an + // explicit Docker Stop from the Customer Service keeps it stopped. + "RestartPolicy": map[string]any{"Name": "unless-stopped", "MaximumRetryCount": 0}, + "CapDrop": []string{"ALL"}, // Bootstrap-only capabilities: the image entrypoint fixes ownership of // /identity and then su-exec permanently switches to uid/gid app. "CapAdd": []string{"CHOWN", "DAC_OVERRIDE", "SETUID", "SETGID"}, diff --git a/internal/customer/docker_test.go b/internal/customer/docker_test.go index 805d7c4..4894e97 100644 --- a/internal/customer/docker_test.go +++ b/internal/customer/docker_test.go @@ -100,6 +100,11 @@ func TestCreateWorkerUsesImageEntrypointByDefault(t *testing.T) { if _, exists := got["Entrypoint"]; exists { t.Fatalf("dedicated worker image should keep its image ENTRYPOINT: %#v", got["Entrypoint"]) } + host, _ := got["HostConfig"].(map[string]any) + rp, _ := host["RestartPolicy"].(map[string]any) + if rp["Name"] != "unless-stopped" { + t.Fatalf("worker restart policy=%#v want unless-stopped", rp) + } cfg.Name = "worker-2" cfg.Entrypoint = "/app/neuralhunt-client" diff --git a/internal/customer/server.go b/internal/customer/server.go index f8c6a8c..e6d29d1 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -261,6 +261,8 @@ func (s *Service) AdminRoutes(ui http.Handler) http.Handler { r.Get("/api/admin/tasks", s.adminTaskCatalog) r.Post("/api/admin/customers/{id}/workers", s.adminCreateWorkerForCustomer) r.Post("/api/admin/workers/{id}/stop", s.adminStopAnyWorker) + r.Post("/api/admin/workers/{id}/update-image", s.adminUpdateWorkerImage) + r.Post("/api/admin/workers/update-image", s.adminUpdateAllWorkerImages) r.Post("/api/admin/invites", s.adminCreateInvite) }) r.Mount("/", ui) @@ -669,6 +671,65 @@ func (s *Service) ensureWorkerImage(ctx context.Context) error { return s.docker.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth) } +// pullWorkerImage always asks Docker to refresh CS_WORKER_IMAGE, even if the +// tag already exists locally. This is intentionally admin-only behavior for +// mutable release tags such as worker_latest; normal worker starts keep the +// cheaper EnsureImage behavior. +func (s *Service) pullWorkerImage(ctx context.Context) error { + s.workerImageMu.Lock() + defer s.workerImageMu.Unlock() + auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer) + if err != nil { + return err + } + if err := s.docker.PullImage(ctx, s.cfg.WorkerImage, auth); err != nil { + return fmt.Errorf("pull worker image %q: %w", s.cfg.WorkerImage, err) + } + return nil +} + +// recreateWorkerWithCurrentImage preserves the named identity volume and the +// worker's task/beacon configuration. Running workers are resumed after the +// container was recreated; stopped workers remain stopped. Pulling is done by +// the caller so bulk updates need only one registry request. +func (s *Service) recreateWorkerWithCurrentImage(ctx context.Context, wk Worker) error { + resume := wk.Status == "running" || wk.Status == "starting" + if resume { + if cust, err := s.store.CustomerByID(ctx, wk.CustomerID); err != nil || cust.Blocked { + resume = false + } + } + oldID := wk.ContainerID + if oldID != "" { + _ = s.docker.Stop(ctx, oldID, 5) + if err := s.docker.Remove(ctx, oldID); err != nil { + return fmt.Errorf("remove old worker container: %w", err) + } + } + name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_") + // Clear a crash-orphan with the deterministic name. Remove is idempotent for + // 404s and never removes the named identity volume (v=false). + _ = s.docker.Remove(ctx, name) + containerID, err := s.docker.CreateWorker(ctx, s.workerContainerConfig(wk, name)) + if err != nil { + _ = s.store.SetWorkerRuntime(ctx, wk.ID, "error", "", "image update: "+err.Error()) + return err + } + if resume { + if err := s.docker.Start(ctx, containerID); err != nil { + _ = s.docker.Remove(ctx, containerID) + _ = s.store.SetWorkerRuntime(ctx, wk.ID, "error", "", "image update start: "+err.Error()) + return err + } + if err := s.store.SetWorkerRuntime(ctx, wk.ID, "running", containerID, ""); err != nil { + _ = s.docker.Stop(ctx, containerID, 2) + return err + } + return nil + } + return s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", containerID, "") +} + // ensureWorkerContainer makes the worker identity volume addressable through // Docker's archive API even when a previous config change removed the old // container. The helper container remains stopped until the customer starts it. @@ -936,6 +997,17 @@ func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) jsonOut(w, 401, map[string]string{"error": "unauthorized"}) return } + // RestartPolicy=unless-stopped is a recovery mechanism, never a lease bypass. + // A container that was explicitly revoked/blocked must fail before it can + // reconnect to the game and submit guesses. + if wk.Status != "running" && wk.Status != "starting" { + jsonOut(w, 409, map[string]string{"error": "worker lease revoked"}) + return + } + if cust, err := s.store.CustomerByID(r.Context(), wk.CustomerID); err != nil || cust.Blocked { + jsonOut(w, 409, map[string]string{"error": "worker lease revoked"}) + return + } if err := s.store.SetWorkerClient(r.Context(), wk.ID, in.ClientID); err != nil { jsonOut(w, 500, map[string]string{"error": err.Error()}) return @@ -1441,11 +1513,63 @@ func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]any{ "manual_credits_enabled": s.cfg.AllowManualCredits, "paypal_enabled": s.paypalAllowed(), + "worker_image": s.cfg.WorkerImage, "settings": s.portalSettings(r.Context()), "customers": out, }) } +func (s *Service) adminUpdateWorkerImage(w http.ResponseWriter, r *http.Request) { + wid := strings.TrimSpace(chi.URLParam(r, "id")) + wk, err := s.store.WorkerByID(r.Context(), wid) + if err != nil { + jsonOut(w, http.StatusNotFound, map[string]string{"error": "worker not found"}) + return + } + if err := s.pullWorkerImage(r.Context()); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + if err := s.recreateWorkerWithCurrentImage(r.Context(), wk); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": "worker image update: " + err.Error()}) + return + } + jsonOut(w, http.StatusOK, map[string]any{"ok": true, "worker_id": wk.ID, "image": s.cfg.WorkerImage, "resumed": wk.Status == "running" || wk.Status == "starting"}) +} + +func (s *Service) adminUpdateAllWorkerImages(w http.ResponseWriter, r *http.Request) { + if err := s.pullWorkerImage(r.Context()); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + rows, err := s.store.DB.QueryContext(r.Context(), `SELECT `+workerCols+` FROM workers ORDER BY created_at`) + if err != nil { + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + var workers []Worker + for rows.Next() { + wk, scanErr := scanWorker(rows) + if scanErr != nil { + _ = rows.Close() + jsonOut(w, http.StatusInternalServerError, map[string]string{"error": scanErr.Error()}) + return + } + workers = append(workers, wk) + } + _ = rows.Close() + updated := 0 + var warnings []string + for _, wk := range workers { + if err := s.recreateWorkerWithCurrentImage(r.Context(), wk); err != nil { + warnings = append(warnings, wk.ID+": "+err.Error()) + continue + } + updated++ + } + jsonOut(w, http.StatusOK, map[string]any{"ok": len(warnings) == 0, "image": s.cfg.WorkerImage, "updated": updated, "total": len(workers), "warnings": warnings}) +} + func (s *Service) adminCreditGrant(w http.ResponseWriter, r *http.Request) { if !s.cfg.AllowManualCredits { jsonOut(w, 403, map[string]string{"error": "manual credit bypass disabled; set CS_ALLOW_MANUAL_CREDITS=1 on the private admin service"}) diff --git a/internal/customerui/dist/admin/app.js b/internal/customerui/dist/admin/app.js index 08c76cc..e56ab9c 100644 --- a/internal/customerui/dist/admin/app.js +++ b/internal/customerui/dist/admin/app.js @@ -6,9 +6,9 @@ const cr=m=>(Number(m||0)/1e6).toLocaleString('de-DE',{maximumFractionDigits:3}) function settingsToUI(s){$('loginEnabled').checked=!!s.login_enabled;$('registrationEnabled').checked=!!s.registration_enabled;$('inviteRequired').checked=!!s.invite_required;$('powBits').value=s.registration_pow_bits??0;$('signupBonus').value=Number(s.signup_bonus_micros||0)/1e6;$('positiveBonus').value=Number(s.positive_tip_bonus_micros||0)/1e6;$('inviteCount').textContent=s.active_registration_invites??0} function settingsPayload(){return{login_enabled:$('loginEnabled').checked,registration_enabled:$('registrationEnabled').checked,invite_required:$('inviteRequired').checked,registration_pow_bits:Number($('powBits').value||0),signup_bonus_credits:Number($('signupBonus').value||0),positive_tip_bonus_credits:Number($('positiveBonus').value||0)}} async function saveSettings(){try{const x=await api('/api/admin/settings',{method:'PUT',body:settingsPayload()});settingsToUI(x);msg('Portal-Einstellungen gespeichert')}catch(e){msg(e.message,true)}} -function workerRows(c){if(!c.worker_items?.length)return'
Keine Worker.
';return `
${c.worker_items.map(w=>`
${esc(w.id)}
${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}
${esc(w.status)}
`).join('')}
`} +function workerRows(c){if(!c.worker_items?.length)return'
Keine Worker.
';return `
${c.worker_items.map(w=>`
${esc(w.id)}
${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}
${w.last_error?`
${esc(w.last_error)}
`:''}
${esc(w.status)}
`).join('')}
`} function taskOptions(){return taskCatalog.map(t=>``).join('')} function customerHTML(c){const status=c.blocked?`GESPERRT`:'AKTIV',bypass=c.worker_limit_bypass?'LIMIT BYPASS':'';return `

${esc(c.username)} ${status} ${bypass}

${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}
${c.blocked_reason?`
Grund: ${esc(c.blocked_reason)}
`:''}
${cr(c.balance_micros)} Credits${c.running}/${c.workers} Worker aktiv
${c.blocked?'':''}${manual?'':''}
${workerRows(c)}
`} -function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.limitBypass').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),c=last?.customers?.find(x=>x.id===row.dataset.id);try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/worker-limit`,{method:'PUT',body:{bypass:!c?.worker_limit_bypass}});msg(c?.worker_limit_bypass?'Worker-Limit wieder aktiv':'Worker-Limit für Benutzer aufgehoben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.addWorker').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),task=row.querySelector('.newWorkerTask').value,beacon=row.querySelector('.newWorkerBeacon').value;if(!task){msg('Task auswählen',true);return}try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers`,{method:'POST',body:{task_id:task,beacon_path:beacon}});msg('Worker dem Benutzer zugeordnet');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})} -async function load(){const [x,tasks]=await Promise.all([api('/api/admin/overview'),api('/api/admin/tasks').catch(()=>[])]);last=x;taskCatalog=Array.isArray(tasks)?tasks:[];manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'
Noch keine Benutzer.
';bindCustomerActions()} -$('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{}); +function bindCustomerActions(){document.querySelectorAll('.block').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const reason=prompt('Optionaler Sperrgrund:','');if(reason===null)return;if(!confirm('Benutzer sperren, Sessions invalidieren und alle Worker stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/block`,{method:'POST',body:{reason}});msg(x.warnings?.length?`Benutzer gesperrt · Warnungen: ${x.warnings.join(' · ')}`:'Benutzer gesperrt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.unblock').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/unblock`,{method:'POST'});msg('Benutzer freigegeben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopAll').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');if(!confirm('Alle Worker dieses Benutzers stoppen?'))return;try{const x=await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers/stop`,{method:'POST'});msg(x.warnings?.length?x.warnings.join(' · '):'Worker gestoppt',!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.stopWorker').forEach(b=>b.onclick=async()=>{try{await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/stop`,{method:'POST'});msg('Worker gestoppt');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.updateWorkerImage').forEach(b=>b.onclick=async()=>{if(!confirm(`Worker ${b.dataset.wid} mit dem aktuell konfigurierten Image neu erzeugen? Die Identity bleibt erhalten.`))return;const old=b.textContent;b.disabled=true;b.textContent='UPDATE …';try{const x=await api(`/api/admin/workers/${encodeURIComponent(b.dataset.wid)}/update-image`,{method:'POST'});msg(`Worker aktualisiert · ${x.image}${x.resumed?' · wieder gestartet':''}`);await load()}catch(e){msg(e.message,true)}finally{b.disabled=false;b.textContent=old}});document.querySelectorAll('.limitBypass').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),c=last?.customers?.find(x=>x.id===row.dataset.id);try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/worker-limit`,{method:'PUT',body:{bypass:!c?.worker_limit_bypass}});msg(c?.worker_limit_bypass?'Worker-Limit wieder aktiv':'Worker-Limit für Benutzer aufgehoben');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.addWorker').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer'),task=row.querySelector('.newWorkerTask').value,beacon=row.querySelector('.newWorkerBeacon').value;if(!task){msg('Task auswählen',true);return}try{await api(`/api/admin/customers/${encodeURIComponent(row.dataset.id)}/workers`,{method:'POST',body:{task_id:task,beacon_path:beacon}});msg('Worker dem Benutzer zugeordnet');await load()}catch(e){msg(e.message,true)}});document.querySelectorAll('.grantBtn').forEach(b=>b.onclick=async()=>{const row=b.closest('.customer');const n=Number(row.querySelector('.amount').value);if(!n)return;try{await api('/api/admin/credits/grant',{method:'POST',body:{customer_id:row.dataset.id,credits:n,reason:'admin-ui'}});msg(`${n} Credits gebucht`);await load()}catch(e){msg(e.message,true)}})} +async function load(){const [x,tasks]=await Promise.all([api('/api/admin/overview'),api('/api/admin/tasks').catch(()=>[])]);last=x;taskCatalog=Array.isArray(tasks)?tasks:[];manual=x.manual_credits_enabled;$('loginBox').classList.add('hidden');$('panel').classList.remove('hidden');$('manual').textContent=manual?'MANUELLER CREDIT-BYPASS IST AKTIV. Jede Buchung bleibt im Ledger nachvollziehbar.':'Manuelle Credits sind deaktiviert (CS_ALLOW_MANUAL_CREDITS=0).';settingsToUI(x.settings||{});$('workerImage').textContent=x.worker_image||'—';$('customers').innerHTML=(x.customers||[]).map(customerHTML).join('')||'
Noch keine Benutzer.
';bindCustomerActions()} +$('saveSettings').onclick=saveSettings;$('saveRewards').onclick=saveSettings;$('createInvite').onclick=async()=>{try{const x=await api('/api/admin/invites',{method:'POST',body:{label:$('inviteLabel').value,hours:Number($('inviteHours').value||24)}});$('inviteOut').textContent=x.invite_code;$('inviteOut').classList.remove('hidden');msg('Invite erzeugt · jetzt sicher an genau einen Benutzer weitergeben');await load()}catch(e){msg(e.message,true)}};$('updateAllImages').onclick=async()=>{const total=(last?.customers||[]).reduce((n,c)=>n+Number(c.workers||0),0);if(!confirm(`CS_WORKER_IMAGE neu pullen und ${total} bestehende Worker aktualisieren? Laufende Worker werden kurz neu gestartet.`))return;const b=$('updateAllImages'),old=b.textContent;b.disabled=true;b.textContent='IMAGE UPDATE LÄUFT …';try{const x=await api('/api/admin/workers/update-image',{method:'POST'});const warn=x.warnings?.length?` · ${x.warnings.length} Fehler`:'';msg(`${x.updated}/${x.total} Worker aktualisiert · ${x.image}${warn}`,!!x.warnings?.length);await load()}catch(e){msg(e.message,true)}finally{b.disabled=false;b.textContent=old}};$('refresh').onclick=()=>load().catch(e=>msg(e.message,true));$('login').onclick=async()=>{try{await api('/api/admin/login',{method:'POST',body:{Username:$('user').value,Password:$('pass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/admin/logout',{method:'POST'}).catch(()=>{});location.reload()};load().catch(()=>{}); diff --git a/internal/customerui/dist/admin/index.html b/internal/customerui/dist/admin/index.html index 7f47dd0..b50d914 100644 --- a/internal/customerui/dist/admin/index.html +++ b/internal/customerui/dist/admin/index.html @@ -9,5 +9,6 @@

Invite-Codes

Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.

Aktive, unbenutzte Invites: 0
+

Hosted Worker Image

Aktuell konfiguriert:

„Alle aktualisieren“ pullt das konfigurierte CS_WORKER_IMAGE erneut und erzeugt alle bestehenden Worker-Container mit demselben Identity-Volume neu. Laufende Worker werden anschließend automatisch fortgesetzt.

Benutzer

diff --git a/internal/customerui/dist/admin/styles.css b/internal/customerui/dist/admin/styles.css index 5a77335..b6c5b40 100644 --- a/internal/customerui/dist/admin/styles.css +++ b/internal/customerui/dist/admin/styles.css @@ -1,2 +1,3 @@ :root{color-scheme:dark;--bg:#080b10;--card:#111821;--line:#293746;--text:#edf4fb;--muted:#8fa1b2;--accent:#54f0a6;--danger:#ff6978;--warn:#ffca56}*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.45 ui-monospace,monospace}main{max-width:1200px;margin:auto;padding:34px 20px}.eyebrow{color:var(--accent);letter-spacing:.2em}h1{font:700 38px system-ui}h2,h3{font-family:system-ui;margin:0 0 10px}section,.customer,article{border:1px solid var(--line);background:var(--card);border-radius:12px;padding:16px;margin:12px 0}label{display:block;color:var(--muted);margin:10px 0}label.toggle{display:flex;align-items:center;gap:9px;color:var(--text)}label.toggle input{width:auto;margin:0}input{display:block;width:100%;margin-top:5px;background:#090e14;color:var(--text);border:1px solid var(--line);border-radius:8px;padding:10px}button{background:var(--accent);color:#06120c;border:0;border-radius:8px;padding:9px 12px;font-weight:900;cursor:pointer}button:disabled{opacity:.4;cursor:not-allowed}.ghost{background:#1a2530;color:var(--text);border:1px solid var(--line)}button.danger{background:#4b1d24;color:#ffd7dc;border:1px solid #79343f}.hidden{display:none!important}.head,.section-title,.customer-head,.actions,.worker-row{display:flex;justify-content:space-between;gap:12px;align-items:center}.admin-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.admin-grid article{margin:0}.small{font-size:12px;color:var(--muted);word-break:break-all}.notice,.msg,.invite{padding:10px;border-radius:8px;background:#18261f;margin:10px 0}.msg.err{background:#32171d}.invite{border:1px solid var(--accent);font-weight:800;word-break:break-all;user-select:all}.customer{margin:12px 0}.customer.blocked{border-color:#6b333b}.customer h3{display:flex;align-items:center;gap:8px}.customer-stats{display:flex;flex-direction:column;align-items:flex-end;color:var(--muted)}.actions{justify-content:flex-start;flex-wrap:wrap;margin:14px 0}.actions input{width:130px;margin:0}.worker-list{display:grid;gap:6px;border-top:1px solid var(--line);padding-top:10px}.worker-row{background:#0b1118;border-radius:8px;padding:8px 10px}.pill{font-size:11px;padding:3px 7px;border-radius:99px;background:#222d38;color:var(--muted)}.pill.on{background:#123022;color:var(--accent)}.pill.danger{background:#3c171d;color:#ff9ba6}.danger-text{color:#ff9ba6}@media(max-width:900px){.admin-grid{grid-template-columns:1fr}.customer-head,.head,.section-title{align-items:flex-start;flex-direction:column}.customer-stats{align-items:flex-start}.worker-row{align-items:flex-start;flex-wrap:wrap}} .admin-worker-create{display:grid;grid-template-columns:minmax(0,1fr) 110px auto;gap:8px;margin:10px 0 12px}.admin-worker-create select{min-width:0}.pill.warn{color:#ffd781;border-color:rgba(255,205,90,.28);background:rgba(255,205,90,.07)}@media(max-width:720px){.admin-worker-create{grid-template-columns:1fr 1fr}.admin-worker-create .addWorker{grid-column:1/-1}} +.worker-image-control{display:flex;align-items:center;justify-content:space-between;gap:18px}.worker-image-control>div{min-width:0}.worker-image-control strong{word-break:break-all;color:var(--accent)}.worker-main{min-width:0;flex:1}.worker-row .updateWorkerImage{white-space:nowrap}@media(max-width:720px){.worker-image-control{align-items:flex-start;flex-direction:column}.worker-image-control button{width:100%}}