Files
groot 47c523dd98
All checks were successful
release-tag / release-image (push) Successful in 3m51s
RC-14
2026-08-14 06:17:30 +02:00

1936 lines
68 KiB
Go

package customer
import (
"context"
"crypto/subtle"
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
)
type CreditPackage struct {
ID string `json:"id"`
AmountCents int64 `json:"amount_cents"`
Currency string `json:"currency"`
CreditsMicros int64 `json:"credits_micros"`
}
type Config struct {
PublicAddr, AdminAddr, InternalAddr string
PublicBaseURL string
GamePublicURL, GameAdminURL, SharedSecret string
DockerHost, WorkerImage, WorkerEntrypoint, WorkerNetwork string
WorkerRegisterURL string
WorkerOrchestrationMode string
ControllerSharedSecret string
ControllerOfflineAfter time.Duration
WorkerRegistryUsername string
WorkerRegistryPassword string
WorkerRegistryServer string
WorkerAutoPull bool
WorkerRateMicrosPerMinute int64
MaxWorkersPerCustomer int
MaxWorkersGlobal int
MaxRunningPerCustomer int
MaxRunningGlobal int
SessionTTL time.Duration
CookieSecure bool
AdminCookieSecureMode string
AdminUser, AdminPassword string
AllowManualCredits bool
LoginEnabled bool
RegistrationEnabled bool
RegistrationPOWBits int
RegistrationInviteRequired bool
NewCustomerCreditsMicros int64
PositiveTipCreditsMicros int64
PayPalEnabled bool
PayPalEnvironment string
PayPalWebhookID string
PayPalLiveApprovalAck string
Packages []CreditPackage
}
type Service struct {
store *Store
docker WorkerRuntime
paypal *PayPalClient
cfg Config
adminSessions sync.Map
workerImageMu sync.Mutex
hc *http.Client
}
func NewService(store *Store, docker WorkerRuntime, paypal *PayPalClient, cfg Config) *Service {
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = 24 * time.Hour
}
if cfg.WorkerRateMicrosPerMinute <= 0 {
cfg.WorkerRateMicrosPerMinute = 1_000_000
}
if cfg.MaxWorkersPerCustomer <= 0 {
cfg.MaxWorkersPerCustomer = 20
}
if cfg.MaxWorkersGlobal <= 0 {
cfg.MaxWorkersGlobal = 1000
}
if cfg.MaxRunningPerCustomer <= 0 {
cfg.MaxRunningPerCustomer = 10
}
if cfg.MaxRunningGlobal <= 0 {
cfg.MaxRunningGlobal = 100
}
if cfg.ControllerOfflineAfter < 15*time.Second {
cfg.ControllerOfflineAfter = 45 * time.Second
}
mode := strings.ToLower(strings.TrimSpace(cfg.WorkerOrchestrationMode))
if mode == "" {
mode = "direct"
}
if mode != "direct" && mode != "controller" && mode != "hybrid" {
mode = "direct"
}
cfg.WorkerOrchestrationMode = mode
return &Service{store: store, docker: docker, paypal: paypal, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}}
}
func requestIsHTTPS(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0])
return strings.EqualFold(proto, "https")
}
func (s *Service) security(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; form-action 'self' https://www.paypal.com https://www.sandbox.paypal.com")
// HSTS is meaningful only for HTTPS responses. In particular, do not emit it
// on the VPN-only plain-HTTP admin listener.
if requestIsHTTPS(r) {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
func jsonOut(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func decode(r *http.Request, v any) error {
d := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
d.DisallowUnknownFields()
return d.Decode(v)
}
const customerCookie = "neuralhunt_customer_session"
const csAdminCookie = "neuralhunt_customer_admin"
func (s *Service) cookieSameSite(name string) http.SameSite {
// The public customer portal may return from external payment providers. Lax
// still blocks cross-site fetch/POST cookie use while allowing normal top-level
// navigation back to the portal. Keep the private admin cookie Strict.
if name == customerCookie {
return http.SameSiteLaxMode
}
return http.SameSiteStrictMode
}
func (s *Service) cookieSecure(r *http.Request, name string) bool {
if name != csAdminCookie {
// The public customer portal should normally stay HTTPS-only.
return s.cfg.CookieSecure
}
// The Customer-Service admin listener is commonly reachable only through an
// encrypted VPN but over plain HTTP. Keep its cookie policy independent from
// the public customer portal. "auto" means Secure behind HTTPS and non-Secure
// for a direct HTTP/VPN connection.
switch strings.ToLower(strings.TrimSpace(s.cfg.AdminCookieSecureMode)) {
case "0", "false", "no", "off":
return false
case "1", "true", "yes", "on":
return true
case "", "auto":
return requestIsHTTPS(r)
default:
return true
}
}
func (s *Service) setCookie(w http.ResponseWriter, r *http.Request, name, value string, ttl time.Duration) {
now := time.Now().UTC()
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
HttpOnly: true,
Secure: s.cookieSecure(r, name),
SameSite: s.cookieSameSite(name),
MaxAge: int(ttl.Seconds()),
Expires: now.Add(ttl),
})
}
func (s *Service) clearCookie(w http.ResponseWriter, r *http.Request, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
HttpOnly: true,
Secure: s.cookieSecure(r, name),
SameSite: s.cookieSameSite(name),
MaxAge: -1,
Expires: time.Unix(1, 0).UTC(),
})
}
func (s *Service) customerID(r *http.Request) (string, error) {
c, err := r.Cookie(customerCookie)
if err != nil {
return "", err
}
return s.store.SessionCustomer(r.Context(), c.Value)
}
func (s *Service) requireCustomer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cid, err := s.customerID(r)
if err != nil {
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
}
ctx := context.WithValue(r.Context(), ctxKey("customer"), cid)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
type ctxKey string
func customerID(r *http.Request) string {
v, _ := r.Context().Value(ctxKey("customer")).(string)
return v
}
func (s *Service) PublicRoutes(ui http.Handler) http.Handler {
r := chi.NewRouter()
r.Use(s.security)
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
r.Get("/api/registration/config", s.registrationConfig)
r.Post("/api/register/challenge", s.registrationChallenge)
r.Post("/api/register", s.register)
r.Post("/api/login", s.login)
r.Post("/api/logout", s.logout)
r.Post("/api/paypal/webhook", s.paypalWebhook)
r.Group(func(r chi.Router) {
r.Use(s.requireCustomer)
r.Get("/api/me", s.me)
r.Get("/api/ledger", s.ledger)
r.Put("/api/reward-identity", s.rewardIdentity)
r.Get("/api/tasks", s.tasks)
r.Get("/api/workers", s.workers)
r.Post("/api/workers", s.createWorker)
r.Put("/api/workers/{id}", s.updateWorker)
r.Post("/api/workers/{id}/start", s.startWorker)
r.Post("/api/workers/{id}/stop", s.stopWorker)
r.Delete("/api/workers/{id}", s.deleteWorker)
r.Get("/api/workers/{id}/identity", s.workerIdentityGet)
r.Put("/api/workers/{id}/identity", s.workerIdentityPut)
r.Get("/api/billing/packages", s.billingPackages)
r.Post("/api/billing/paypal/order", s.paypalCreateOrder)
r.Post("/api/billing/paypal/capture", s.paypalCapture)
})
r.Mount("/", ui)
return r
}
func (s *Service) AdminRoutes(ui http.Handler) http.Handler {
r := chi.NewRouter()
r.Use(s.security)
r.Post("/api/admin/login", s.adminLogin)
r.Post("/api/admin/logout", s.adminLogout)
r.Group(func(r chi.Router) {
r.Use(s.requireAdmin)
r.Get("/api/admin/overview", s.adminOverview)
r.Get("/api/admin/settings", s.adminSettings)
r.Put("/api/admin/settings", s.adminUpdateSettings)
r.Post("/api/admin/credits/grant", s.adminCreditGrant)
r.Post("/api/admin/customers/{id}/block", s.adminBlockCustomer)
r.Post("/api/admin/customers/{id}/unblock", s.adminUnblockCustomer)
r.Post("/api/admin/customers/{id}/workers/stop", s.adminStopCustomerWorkers)
r.Put("/api/admin/customers/{id}/worker-limit", s.adminSetCustomerWorkerLimit)
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.Put("/api/admin/controllers/{id}", s.adminUpdateControllerPolicy)
r.Post("/api/admin/invites", s.adminCreateInvite)
})
r.Mount("/", ui)
return r
}
func (s *Service) InternalRoutes() http.Handler {
r := chi.NewRouter()
r.Use(s.security)
r.Post("/internal/workers/register", s.internalWorkerRegister)
r.Post("/internal/workers/lease", s.internalWorkerLease)
r.Post("/internal/game/positive-tip", s.internalGamePositiveTip)
r.Post("/internal/controllers/register", s.internalControllerRegister)
r.Get("/api/healthz", func(w http.ResponseWriter, r *http.Request) { jsonOut(w, 200, map[string]bool{"ok": true}) })
return r
}
func (s *Service) register(w http.ResponseWriter, r *http.Request) {
cfg := s.portalSettings(r.Context())
if !cfg.RegistrationEnabled {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Registrierungen sind derzeit deaktiviert."})
return
}
var in struct {
Username string `json:"Username"`
Password string `json:"Password"`
Challenge string `json:"Challenge"`
ProofCounter uint64 `json:"ProofCounter"`
InviteCode string `json:"InviteCode"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
in.Username = strings.TrimSpace(in.Username)
if len(in.Username) < 3 || len(in.Username) > 80 {
jsonOut(w, 400, map[string]string{"error": "username must be 3..80 characters"})
return
}
if cfg.RegistrationPOWBits > 0 {
if !VerifyRegistrationChallenge(s.cfg.SharedSecret, in.Username, in.Challenge) || !VerifyRegistrationProof(in.Username, in.Challenge, in.ProofCounter, cfg.RegistrationPOWBits) {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "Registrierungs-Proof-of-Work ist ungültig oder abgelaufen."})
return
}
}
inviteHash := ""
if cfg.InviteRequired {
if strings.TrimSpace(in.InviteCode) == "" {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Für die Registrierung ist ein einmaliger Invite-Code erforderlich."})
return
}
inviteHash = HashInviteCode(in.InviteCode)
}
salt, hash, err := NewPasswordHash(in.Password)
if err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
cid := "cust_" + RandomToken(16)
if err := s.store.CreateCustomer(r.Context(), cid, in.Username, salt, hash, cfg.SignupBonusMicros, inviteHash); err != nil {
if errors.Is(err, ErrInvalidInvite) {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Invite-Code ist ungültig, abgelaufen oder bereits verwendet."})
return
}
jsonOut(w, 409, map[string]string{"error": "username already exists"})
return
}
sid := RandomToken(32)
if err := s.store.CreateSession(r.Context(), sid, cid, s.cfg.SessionTTL); err != nil {
jsonOut(w, 500, map[string]string{"error": "session failed"})
return
}
s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL)
jsonOut(w, 201, map[string]any{"id": cid, "username": in.Username, "signup_bonus_micros": cfg.SignupBonusMicros})
}
func (s *Service) login(w http.ResponseWriter, r *http.Request) {
if !s.portalSettings(r.Context()).LoginEnabled {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": "Anmeldung ist momentan administrativ deaktiviert."})
return
}
var in struct{ Username, Password string }
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
c, salt, hash, err := s.store.CustomerByUsername(r.Context(), in.Username)
if err != nil || !VerifyPassword(in.Password, salt, hash) {
time.Sleep(250 * time.Millisecond)
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
return
}
if c.Blocked {
jsonOut(w, http.StatusForbidden, map[string]string{"error": "Dieses Kundenkonto ist gesperrt."})
return
}
sid := RandomToken(32)
if err := s.store.CreateSession(r.Context(), sid, c.ID, s.cfg.SessionTTL); err != nil {
jsonOut(w, 500, map[string]string{"error": "session failed"})
return
}
s.setCookie(w, r, customerCookie, sid, s.cfg.SessionTTL)
jsonOut(w, 200, map[string]any{"ok": true})
}
func (s *Service) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(customerCookie); err == nil {
s.store.DeleteSession(r.Context(), c.Value)
}
s.clearCookie(w, r, customerCookie)
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) me(w http.ResponseWriter, r *http.Request) {
cid := customerID(r)
c, err := s.store.CustomerByID(r.Context(), cid)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "customer missing"})
return
}
bal, _ := s.store.BalanceMicros(r.Context(), cid)
jsonOut(w, 200, map[string]any{"customer": c, "balance_micros": bal, "worker_rate_micros_per_minute": s.cfg.WorkerRateMicrosPerMinute, "paypal_enabled": s.paypalAllowed()})
}
func (s *Service) ledger(w http.ResponseWriter, r *http.Request) {
x, err := s.store.LedgerSummary(r.Context(), customerID(r), 60)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, x)
}
func (s *Service) rewardIdentity(w http.ResponseWriter, r *http.Request) {
cid := customerID(r)
var in struct {
LinkCode string `json:"link_code"`
Clear bool `json:"clear"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
workers, _ := s.store.Workers(r.Context(), cid)
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, ""); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": ""})
return
}
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 {
log.Printf("customer reward identity %s pairing failed: %v", cid, err)
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 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(s.cfg.GamePublicURL, "/")+path, nil)
if err != nil {
return err
}
resp, err := s.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("game HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return json.Unmarshal(b, out)
}
func (s *Service) tasks(w http.ResponseWriter, r *http.Request) {
var out any
if err := s.proxyGET(r.Context(), "/api/public/tasks", &out); err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, out)
}
func (s *Service) taskAvailable(ctx context.Context, taskID string) (bool, error) {
var tasks []struct {
ID string `json:"id"`
Paused bool `json:"paused"`
}
if err := s.proxyGET(ctx, "/api/public/tasks", &tasks); err != nil {
return false, err
}
for _, t := range tasks {
if t.ID == strings.TrimSpace(taskID) {
return true, nil
}
}
return false, nil
}
func normalizePath(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
switch v {
case "pulse", "flux", "orbit", "auto":
return v
default:
return "auto"
}
}
func (s *Service) workers(w http.ResponseWriter, r *http.Request) {
x, err := s.store.Workers(r.Context(), customerID(r))
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
// Physical host/controller IDs are private infrastructure metadata. The
// customer-facing API deliberately keeps placement transparent.
for i := range x {
x[i].ControllerID = ""
x[i].ContainerID = ""
x[i].Volume = ""
x[i].LastLeaseAt = nil
}
jsonOut(w, 200, x)
}
func (s *Service) createWorkerRecord(ctx context.Context, cid, taskID, beaconPath string, ignoreCustomerLimit bool) (Worker, error) {
cust, err := s.store.CustomerByID(ctx, cid)
if err != nil {
return Worker{}, fmt.Errorf("customer not found")
}
if !ignoreCustomerLimit && !cust.WorkerLimitBypass {
n, err := s.store.WorkerCount(ctx, cid, false)
if err != nil {
return Worker{}, err
}
if n >= s.cfg.MaxWorkersPerCustomer {
return Worker{}, fmt.Errorf("worker limit reached (%d)", s.cfg.MaxWorkersPerCustomer)
}
}
n, err := s.store.TotalWorkerCount(ctx)
if err != nil {
return Worker{}, err
}
if n >= s.cfg.MaxWorkersGlobal {
return Worker{}, fmt.Errorf("global hosted worker inventory limit reached")
}
available, err := s.taskAvailable(ctx, taskID)
if err != nil {
return Worker{}, fmt.Errorf("task catalog: %w", err)
}
if !available {
return Worker{}, fmt.Errorf("task is not active/available")
}
wid := "wrk_" + RandomToken(12)
wk := Worker{ID: wid, CustomerID: cid, TaskID: strings.TrimSpace(taskID), BeaconPath: normalizePath(beaconPath), Volume: "nh_identity_" + strings.ReplaceAll(wid, "-", "_"), RegisterToken: RandomToken(24), RateMicrosPerMinute: s.cfg.WorkerRateMicrosPerMinute}
if err := s.store.CreateWorker(ctx, wk); err != nil {
return Worker{}, err
}
return wk, nil
}
func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) {
var in struct{ TaskID, BeaconPath string }
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
jsonOut(w, 400, map[string]string{"error": "task_id required"})
return
}
wk, err := s.createWorkerRecord(r.Context(), customerID(r), in.TaskID, in.BeaconPath, false)
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "limit reached") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "global hosted") || strings.Contains(err.Error(), "task catalog") {
status = http.StatusServiceUnavailable
}
jsonOut(w, status, map[string]string{"error": err.Error()})
return
}
wk.ControllerID = ""
wk.ContainerID = ""
wk.Volume = ""
wk.LastLeaseAt = nil
jsonOut(w, 201, wk)
}
func (s *Service) adminTaskCatalog(w http.ResponseWriter, r *http.Request) {
var out any
if err := s.proxyGET(r.Context(), "/api/public/tasks", &out); err != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusOK, out)
}
func (s *Service) adminCreateWorkerForCustomer(w http.ResponseWriter, r *http.Request) {
cid := chi.URLParam(r, "id")
var in struct {
TaskID string `json:"task_id"`
BeaconPath string `json:"beacon_path"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
jsonOut(w, http.StatusBadRequest, map[string]string{"error": "task_id required"})
return
}
wk, err := s.createWorkerRecord(r.Context(), cid, in.TaskID, in.BeaconPath, true)
if err != nil {
status := http.StatusBadRequest
if strings.Contains(err.Error(), "global hosted") {
status = http.StatusServiceUnavailable
}
if strings.Contains(err.Error(), "customer not found") {
status = http.StatusNotFound
}
jsonOut(w, status, map[string]string{"error": err.Error()})
return
}
jsonOut(w, http.StatusCreated, wk)
}
func (s *Service) updateWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
wk, err := s.store.Worker(r.Context(), cid, wid)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
var in struct{ TaskID, BeaconPath string }
if decode(r, &in) != nil || strings.TrimSpace(in.TaskID) == "" {
jsonOut(w, 400, map[string]string{"error": "task_id required"})
return
}
available, err := s.taskAvailable(r.Context(), in.TaskID)
if err != nil {
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
return
}
if !available {
jsonOut(w, 400, map[string]string{"error": "task is not active/available"})
return
}
if wk.ContainerID != "" {
// Revoke centrally first. If the remote host is temporarily unavailable,
// the worker loses its next lease and exits on its own.
_ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", wk.ContainerID, "")
if rt, rtErr := s.runtimeForWorker(r.Context(), &wk, false); rtErr == nil {
_ = rt.Stop(r.Context(), wk.ContainerID, 5)
_ = rt.Remove(r.Context(), wk.ContainerID)
_ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", "", "")
}
}
if err := s.store.UpdateWorkerConfig(r.Context(), cid, wid, strings.TrimSpace(in.TaskID), normalizePath(in.BeaconPath)); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) ensurePaidSlice(ctx context.Context, wk Worker) (ok bool, newlyCharged bool, chargedAt time.Time, err error) {
now := time.Now().UTC()
if wk.LastChargeAt != nil && now.Sub(*wk.LastChargeAt) < time.Minute {
return true, false, time.Time{}, nil
}
ok, err = s.store.ChargeWorkerMinute(ctx, wk, now)
return ok, ok && err == nil, now, err
}
func (s *Service) workerContainerConfig(wk Worker, name string) WorkerContainerConfig {
registerURL := strings.TrimSpace(s.cfg.WorkerRegisterURL)
if registerURL == "" {
registerURL = "http://customer-service:8092/internal/workers/register"
}
return WorkerContainerConfig{Image: s.cfg.WorkerImage, Entrypoint: s.cfg.WorkerEntrypoint, Network: s.cfg.WorkerNetwork, GameURL: s.cfg.GamePublicURL, RegisterURL: registerURL, WorkerID: wk.ID, RegisterToken: wk.RegisterToken, TaskID: wk.TaskID, BeaconPath: wk.BeaconPath, Volume: wk.Volume, Name: name}
}
func (s *Service) controllerOnline(c ServiceController) bool {
return c.Enabled && !c.Draining && time.Since(c.LastSeen) <= s.cfg.ControllerOfflineAfter
}
func (s *Service) controllerRuntime(ctx context.Context, controllerID string) (WorkerRuntime, error) {
c, err := s.store.ServiceController(ctx, strings.TrimSpace(controllerID))
if err != nil {
return nil, fmt.Errorf("service controller %q not found", controllerID)
}
if !c.Enabled {
return nil, fmt.Errorf("service controller %s is disabled", c.ID)
}
if time.Since(c.LastSeen) > s.cfg.ControllerOfflineAfter {
return nil, fmt.Errorf("service controller %s is offline (last heartbeat %s ago)", c.ID, time.Since(c.LastSeen).Round(time.Second))
}
return NewRemoteControllerClient(c.BaseURL, s.cfg.ControllerSharedSecret)
}
func (s *Service) chooseController(ctx context.Context) (ServiceController, error) {
controllers, err := s.store.ServiceControllers(ctx)
if err != nil {
return ServiceController{}, err
}
var best ServiceController
bestScore := 1e18
found := false
for _, c := range controllers {
if !s.controllerOnline(c) {
continue
}
total, running, err := s.store.ControllerWorkerCounts(ctx, c.ID)
if err != nil {
continue
}
if c.MaxWorkers > 0 && (total >= c.MaxWorkers || c.ReportedWorkers >= c.MaxWorkers) {
continue
}
maxW := c.MaxWorkers
if maxW <= 0 {
maxW = 1
}
maxR := c.MaxRunning
if maxR <= 0 {
maxR = 1
}
// Prefer the least-loaded controller. Local DB assignment counts are the
// source of truth; reported counts add a small penalty for emergency/manual
// containers that the Master may not know about yet.
score := float64(running)/float64(maxR)*10 + float64(total)/float64(maxW)
if c.ReportedRunning > running {
score += float64(c.ReportedRunning-running) / float64(maxR) * 4
}
if c.ReportedWorkers > total {
score += float64(c.ReportedWorkers-total) / float64(maxW)
}
if !found || score < bestScore {
best, bestScore, found = c, score, true
}
}
if !found {
return ServiceController{}, errors.New("no online Service Controller with free capacity")
}
return best, nil
}
// runtimeForWorker resolves the worker host. Existing assignments are sticky so
// identity volumes never jump hosts accidentally. New workers are placed on a
// healthy controller in controller/hybrid mode; direct mode keeps the legacy
// local docker.sock behavior.
func (s *Service) runtimeForWorker(ctx context.Context, wk *Worker, assign bool) (WorkerRuntime, error) {
if wk == nil {
return nil, errors.New("worker required")
}
if strings.EqualFold(strings.TrimSpace(wk.ControllerID), "local") {
if s.docker == nil {
return nil, errors.New("worker is assigned to local Docker but local runtime is disabled")
}
return s.docker, nil
}
if strings.TrimSpace(wk.ControllerID) != "" {
return s.controllerRuntime(ctx, wk.ControllerID)
}
mode := s.cfg.WorkerOrchestrationMode
if mode == "direct" {
if s.docker == nil {
return nil, errors.New("local Docker runtime unavailable")
}
return s.docker, nil
}
// Backward compatibility: workers created before Service Controllers existed
// have no controller_id. If they already have a local container, never move
// their named identity volume implicitly to another host.
if wk.ContainerID != "" || wk.WorkerClientID != "" {
if s.docker != nil {
_ = s.store.SetWorkerController(ctx, wk.ID, "local")
wk.ControllerID = "local"
return s.docker, nil
}
return nil, errors.New("legacy local worker has an existing runtime identity but no controller assignment; use direct/hybrid mode to export or stop it before moving to controller-only mode")
}
if assign {
c, err := s.chooseController(ctx)
if err == nil {
rt, rtErr := NewRemoteControllerClient(c.BaseURL, s.cfg.ControllerSharedSecret)
if rtErr != nil {
return nil, rtErr
}
if err := s.store.SetWorkerController(ctx, wk.ID, c.ID); err != nil {
return nil, err
}
wk.ControllerID = c.ID
return rt, nil
}
if mode == "controller" {
return nil, err
}
}
if mode == "hybrid" && s.docker != nil {
_ = s.store.SetWorkerController(ctx, wk.ID, "local")
wk.ControllerID = "local"
return s.docker, nil
}
return nil, errors.New("worker has no Service Controller assignment")
}
func (s *Service) ensureWorkerImageOn(ctx context.Context, rt WorkerRuntime) error {
if rt == nil {
return errors.New("worker runtime unavailable")
}
auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer)
if err != nil {
return err
}
return rt.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth)
}
func (s *Service) pullWorkerImageOn(ctx context.Context, rt WorkerRuntime) error {
if rt == nil {
return errors.New("worker runtime unavailable")
}
auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer)
if err != nil {
return err
}
if err := rt.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 {
rt, err := s.runtimeForWorker(ctx, &wk, true)
if err != nil {
return err
}
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 != "" {
_ = rt.Stop(ctx, oldID, 5)
if err := rt.Remove(ctx, oldID); err != nil {
return fmt.Errorf("remove old worker container: %w", err)
}
}
name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_")
_ = rt.Remove(ctx, name)
containerID, err := rt.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 := rt.Start(ctx, containerID); err != nil {
_ = rt.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 {
_ = rt.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.
func (s *Service) ensureWorkerContainer(ctx context.Context, wk Worker) (Worker, error) {
if wk.ContainerID != "" {
return wk, nil
}
rt, err := s.runtimeForWorker(ctx, &wk, true)
if err != nil {
return wk, err
}
s.workerImageMu.Lock()
err = s.ensureWorkerImageOn(ctx, rt)
s.workerImageMu.Unlock()
if err != nil {
return wk, err
}
name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_")
id, err := rt.CreateWorker(ctx, s.workerContainerConfig(wk, name))
if err != nil {
return wk, err
}
wk.ContainerID = id
if err := s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", id, ""); err != nil {
_ = rt.Remove(ctx, id)
return wk, err
}
return wk, nil
}
func (s *Service) startWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
wk, err := s.store.Worker(r.Context(), cid, wid)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
if wk.Status != "running" {
cust, custErr := s.store.CustomerByID(r.Context(), cid)
if custErr != nil {
jsonOut(w, 404, map[string]string{"error": "customer not found"})
return
}
if !cust.WorkerLimitBypass {
if n, _ := s.store.WorkerCount(r.Context(), cid, true); n >= s.cfg.MaxRunningPerCustomer {
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("running worker limit reached (%d)", s.cfg.MaxRunningPerCustomer)})
return
}
}
if n, _ := s.store.RunningWorkerCount(r.Context()); n >= s.cfg.MaxRunningGlobal {
jsonOut(w, 503, map[string]string{"error": "hosted worker capacity reached"})
return
}
}
available, err := s.taskAvailable(r.Context(), wk.TaskID)
if err != nil {
jsonOut(w, 502, map[string]string{"error": "task catalog: " + err.Error()})
return
}
if !available {
jsonOut(w, 409, map[string]string{"error": "assigned task is no longer active; choose another task"})
return
}
cust, err := s.store.CustomerByID(r.Context(), cid)
if err != nil || strings.TrimSpace(cust.RewardClientID) == "" {
jsonOut(w, 409, map[string]string{"error": "configure and verify a main reward identity before starting hosted workers"})
return
}
rt, err := s.runtimeForWorker(r.Context(), &wk, true)
if err != nil {
jsonOut(w, 503, map[string]string{"error": err.Error()})
return
}
s.workerImageMu.Lock()
err = s.ensureWorkerImageOn(r.Context(), rt)
s.workerImageMu.Unlock()
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
claimed, err := s.store.ClaimWorkerStart(r.Context(), cid, wid)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
if !claimed {
jsonOut(w, 409, map[string]string{"error": "worker is already running or starting"})
return
}
ok, newlyCharged, chargedAt, err := s.ensurePaidSlice(r.Context(), wk)
if err != nil {
_ = s.store.SetWorkerRuntime(r.Context(), wid, "error", wk.ContainerID, err.Error())
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
if !ok {
_ = s.store.SetWorkerRuntime(r.Context(), wid, "stopped", wk.ContainerID, "prepaid balance exhausted")
jsonOut(w, 402, map[string]string{"error": "prepaid balance exhausted"})
return
}
if wk.ContainerID != "" {
_ = rt.Remove(r.Context(), wk.ContainerID)
}
name := "neuralhunt-worker-" + strings.TrimPrefix(wid, "wrk_")
_ = rt.Remove(r.Context(), name)
containerID, err := rt.CreateWorker(r.Context(), s.workerContainerConfig(wk, name))
if err == nil {
err = rt.Start(r.Context(), containerID)
}
if err != nil {
if containerID != "" {
_ = rt.Stop(r.Context(), containerID, 2)
_ = rt.Remove(r.Context(), containerID)
}
if newlyCharged {
_ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "runtime_start_failed")
}
_ = s.store.SetWorkerRuntime(r.Context(), wid, "error", "", err.Error())
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
if err := s.store.SetWorkerRuntime(r.Context(), wid, "running", containerID, ""); err != nil {
_ = rt.Stop(r.Context(), containerID, 2)
_ = rt.Remove(r.Context(), containerID)
if newlyCharged {
_ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "runtime_state_failed")
}
jsonOut(w, 500, map[string]string{"error": "worker state could not be persisted"})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) stopWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
wk, err := s.store.Worker(r.Context(), cid, wid)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
if err := s.stopWorkerInternal(r.Context(), wk, "stopped"); err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) stopWorkerInternal(ctx context.Context, wk Worker, status string) error {
// Revoke the central lease first. Even when a remote controller is offline,
// the agent can no longer renew its lease and will self-terminate.
if err := s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, ""); err != nil {
return err
}
if wk.ContainerID == "" {
return nil
}
rt, err := s.runtimeForWorker(ctx, &wk, false)
if err != nil {
return err
}
if err := rt.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") {
return err
}
return nil
}
func (s *Service) deleteWorker(w http.ResponseWriter, r *http.Request) {
cid, wid := customerID(r), chi.URLParam(r, "id")
wk, err := s.store.Worker(r.Context(), cid, wid)
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
rt, rtErr := s.runtimeForWorker(r.Context(), &wk, false)
if wk.ContainerID != "" {
if rtErr != nil {
jsonOut(w, 502, map[string]string{"error": "worker host unavailable; refusing to orphan identity volume: " + rtErr.Error()})
return
}
_ = rt.Stop(r.Context(), wk.ContainerID, 3)
if err := rt.Remove(r.Context(), wk.ContainerID); err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
}
if wk.WorkerClientID != "" {
_ = s.syncDelegation(r.Context(), wk.WorkerClientID, "")
}
if rtErr == nil && rt != nil {
if err := rt.RemoveVolume(r.Context(), wk.Volume); err != nil {
jsonOut(w, 502, map[string]string{"error": "identity volume removal failed: " + err.Error()})
return
}
}
if err := s.store.DeleteWorker(r.Context(), cid, wid); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) workerIdentityGet(w http.ResponseWriter, r *http.Request) {
wk, err := s.store.Worker(r.Context(), customerID(r), chi.URLParam(r, "id"))
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
if wk.ContainerID == "" {
jsonOut(w, 409, map[string]string{"error": "worker identity does not exist yet; start the worker once or upload an identity first"})
return
}
rt, err := s.runtimeForWorker(r.Context(), &wk, false)
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
b, err := rt.GetFile(r.Context(), wk.ContainerID, "/identity/identity.json")
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Disposition", `attachment; filename="neuralhunt-worker-identity.json"`)
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(b)
}
func (s *Service) workerIdentityPut(w http.ResponseWriter, r *http.Request) {
wk, err := s.store.Worker(r.Context(), customerID(r), chi.URLParam(r, "id"))
if err != nil {
jsonOut(w, 404, map[string]string{"error": "worker not found"})
return
}
wk, err = s.ensureWorkerContainer(r.Context(), wk)
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
rt, err := s.runtimeForWorker(r.Context(), &wk, false)
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
b, err := io.ReadAll(io.LimitReader(r.Body, 256<<10))
if err != nil || len(b) == 0 {
jsonOut(w, 400, map[string]string{"error": "identity JSON required"})
return
}
if err := ValidateRawIdentity(b); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
if wk.Status == "running" {
_ = rt.Stop(r.Context(), wk.ContainerID, 5)
}
if err := rt.PutFile(r.Context(), wk.ContainerID, "/identity", "identity.json", b); err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
_ = s.store.SetWorkerClient(r.Context(), wk.ID, "")
_ = s.store.SetWorkerRuntime(r.Context(), wk.ID, "stopped", wk.ContainerID, "")
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) authenticateWorkerInternal(r *http.Request, workerID string) (Worker, bool) {
wk, err := s.store.WorkerByID(r.Context(), workerID)
if err != nil {
return Worker{}, false
}
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if len(provided) != len(wk.RegisterToken) || subtle.ConstantTimeCompare([]byte(provided), []byte(wk.RegisterToken)) != 1 {
return Worker{}, false
}
return wk, true
}
func (s *Service) internalWorkerLease(w http.ResponseWriter, r *http.Request) {
var in struct {
WorkerID string `json:"worker_id"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.WorkerID) == "" {
jsonOut(w, 400, map[string]string{"error": "worker_id required"})
return
}
wk, ok := s.authenticateWorkerInternal(r, in.WorkerID)
if !ok {
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
}
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
}
_ = s.store.TouchWorkerLease(r.Context(), wk.ID)
jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45})
}
func (s *Service) internalControllerRegister(w http.ResponseWriter, r *http.Request) {
secret := strings.TrimSpace(s.cfg.ControllerSharedSecret)
provided := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
if len(secret) < 32 || len(provided) != len(secret) || subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
jsonOut(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
var in struct {
ID string `json:"id"`
Name string `json:"name"`
BaseURL string `json:"base_url"`
ProtocolVersion int `json:"protocol_version"`
MaxWorkers int `json:"max_workers"`
MaxRunning int `json:"max_running"`
ReportedWorkers int `json:"reported_workers"`
ReportedRunning int `json:"reported_running"`
}
if decode(r, &in) != nil || strings.TrimSpace(in.ID) == "" || strings.TrimSpace(in.BaseURL) == "" {
jsonOut(w, 400, map[string]string{"error": "id and base_url required"})
return
}
u, err := url.Parse(strings.TrimSpace(in.BaseURL))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
jsonOut(w, 400, map[string]string{"error": "invalid base_url"})
return
}
if in.ProtocolVersion != 1 {
jsonOut(w, http.StatusConflict, map[string]string{"error": fmt.Sprintf("unsupported Service Controller protocol version %d (Master supports 1)", in.ProtocolVersion)})
return
}
if in.MaxWorkers <= 0 || in.MaxWorkers > 100000 || in.MaxRunning <= 0 || in.MaxRunning > 100000 || in.ReportedWorkers < 0 || in.ReportedRunning < 0 {
jsonOut(w, 400, map[string]string{"error": "invalid capacity"})
return
}
baseURL := strings.TrimRight(strings.TrimSpace(in.BaseURL), "/")
// Registration is bidirectional: accept a heartbeat only when the Master can
// reach the advertised private control endpoint as well.
rt, rtErr := NewRemoteControllerClient(baseURL, s.cfg.ControllerSharedSecret)
if rtErr != nil {
jsonOut(w, 400, map[string]string{"error": rtErr.Error()})
return
}
pingCtx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
pingErr := rt.Ping(pingCtx)
cancel()
if pingErr != nil {
jsonOut(w, http.StatusBadGateway, map[string]string{"error": "Master cannot reach advertised controller URL: " + pingErr.Error()})
return
}
name := strings.TrimSpace(in.Name)
if name == "" {
name = in.ID
}
if err := s.store.UpsertServiceController(r.Context(), ServiceController{ID: strings.TrimSpace(in.ID), Name: name, BaseURL: baseURL, ProtocolVersion: in.ProtocolVersion, MaxWorkers: in.MaxWorkers, MaxRunning: in.MaxRunning, ReportedWorkers: in.ReportedWorkers, ReportedRunning: in.ReportedRunning}); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "controller_id": strings.TrimSpace(in.ID), "master_mode": s.cfg.WorkerOrchestrationMode})
}
func (s *Service) internalWorkerRegister(w http.ResponseWriter, r *http.Request) {
var in struct {
WorkerID string `json:"worker_id"`
ClientID string `json:"client_id"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
wk, ok := s.authenticateWorkerInternal(r, in.WorkerID)
if !ok {
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
}
_ = s.store.TouchWorkerLease(r.Context(), wk.ID)
cust, _ := s.store.CustomerByID(r.Context(), wk.CustomerID)
if err := s.syncDelegation(r.Context(), in.ClientID, cust.RewardClientID); err != nil {
_ = s.store.SetWorkerRuntime(r.Context(), wk.ID, "error", wk.ContainerID, "delegation failed: "+err.Error())
jsonOut(w, 502, map[string]string{"error": "delegation failed: " + err.Error()})
return
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
// 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 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)))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
resp, err := s.hc.Do(req)
if err != nil {
return "", err
}
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)))
}
var out struct {
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(b, &out); err != nil {
return "", err
}
if strings.TrimSpace(out.ClientID) == "" {
return "", errors.New("game returned empty reward identity")
}
return strings.TrimSpace(out.ClientID), nil
}
func (s *Service) rewardIdentityExists(ctx context.Context, clientID string) (bool, error) {
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
return false, errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
}
body, _ := json.Marshal(map[string]string{"client_id": clientID})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/identity-exists", strings.NewReader(string(body)))
if err != nil {
return false, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
resp, err := s.hc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode/100 != 2 {
return false, fmt.Errorf("game identity HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
var out struct {
Exists bool `json:"exists"`
}
if err := json.Unmarshal(b, &out); err != nil {
return false, err
}
return out.Exists, nil
}
func (s *Service) syncDelegation(ctx context.Context, worker, owner string) error {
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
return errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
}
body, _ := json.Marshal(map[string]string{"worker_client_id": worker, "owner_client_id": owner})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(s.cfg.GameAdminURL, "/")+"/api/internal/delegations", strings.NewReader(string(body)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+s.cfg.SharedSecret)
resp, err := s.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode/100 != 2 {
return fmt.Errorf("game delegation HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
func (s *Service) RunBilling(ctx context.Context) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.billOnce(ctx)
}
}
}
func (s *Service) billOnce(ctx context.Context) {
workers, err := s.store.RunningWorkers(ctx)
if err != nil {
log.Printf("customer billing: %v", err)
return
}
now := time.Now().UTC()
for _, wk := range workers {
if cust, err := s.store.CustomerByID(ctx, wk.CustomerID); err == nil && cust.Blocked {
_ = s.stopWorkerInternal(ctx, wk, "stopped")
log.Printf("billing worker %s stopped: customer account blocked", wk.ID)
continue
}
if wk.ContainerID == "" {
continue
}
// A fresh worker lease is the most reliable liveness signal in distributed
// mode because it reaches the Master independently of the controller API.
alive := wk.LastLeaseAt != nil && now.Sub(*wk.LastLeaseAt) <= 90*time.Second
if !alive {
rt, rtErr := s.runtimeForWorker(ctx, &wk, false)
if rtErr == nil {
running, runErr := rt.Running(ctx, wk.ContainerID)
if runErr == nil && running {
alive = true
}
}
}
if !alive {
_ = s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", wk.ContainerID, "worker lease/liveness expired")
continue
}
last := wk.LastChargeAt
if last == nil {
t := now
last = &t
}
for now.Sub(*last) >= time.Minute {
next := last.Add(time.Minute)
ok, err := s.store.ChargeWorkerMinute(ctx, wk, next)
if err != nil {
log.Printf("billing worker %s: %v", wk.ID, err)
break
}
if !ok {
_ = s.stopWorkerInternal(ctx, wk, "stopped")
log.Printf("billing worker %s stopped: prepaid balance exhausted", wk.ID)
break
}
last = &next
wk.LastChargeAt = last
}
}
}
func (s *Service) billingPackages(w http.ResponseWriter, r *http.Request) {
enabled := s.paypalAllowed()
packages := s.cfg.Packages
if !enabled {
packages = nil
}
jsonOut(w, 200, map[string]any{"paypal_enabled": enabled, "environment": s.cfg.PayPalEnvironment, "packages": packages})
}
func (s *Service) paypalAllowed() bool {
if !s.cfg.PayPalEnabled || s.paypal == nil || !s.paypal.Ready() {
return false
}
if strings.EqualFold(s.cfg.PayPalEnvironment, "live") && s.cfg.PayPalLiveApprovalAck != "I_HAVE_PAYPAL_APPROVAL" {
return false
}
return true
}
func (s *Service) packageByID(id string) (CreditPackage, bool) {
for _, p := range s.cfg.Packages {
if p.ID == id {
return p, true
}
}
return CreditPackage{}, false
}
func centsValue(c int64) string {
return fmt.Sprintf("%d.%02d", c/100, int64(math.Abs(float64(c%100))))
}
func parseMoneyCents(v string) (int64, error) {
v = strings.TrimSpace(v)
if v == "" || strings.HasPrefix(v, "-") || strings.HasPrefix(v, "+") {
return 0, errors.New("invalid money value")
}
whole, frac, hasDot := strings.Cut(v, ".")
if whole == "" {
whole = "0"
}
if hasDot {
if len(frac) > 2 {
return 0, errors.New("money value has more than two decimals")
}
for len(frac) < 2 {
frac += "0"
}
} else {
frac = "00"
}
w, err := strconv.ParseInt(whole, 10, 64)
if err != nil || w < 0 {
return 0, errors.New("invalid money value")
}
f, err := strconv.ParseInt(frac, 10, 64)
if err != nil || f < 0 || f > 99 {
return 0, errors.New("invalid money value")
}
if w > (math.MaxInt64-f)/100 {
return 0, errors.New("money value too large")
}
return w*100 + f, nil
}
func (s *Service) paypalCreateOrder(w http.ResponseWriter, r *http.Request) {
if !s.paypalAllowed() {
jsonOut(w, 503, map[string]string{"error": "PayPal is disabled or live approval acknowledgement is missing"})
return
}
var in struct {
PackageID string `json:"package_id"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
pkg, ok := s.packageByID(in.PackageID)
if !ok {
jsonOut(w, 400, map[string]string{"error": "unknown package"})
return
}
cid := customerID(r)
base := strings.TrimRight(s.cfg.PublicBaseURL, "/")
order, err := s.paypal.CreateOrder(r.Context(), "credits:"+cid+":"+pkg.ID, "Neural Hunt hosted worker credits", centsValue(pkg.AmountCents), pkg.Currency, base+"/?paypal=return", base+"/?paypal=cancel")
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
approve := ApprovalURL(order)
if order.ID == "" || approve == "" {
jsonOut(w, 502, map[string]string{"error": "PayPal did not return an approval URL"})
return
}
if err := s.store.UpsertPayPalOrder(r.Context(), order.ID, cid, pkg.ID, pkg.AmountCents, pkg.Currency, pkg.CreditsMicros, order.Status); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 201, map[string]string{"order_id": order.ID, "approval_url": approve})
}
func (s *Service) paypalCapture(w http.ResponseWriter, r *http.Request) {
if !s.paypalAllowed() {
jsonOut(w, 503, map[string]string{"error": "PayPal disabled"})
return
}
var in struct {
OrderID string `json:"order_id"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
stored, err := s.store.PayPalOrder(r.Context(), in.OrderID)
if err != nil || stored.CustomerID != customerID(r) {
jsonOut(w, 404, map[string]string{"error": "order not found"})
return
}
view, err := s.paypal.CaptureOrder(r.Context(), stored.OrderID)
if err != nil {
view, err = s.paypal.GetOrder(r.Context(), stored.OrderID)
}
if err != nil {
jsonOut(w, 502, map[string]string{"error": err.Error()})
return
}
if err := s.reconcilePayPal(r.Context(), stored, view); err != nil {
jsonOut(w, 409, map[string]string{"error": err.Error()})
return
}
bal, _ := s.store.BalanceMicros(r.Context(), stored.CustomerID)
jsonOut(w, 200, map[string]any{"ok": true, "balance_micros": bal})
}
func (s *Service) reconcilePayPal(ctx context.Context, stored PayPalOrder, view PayPalOrderView) error {
if !strings.EqualFold(view.Status, "COMPLETED") {
return fmt.Errorf("PayPal order status is %s", view.Status)
}
if len(view.PurchaseUnits) < 1 {
return errors.New("PayPal order has no purchase unit")
}
cents, err := parseMoneyCents(view.PurchaseUnits[0].Amount.Value)
if err != nil {
return err
}
if cents != stored.AmountCents || !strings.EqualFold(view.PurchaseUnits[0].Amount.CurrencyCode, stored.Currency) {
return errors.New("PayPal captured amount/currency does not match requested credit package")
}
captureID := ""
for _, c := range view.PurchaseUnits[0].Payments.Captures {
if !strings.EqualFold(c.Status, "COMPLETED") {
continue
}
cc, err := parseMoneyCents(c.Amount.Value)
if err == nil && cc == stored.AmountCents && strings.EqualFold(c.Amount.CurrencyCode, stored.Currency) {
captureID = c.ID
break
}
}
if captureID == "" {
return errors.New("PayPal order has no matching completed capture")
}
return s.store.CompletePayPalOrder(ctx, stored.OrderID, captureID)
}
func findOrderID(v any) string {
switch x := v.(type) {
case map[string]any:
if sd, ok := x["supplementary_data"].(map[string]any); ok {
if rel, ok := sd["related_ids"].(map[string]any); ok {
if id, ok := rel["order_id"].(string); ok {
return id
}
}
}
for k, v := range x {
if k == "order_id" {
if id, ok := v.(string); ok {
return id
}
}
if id := findOrderID(v); id != "" {
return id
}
}
case []any:
for _, v := range x {
if id := findOrderID(v); id != "" {
return id
}
}
}
return ""
}
func (s *Service) paypalWebhook(w http.ResponseWriter, r *http.Request) {
if !s.paypalAllowed() {
jsonOut(w, 503, map[string]string{"error": "PayPal disabled"})
return
}
raw, err := io.ReadAll(io.LimitReader(r.Body, 2<<20))
if err != nil {
jsonOut(w, 400, map[string]string{"error": "bad body"})
return
}
ok, err := s.paypal.VerifyWebhook(r.Context(), s.cfg.PayPalWebhookID, r.Header, raw)
if err != nil || !ok {
jsonOut(w, 401, map[string]string{"error": "invalid PayPal webhook signature"})
return
}
var ev any
if json.Unmarshal(raw, &ev) != nil {
jsonOut(w, 400, map[string]string{"error": "bad event"})
return
}
orderID := findOrderID(ev)
if orderID != "" {
if stored, err := s.store.PayPalOrder(r.Context(), orderID); err == nil {
if view, err := s.paypal.GetOrder(r.Context(), orderID); err == nil {
_ = s.reconcilePayPal(r.Context(), stored, view)
}
}
}
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) adminLogin(w http.ResponseWriter, r *http.Request) {
var in struct{ Username, Password string }
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
uok := len(in.Username) == len(s.cfg.AdminUser) && subtle.ConstantTimeCompare([]byte(in.Username), []byte(s.cfg.AdminUser)) == 1
pok := len(in.Password) == len(s.cfg.AdminPassword) && subtle.ConstantTimeCompare([]byte(in.Password), []byte(s.cfg.AdminPassword)) == 1
if !uok || !pok {
time.Sleep(300 * time.Millisecond)
jsonOut(w, 401, map[string]string{"error": "invalid credentials"})
return
}
sid := RandomToken(32)
s.adminSessions.Store(sid, time.Now().Add(12*time.Hour))
s.setCookie(w, r, csAdminCookie, sid, 12*time.Hour)
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(csAdminCookie)
if err != nil {
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
}
v, ok := s.adminSessions.Load(c.Value)
exp, _ := v.(time.Time)
if !ok || time.Now().After(exp) {
s.adminSessions.Delete(c.Value)
jsonOut(w, 401, map[string]string{"error": "unauthorized"})
return
}
next.ServeHTTP(w, r)
})
}
func (s *Service) adminLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(csAdminCookie); err == nil {
s.adminSessions.Delete(c.Value)
}
s.clearCookie(w, r, csAdminCookie)
jsonOut(w, 200, map[string]bool{"ok": true})
}
func (s *Service) adminOverview(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT c.id,c.username,c.reward_client_id,c.blocked,c.blocked_reason,c.blocked_at,c.worker_limit_bypass,c.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0) FROM customers c ORDER BY c.created_at DESC LIMIT 500`)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
defer rows.Close()
var out []map[string]any
for rows.Next() {
var id, user, reward, blockedReason string
var blocked, workerLimitBypass int
var blockedAt sql.NullInt64
var created, bal int64
if err := rows.Scan(&id, &user, &reward, &blocked, &blockedReason, &blockedAt, &workerLimitBypass, &created, &bal); err != nil {
continue
}
workers, _ := s.store.Workers(r.Context(), id)
running := 0
for _, wk := range workers {
if wk.Status == "running" {
running++
}
}
item := map[string]any{
"id": id, "username": user, "reward_client_id": reward,
"blocked": blocked != 0, "blocked_reason": blockedReason, "worker_limit_bypass": workerLimitBypass != 0,
"created_at": time.UnixMilli(created).UTC(), "balance_micros": bal,
"workers": len(workers), "running": running, "worker_items": workers,
}
if blockedAt.Valid {
item["blocked_at"] = time.UnixMilli(blockedAt.Int64).UTC()
}
out = append(out, item)
}
controllers, _ := s.store.ServiceControllers(r.Context())
controllerItems := make([]map[string]any, 0, len(controllers))
for _, c := range controllers {
total, running, _ := s.store.ControllerWorkerCounts(r.Context(), c.ID)
controllerItems = append(controllerItems, map[string]any{"id": c.ID, "name": c.Name, "base_url": c.BaseURL, "protocol_version": c.ProtocolVersion, "max_workers": c.MaxWorkers, "max_running": c.MaxRunning, "reported_workers": c.ReportedWorkers, "reported_running": c.ReportedRunning, "assigned_workers": total, "assigned_running": running, "enabled": c.Enabled, "draining": c.Draining, "online": time.Since(c.LastSeen) <= s.cfg.ControllerOfflineAfter, "last_seen": c.LastSeen, "last_error": c.LastError})
}
jsonOut(w, 200, map[string]any{
"manual_credits_enabled": s.cfg.AllowManualCredits,
"paypal_enabled": s.paypalAllowed(),
"worker_image": s.cfg.WorkerImage,
"orchestration_mode": s.cfg.WorkerOrchestrationMode,
"controllers": controllerItems,
"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
}
rt, err := s.runtimeForWorker(r.Context(), &wk, true)
if err != nil {
jsonOut(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()})
return
}
s.workerImageMu.Lock()
err = s.pullWorkerImageOn(r.Context(), rt)
s.workerImageMu.Unlock()
if 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, "controller_id": wk.ControllerID, "resumed": wk.Status == "running" || wk.Status == "starting"})
}
func (s *Service) adminUpdateAllWorkerImages(w http.ResponseWriter, r *http.Request) {
rows, err := s.store.DB.QueryContext(r.Context(), `SELECT `+workerCols+` FROM workers ORDER BY created_at`)
if err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
var workers []Worker
for rows.Next() {
wk, scanErr := scanWorker(rows)
if scanErr != nil {
_ = rows.Close()
jsonOut(w, 500, map[string]string{"error": scanErr.Error()})
return
}
workers = append(workers, wk)
}
_ = rows.Close()
// Pull once per distinct worker host, then recreate each worker on the host
// that owns its persistent identity volume.
pullState := map[string]error{}
updated := 0
var warnings []string
for i := range workers {
wk := workers[i]
rt, rtErr := s.runtimeForWorker(r.Context(), &wk, true)
if rtErr != nil {
warnings = append(warnings, wk.ID+": "+rtErr.Error())
continue
}
key := wk.ControllerID
if key == "" {
key = "local"
}
pullErr, seen := pullState[key]
if !seen {
s.workerImageMu.Lock()
pullErr = s.pullWorkerImageOn(r.Context(), rt)
s.workerImageMu.Unlock()
pullState[key] = pullErr
if pullErr != nil {
warnings = append(warnings, "host "+key+": "+pullErr.Error())
}
}
if pullErr != nil {
continue
}
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), "hosts": len(pullState), "warnings": warnings})
}
func (s *Service) adminUpdateControllerPolicy(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(chi.URLParam(r, "id"))
if id == "" {
jsonOut(w, 400, map[string]string{"error": "controller id required"})
return
}
var in struct {
Enabled bool `json:"enabled"`
Draining bool `json:"draining"`
}
if decode(r, &in) != nil {
jsonOut(w, 400, map[string]string{"error": "bad json"})
return
}
if err := s.store.SetServiceControllerPolicy(r.Context(), id, in.Enabled, in.Draining); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "id": id, "enabled": in.Enabled, "draining": in.Draining})
}
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"})
return
}
var in struct {
CustomerID string `json:"customer_id"`
Credits float64 `json:"credits"`
Reason string `json:"reason"`
}
if decode(r, &in) != nil || in.Credits <= 0 || in.Credits > 1_000_000 {
jsonOut(w, 400, map[string]string{"error": "invalid grant"})
return
}
micros := int64(math.Round(in.Credits * 1_000_000))
ref := "manual:" + RandomToken(12)
reason := "manual_test_grant"
if strings.TrimSpace(in.Reason) != "" {
reason += ":" + strings.TrimSpace(in.Reason)
}
if err := s.store.AddLedger(r.Context(), strings.TrimSpace(in.CustomerID), micros, reason, ref); err != nil {
jsonOut(w, 400, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "reference": ref})
}
// Keep sql imported in this file because several handlers intentionally treat
// sql.ErrNoRows as a 404 without leaking database details.
var _ = sql.ErrNoRows
var _ = url.PathEscape