This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
10
TESTING.md
10
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=<retired-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.
|
||||
|
||||
35
V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md
Normal file
35
V4.2.9_WORKER_RECOVERY_IMAGE_UPDATE.md
Normal file
@@ -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.
|
||||
@@ -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++
|
||||
|
||||
@@ -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 <datei> (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 {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"})
|
||||
|
||||
8
internal/customerui/dist/admin/app.js
vendored
8
internal/customerui/dist/admin/app.js
vendored
@@ -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'<div class="small">Keine Worker.</div>';return `<div class="worker-list">${c.worker_items.map(w=>`<div class="worker-row"><div><strong>${esc(w.id)}</strong><div class="small">${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}</div></div><span class="pill ${w.status==='running'?'on':''}">${esc(w.status)}</span><button class="ghost stopWorker" data-wid="${esc(w.id)}" ${w.status==='stopped'?'disabled':''}>STOP</button></div>`).join('')}</div>`}
|
||||
function workerRows(c){if(!c.worker_items?.length)return'<div class="small">Keine Worker.</div>';return `<div class="worker-list">${c.worker_items.map(w=>`<div class="worker-row"><div class="worker-main"><strong>${esc(w.id)}</strong><div class="small">${esc(w.worker_client_id||'noch keine Identity')} · Task ${esc(w.task_id)}</div>${w.last_error?`<div class="small danger-text">${esc(w.last_error)}</div>`:''}</div><span class="pill ${w.status==='running'?'on':''}">${esc(w.status)}</span><button class="ghost updateWorkerImage" data-wid="${esc(w.id)}">IMAGE UPDATE</button><button class="ghost stopWorker" data-wid="${esc(w.id)}" ${w.status==='stopped'?'disabled':''}>STOP</button></div>`).join('')}</div>`}
|
||||
function taskOptions(){return taskCatalog.map(t=>`<option value="${esc(t.id)}">${esc(t.display_name||t.id.slice(-12))} · ${Number(t.range_bits||0)} Bit</option>`).join('')}
|
||||
function customerHTML(c){const status=c.blocked?`<span class="pill danger">GESPERRT</span>`:'<span class="pill on">AKTIV</span>',bypass=c.worker_limit_bypass?'<span class="pill warn">LIMIT BYPASS</span>':'';return `<article class="customer ${c.blocked?'blocked':''}" data-id="${esc(c.id)}"><div class="customer-head"><div><h3>${esc(c.username)} ${status} ${bypass}</h3><div class="small">${esc(c.id)} · Reward ${esc(c.reward_client_id||'—')}</div>${c.blocked_reason?`<div class="small danger-text">Grund: ${esc(c.blocked_reason)}</div>`:''}</div><div class="customer-stats"><strong>${cr(c.balance_micros)} Credits</strong><span>${c.running}/${c.workers} Worker aktiv</span></div></div><div class="actions">${c.blocked?'<button class="unblock">FREIGEBEN</button>':'<button class="danger block">BENUTZER SPERREN</button>'}<button class="ghost stopAll" ${c.running?'' :'disabled'}>ALLE WORKER STOPPEN</button><button class="ghost limitBypass">${c.worker_limit_bypass?'LIMIT NORMAL':'LIMIT AUFHEBEN'}</button>${manual?'<input class="amount" type="number" min="0.001" step="1" placeholder="Credits"><button class="grantBtn">+ CREDIT</button>':''}</div><div class="admin-worker-create"><select class="newWorkerTask"><option value="">Task wählen …</option>${taskOptions()}</select><select class="newWorkerBeacon"><option value="auto">AUTO</option><option value="pulse">PULSE</option><option value="flux">FLUX</option><option value="orbit">ORBIT</option></select><button class="addWorker" ${taskCatalog.length?'':'disabled'}>WORKER +</button></div>${workerRows(c)}</article>`}
|
||||
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('')||'<div class="notice">Noch keine Benutzer.</div>';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('')||'<div class="notice">Noch keine Benutzer.</div>';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(()=>{});
|
||||
|
||||
1
internal/customerui/dist/admin/index.html
vendored
1
internal/customerui/dist/admin/index.html
vendored
@@ -9,5 +9,6 @@
|
||||
<article><h2>Invite-Codes</h2><p class="small">Optionaler Schutz gegen Account-Farming. Jeder Code ist einmalig und wird nur hier bei der Erstellung im Klartext gezeigt.</p><label>Bezeichnung<input id="inviteLabel" placeholder="z. B. Beta Tester"></label><label>Gültigkeit · Stunden<input id="inviteHours" type="number" min="1" max="720" value="24"></label><button id="createInvite">INVITE ERZEUGEN</button><div id="inviteOut" class="invite hidden"></div><div class="small">Aktive, unbenutzte Invites: <strong id="inviteCount">0</strong></div></article>
|
||||
</div>
|
||||
<div id="manual" class="notice"></div>
|
||||
<article class="worker-image-control"><div><h2>Hosted Worker Image</h2><div class="small">Aktuell konfiguriert: <strong id="workerImage">—</strong></div><p class="small">„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.</p></div><button id="updateAllImages" class="ghost">IMAGE LADEN · ALLE WORKER AKTUALISIEREN</button></article>
|
||||
<div class="section-title"><h2>Benutzer</h2><button id="refresh" class="ghost">AKTUALISIEREN</button></div><div id="customers"></div>
|
||||
</section></main><script src="/app.js" defer></script></body></html>
|
||||
|
||||
1
internal/customerui/dist/admin/styles.css
vendored
1
internal/customerui/dist/admin/styles.css
vendored
@@ -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%}}
|
||||
|
||||
Reference in New Issue
Block a user