RC-8
All checks were successful
release-tag / release-image (push) Successful in 4m39s

This commit is contained in:
2026-08-11 19:02:09 +02:00
parent d9eb570629
commit ba3818ef30
8 changed files with 171 additions and 32 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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).

16
V4.2.2_HOSTED_CODE_FIX.md Normal file
View File

@@ -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.

View File

@@ -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},

View File

@@ -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"`

View File

@@ -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=>`<div class="ledger-row"><span>${esc(x.reason)}</span><span>${new Date(x.created_at).toLocaleString()}</span><strong class="${x.delta_micros>=0?'credit':'debit'}">${x.delta_micros>=0?'+':''}${credits(x.delta_micros)}</strong></div>`).join(''):'<div class="muted">Noch keine Buchungen.</div>'}
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=>`<div class="package"><span><strong>${credits(p.credits_micros)} Credits</strong><br><small>${(p.amount_cents/100).toFixed(2)} ${esc(p.currency)}</small></span><button data-pkg="${esc(p.id)}" ${v.paypal_enabled?'':'disabled'}>PAYPAL</button></div>`).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();

View File

@@ -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")) {