From ba3818ef306972fa8cbcf5c7d1292967142a1a36 Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 11 Aug 2026 19:02:09 +0200 Subject: [PATCH] RC-8 --- .env.example | 8 +- HOSTED_SERVICE.md | 11 ++ TESTING.md | 2 + V4.2.2_HOSTED_CODE_FIX.md | 16 +++ cmd/customer-service/main.go | 9 +- internal/customer/server.go | 140 ++++++++++++++++++++----- internal/customerui/dist/public/app.js | 2 +- internal/server/server.go | 15 +++ 8 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 V4.2.2_HOSTED_CODE_FIX.md diff --git a/.env.example b/.env.example index ae50a84..8dcfa15 100644 --- a/.env.example +++ b/.env.example @@ -149,9 +149,11 @@ CS_WORKER_NETWORK=neuralhunt_backend CS_WORKER_REGISTER_URL=http://customer-service:8092/internal/workers/register DOCKER_HOST=unix:///var/run/docker.sock -# Game endpoints as seen from Customer Service. Compose sets them to app:8080/8081. -CS_GAME_PUBLIC_URL=http://app:8080 -CS_GAME_ADMIN_URL=http://app:8081 +# Game endpoints as seen from Customer Service. These localhost defaults make +# `go run ./cmd/customer-service` work when the game server also runs locally. +# docker-compose.yml explicitly overrides them to http://app:8080 / :8081. +CS_GAME_PUBLIC_URL=http://127.0.0.1:8080 +CS_GAME_ADMIN_URL=http://127.0.0.1:8081 CUSTOMER_SQLITE_PATH=/customer-data/customer-service.db # PayPal. Start in sandbox. Live mode is additionally locked in code until the diff --git a/HOSTED_SERVICE.md b/HOSTED_SERVICE.md index 75ab26e..4882a08 100644 --- a/HOSTED_SERVICE.md +++ b/HOSTED_SERVICE.md @@ -74,6 +74,17 @@ export CS_WORKER_IMAGE=registry.example.com/neuralhunt/worker:v4.1 Do not route 8081, 8091 or 8092 to the public Internet. +### Local `go run` test + +When both processes run directly on the same development machine, use: + +```env +CS_GAME_PUBLIC_URL=http://127.0.0.1:8080 +CS_GAME_ADMIN_URL=http://127.0.0.1:8081 +``` + +The Docker hostname `app` only exists inside the Compose network. `docker-compose.yml` overrides the two values to `http://app:8080` and `http://app:8081` automatically. The Customer Service now probes the authenticated game control plane at startup and prints a concrete warning if the URL points to the public listener, DNS is wrong, or `CUSTOMER_SERVICE_SHARED_SECRET` differs. + ## PrePaid billing model The reference implementation bills **running worker time**, not individual diff --git a/TESTING.md b/TESTING.md index 684eba5..2a6d9f2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -397,6 +397,8 @@ node --check internal/webui/dist/app.js ### Hosted reward pairing +For a direct local `go run` test (not Compose), set `CS_GAME_PUBLIC_URL=http://127.0.0.1:8080` and `CS_GAME_ADMIN_URL=http://127.0.0.1:8081`. On Customer Service startup, verify the log says `Hosted-Code/Reward-Control-Plane bereit`. A warning about DNS/connection, HTTP 404, or `CUSTOMER_SERVICE_SHARED_SECRET` must be fixed before generating a one-shot code. + 1. Build with `make images-compose`, then start with `docker compose --profile hosted up -d` and route 8090 publicly over HTTPS; keep 8081/8091/8092 private. 2. Create/log in to a Customer Service account. 3. Log into the normal Neural Hunt browser with the desired reward identity and click **HOSTED CODE** (or run `hosted-code` in the CLI). diff --git a/V4.2.2_HOSTED_CODE_FIX.md b/V4.2.2_HOSTED_CODE_FIX.md new file mode 100644 index 0000000..862bb77 --- /dev/null +++ b/V4.2.2_HOSTED_CODE_FIX.md @@ -0,0 +1,16 @@ +# V4.2.2 Hosted-Code Fix + +The Hosted-Code pairing path now probes the authenticated game control plane before consuming a one-shot code and returns actionable diagnostics for unreachable/wrong URLs, 401 shared-secret mismatches, and old/public listeners. + +For local `go run` testing use: + +```env +CS_GAME_PUBLIC_URL=http://127.0.0.1:8080 +CS_GAME_ADMIN_URL=http://127.0.0.1:8081 +``` + +Inside Docker Compose, `docker-compose.yml` overrides these values to `http://app:8080` and `http://app:8081`. + +Both processes must receive the exact same `CUSTOMER_SERVICE_SHARED_SECRET`. + +A successfully consumed Hosted Code is now persisted as the customer's main reward identity before already-known worker delegations are synchronized. A temporary worker-delegation failure therefore no longer destroys a valid one-shot pairing; the portal reports a warning instead. diff --git a/cmd/customer-service/main.go b/cmd/customer-service/main.go index 2e70896..4babc46 100644 --- a/cmd/customer-service/main.go +++ b/cmd/customer-service/main.go @@ -155,7 +155,7 @@ func main() { } cfg := customer.Config{ PublicAddr: env("CS_HTTP_ADDR", ":8090"), AdminAddr: env("CS_ADMIN_HTTP_ADDR", ":8091"), InternalAddr: env("CS_INTERNAL_ADDR", ":8092"), - PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://app:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://app:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""), + PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://127.0.0.1:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://127.0.0.1:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""), DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100), SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false), PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs, @@ -180,6 +180,13 @@ func main() { } pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment) svc := customer.NewService(st, dc, pp, cfg) + checkCtx, checkCancel := context.WithTimeout(ctx, 5*time.Second) + if err := svc.CheckGameControlPlane(checkCtx); err != nil { + log.Printf("WARNING: Hosted-Code/Reward-Control-Plane nicht bereit: %v", err) + } else { + log.Printf("Hosted-Code/Reward-Control-Plane bereit: %s", cfg.GameAdminURL) + } + checkCancel() go svc.RunBilling(ctx) servers := []*http.Server{ {Addr: cfg.PublicAddr, Handler: svc.PublicRoutes(customerui.Public()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}, diff --git a/internal/customer/server.go b/internal/customer/server.go index 61733a2..3d88569 100644 --- a/internal/customer/server.go +++ b/internal/customer/server.go @@ -280,36 +280,65 @@ func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) { jsonOut(w, 400, map[string]string{"error": "bad json"}) return } - clientID := "" - if !in.Clear { - if strings.TrimSpace(in.LinkCode) == "" { - jsonOut(w, 400, map[string]string{"error": "pairing code required; generate it while logged in with the reward identity"}) - return - } - var err error - clientID, err = s.redeemRewardLink(r.Context(), in.LinkCode) - if err != nil { - jsonOut(w, 400, map[string]string{"error": "reward identity proof failed: " + err.Error()}) - return - } - } - // Install delegations before committing the new owner locally. If any game - // control-plane call fails, the customer's previous reward owner remains - // unchanged and no half-applied portal state is presented as successful. + workers, _ := s.store.Workers(r.Context(), cid) - for _, wk := range workers { - if wk.WorkerClientID != "" { - if err := s.syncDelegation(r.Context(), wk.WorkerClientID, clientID); err != nil { - jsonOut(w, 400, map[string]string{"error": "reward identity could not be registered in game: " + err.Error()}) + if in.Clear { + // Clearing must reach the game first; otherwise old worker delegations + // could silently remain active while the portal already looks unlinked. + if err := s.checkGameControlPlane(r.Context()); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + for _, wk := range workers { + if wk.WorkerClientID == "" { + continue + } + if err := s.syncDelegation(r.Context(), wk.WorkerClientID, ""); err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Reward-Kopplung konnte im Spiel nicht entfernt werden: " + err.Error()}) return } } - } - if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil { - jsonOut(w, 500, map[string]string{"error": err.Error()}) + if err := s.store.SetRewardClientID(r.Context(), cid, ""); err != nil { + jsonOut(w, 500, map[string]string{"error": err.Error()}) + return + } + jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": ""}) return } - jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": clientID}) + + if strings.TrimSpace(in.LinkCode) == "" { + jsonOut(w, 400, map[string]string{"error": "Hosted-Code fehlt. Erzeuge ihn im Neural-Hunt-Spiel mit der gewünschten Haupt-Identität."}) + return + } + clientID, err := s.redeemRewardLink(r.Context(), in.LinkCode) + if err != nil { + jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Hosted-Code konnte nicht gekoppelt werden: " + err.Error()}) + return + } + + // The one-shot code has now been consumed. Persist the proven owner before + // syncing already-known workers so a transient delegation failure does not + // destroy a valid pairing and force the customer to generate another code. + if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil { + jsonOut(w, 500, map[string]string{"error": "Hosted-Code wurde bestätigt, aber die Haupt-Identität konnte lokal nicht gespeichert werden: " + err.Error()}) + return + } + + var warnings []string + for _, wk := range workers { + if wk.WorkerClientID == "" { + continue + } + if err := s.syncDelegation(r.Context(), wk.WorkerClientID, clientID); err != nil { + warnings = append(warnings, fmt.Sprintf("Worker %s: %v", wk.ID, err)) + } + } + out := map[string]any{"ok": true, "reward_client_id": clientID} + if len(warnings) > 0 { + out["warning"] = "Haupt-Identität ist gekoppelt; bestehende Worker konnten noch nicht vollständig synchronisiert werden: " + strings.Join(warnings, "; ") + log.Printf("customer reward identity %s coupled with delegation warnings: %s", cid, strings.Join(warnings, "; ")) + } + jsonOut(w, 200, out) } func (s *Service) proxyGET(ctx context.Context, path string, out any) error { @@ -735,9 +764,60 @@ func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) } jsonOut(w, 200, map[string]bool{"ok": true}) } -func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) { + +// CheckGameControlPlane verifies that the configured private game listener is +// reachable and that both services use the same hosted-service secret. It is +// safe to call at startup and does not consume a Hosted Code. +func (s *Service) CheckGameControlPlane(ctx context.Context) error { + return s.checkGameControlPlane(ctx) +} + +func (s *Service) checkGameControlPlane(ctx context.Context) error { if strings.TrimSpace(s.cfg.SharedSecret) == "" { - return "", errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing") + return errors.New("CUSTOMER_SERVICE_SHARED_SECRET fehlt") + } + base := strings.TrimRight(strings.TrimSpace(s.cfg.GameAdminURL), "/") + if base == "" { + return errors.New("CS_GAME_ADMIN_URL fehlt") + } + endpoint := base + "/api/internal/customer-service/health" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret) + resp, err := s.hc.Do(req) + if err != nil { + return fmt.Errorf("Game-Control-Plane unter %s nicht erreichbar: %w. Bei lokalem 'go run' normalerweise CS_GAME_ADMIN_URL=http://127.0.0.1:8081; im Compose-Netz http://app:8081", base, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode == http.StatusUnauthorized { + return errors.New("Game-Control-Plane erreichbar, aber CUSTOMER_SERVICE_SHARED_SECRET stimmt zwischen Game Server und Customer Service nicht überein") + } + if resp.StatusCode == http.StatusNotFound { + return fmt.Errorf("Game-Control-Plane antwortet mit HTTP 404. CS_GAME_ADMIN_URL=%s zeigt wahrscheinlich auf den öffentlichen Listener/Reverse-Proxy oder auf ein älteres Game-Image ohne Hosted-Control-Plane", base) + } + if resp.StatusCode/100 != 2 { + return fmt.Errorf("Game-Control-Plane HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + var out struct { + OK bool `json:"ok"` + Service string `json:"service"` + CustomerLinkSupported bool `json:"customer_link_supported"` + } + if err := json.Unmarshal(b, &out); err != nil { + return fmt.Errorf("Game-Control-Plane liefert keine gültige JSON-Antwort: %w", err) + } + if !out.OK || out.Service != "neuralhunt-game-control-plane" || !out.CustomerLinkSupported { + return errors.New("Game-Control-Plane ist erreichbar, unterstützt aber die erwartete Hosted-Code-Schnittstelle nicht") + } + return nil +} + +func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) { + if err := s.checkGameControlPlane(ctx); err != nil { + return "", err } body, _ := json.Marshal(map[string]string{"code": strings.TrimSpace(code)}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/customer-link/consume", strings.NewReader(string(body))) @@ -752,8 +832,14 @@ func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, er } defer resp.Body.Close() b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if resp.StatusCode == http.StatusUnauthorized { + return "", errors.New("Game Server lehnt CUSTOMER_SERVICE_SHARED_SECRET ab") + } + if resp.StatusCode == http.StatusNotFound { + return "", errors.New("Hosted-Code ist abgelaufen, ungültig oder bereits verwendet") + } if resp.StatusCode/100 != 2 { - return "", fmt.Errorf("game pairing HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return "", fmt.Errorf("Game-Pairing HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) } var out struct { ClientID string `json:"client_id"` diff --git a/internal/customerui/dist/public/app.js b/internal/customerui/dist/public/app.js index 3bef17c..c60b3cb 100644 --- a/internal/customerui/dist/public/app.js +++ b/internal/customerui/dist/public/app.js @@ -12,4 +12,4 @@ async function workerAction(card,act){const id=card.dataset.id;try{if(act==='sav async function uploadIdentity(card,file){if(!file)return;if(!confirm('Die aktuelle Worker-Identity wird ersetzt. Der Worker wird dabei gestoppt. Fortfahren?'))return;try{const r=await fetch(`/api/workers/${encodeURIComponent(card.dataset.id)}/identity`,{method:'PUT',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:await file.text()});const x=await r.json().catch(()=>({}));if(!r.ok)throw new Error(x.error||'Upload fehlgeschlagen');msg('Identity ersetzt');await load()}catch(e){msg(e.message,true)}} function renderLedger(xs){$('ledger').innerHTML=xs.length?xs.map(x=>`
${esc(x.reason)}${new Date(x.created_at).toLocaleString()}${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}
`).join(''):'
Noch keine Buchungen.
'} function renderPackages(v){const h=$('packages');$('paypalNote').textContent=v.paypal_enabled?`PayPal ${v.environment||''} · Credits werden erst nach serverseitig bestätigtem Capture verbucht.`:'PayPal ist derzeit deaktiviert.';h.innerHTML=(v.packages||[]).map(p=>`
${credits(p.credits_micros)} Credits
${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}
`).join('');h.querySelectorAll('[data-pkg]').forEach(b=>b.onclick=async()=>{try{const x=await api('/api/billing/paypal/order',{method:'POST',body:{package_id:b.dataset.pkg}});location.href=x.approval_url}catch(e){msg(e.message,true)}})} -$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg('Haupt-Identität sicher gekoppelt');await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot(); +$('login').onclick=async()=>{try{await api('/api/login',{method:'POST',body:{Username:$('loginUser').value,Password:$('loginPass').value}});await load()}catch(e){msg(e.message,true)}};$('register').onclick=async()=>{try{await api('/api/register',{method:'POST',body:{Username:$('regUser').value,Password:$('regPass').value}});await load()}catch(e){msg(e.message,true)}};$('logout').onclick=async()=>{await api('/api/logout',{method:'POST'}).catch(()=>{});location.reload()};$('saveReward').onclick=async()=>{try{const code=$('rewardLinkCode').value.trim();if(!code)throw new Error('Hosted-Code einfügen');const x=await api('/api/reward-identity',{method:'PUT',body:{link_code:code}});$('rewardLinkCode').value='';msg(x.warning||'Haupt-Identität sicher gekoppelt',!!x.warning);await load()}catch(e){msg(e.message,true)}};$('clearReward').onclick=async()=>{if(!confirm('Reward-Kopplung wirklich lösen? Laufende Worker sollten vorher gestoppt werden.'))return;try{await api('/api/reward-identity',{method:'PUT',body:{clear:true}});msg('Reward-Kopplung gelöst');await load()}catch(e){msg(e.message,true)}};$('newWorker').onclick=()=> $('newWorkerBox').classList.toggle('hidden');$('createWorker').onclick=async()=>{try{await api('/api/workers',{method:'POST',body:{TaskID:$('newTask').value,BeaconPath:$('newPath').value}});$('newWorkerBox').classList.add('hidden');await load()}catch(e){msg(e.message,true)}};boot(); diff --git a/internal/server/server.go b/internal/server/server.go index 0f2e3d5..671060f 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -310,6 +310,7 @@ func (s *Server) Routes() http.Handler { r.Post("/api/internal/delegations", s.internalDelegation) r.Post("/api/internal/identity-exists", s.internalIdentityExists) r.Post("/api/internal/customer-link/consume", s.internalCustomerLinkConsume) + r.Get("/api/internal/customer-service/health", s.internalCustomerServiceHealth) r.Group(func(r chi.Router) { r.Use(func(n http.Handler) http.Handler { return s.require("user", n) }) r.Get("/api/tasks", s.clientTasks) @@ -903,6 +904,20 @@ func (s *Server) customerLinkCode(w http.ResponseWriter, r *http.Request) { jsonOut(w, 201, map[string]any{"code": code, "client_id": c.ClientID, "expires_at": expires}) } +func (s *Server) internalCustomerServiceHealth(w http.ResponseWriter, r *http.Request) { + secret := strings.TrimSpace(s.internalServiceSecret) + if !serviceTokenOK(secret, r.Header.Get("Authorization")) { + jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) + return + } + w.Header().Set("Cache-Control", "no-store") + jsonOut(w, http.StatusOK, map[string]any{ + "ok": true, + "service": "neuralhunt-game-control-plane", + "customer_link_supported": true, + }) +} + func (s *Server) internalCustomerLinkConsume(w http.ResponseWriter, r *http.Request) { secret := strings.TrimSpace(s.internalServiceSecret) if !serviceTokenOK(secret, r.Header.Get("Authorization")) {