Files
neural-hunt/internal/customer/server.go
jbergner bcfbef390f
Some checks failed
release-tag / release-image (push) Failing after 2m44s
RC-7
2026-08-11 16:58:07 +02:00

1174 lines
41 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
WorkerAutoPull bool
WorkerRateMicrosPerMinute int64
MaxWorkersPerCustomer int
MaxWorkersGlobal int
MaxRunningPerCustomer int
MaxRunningGlobal int
SessionTTL time.Duration
CookieSecure bool
AdminUser, AdminPassword string
AllowManualCredits bool
PayPalEnabled bool
PayPalEnvironment string
PayPalWebhookID string
PayPalLiveApprovalAck string
Packages []CreditPackage
}
type Service struct {
store *Store
docker *DockerClient
paypal *PayPalClient
cfg Config
adminSessions sync.Map
workerImageMu sync.Mutex
hc *http.Client
}
func NewService(store *Store, docker *DockerClient, 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
}
return &Service{store: store, docker: docker, paypal: paypal, cfg: cfg, hc: &http.Client{Timeout: 10 * time.Second}}
}
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")
if s.cfg.CookieSecure {
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) setCookie(w http.ResponseWriter, name, value string, ttl time.Duration) {
http.SetCookie(w, &http.Cookie{Name: name, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: int(ttl.Seconds())})
}
func (s *Service) clearCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.CookieSecure, SameSite: http.SameSiteStrictMode, MaxAge: -1})
}
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.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.Post("/api/admin/credits/grant", s.adminCreditGrant)
})
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.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) {
var in struct{ Username, Password string }
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
}
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); err != nil {
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, customerCookie, sid, s.cfg.SessionTTL)
jsonOut(w, 201, map[string]string{"id": cid, "username": in.Username})
}
func (s *Service) login(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
}
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
}
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, 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, 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.Ledger(r.Context(), customerID(r), 100)
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
}
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()})
return
}
}
}
if err := s.store.SetRewardClientID(r.Context(), cid, clientID); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 200, map[string]any{"ok": true, "reward_client_id": clientID})
}
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
}
jsonOut(w, 200, x)
}
func (s *Service) createWorker(w http.ResponseWriter, r *http.Request) {
cid := customerID(r)
if n, err := s.store.WorkerCount(r.Context(), cid, false); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
} else if n >= s.cfg.MaxWorkersPerCustomer {
jsonOut(w, 409, map[string]string{"error": fmt.Sprintf("worker limit reached (%d)", s.cfg.MaxWorkersPerCustomer)})
return
}
if n, err := s.store.TotalWorkerCount(r.Context()); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
} else if n >= s.cfg.MaxWorkersGlobal {
jsonOut(w, 503, map[string]string{"error": "global hosted worker inventory limit reached"})
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
}
wid := "wrk_" + RandomToken(12)
wk := Worker{ID: wid, CustomerID: cid, TaskID: strings.TrimSpace(in.TaskID), BeaconPath: normalizePath(in.BeaconPath), Volume: "nh_identity_" + strings.ReplaceAll(wid, "-", "_"), RegisterToken: RandomToken(24), RateMicrosPerMinute: s.cfg.WorkerRateMicrosPerMinute}
// Store the intended named-volume reference, but do not allocate Docker
// resources merely because an account created a stopped worker. Docker will
// create the named volume lazily when the worker is started or an identity is
// explicitly uploaded. This prevents free stopped-worker creation from
// consuming host volumes.
if err := s.store.CreateWorker(r.Context(), wk); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
jsonOut(w, 201, 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 != "" {
_ = s.docker.Stop(r.Context(), wk.ContainerID, 5)
_ = s.docker.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) ensureWorkerImage(ctx context.Context) error {
// Avoid several customer requests triggering concurrent pulls of the same
// image. The local inspect is cheap once the image is available.
s.workerImageMu.Lock()
defer s.workerImageMu.Unlock()
auth, err := RegistryAuthHeader(s.cfg.WorkerRegistryUsername, s.cfg.WorkerRegistryPassword, s.cfg.WorkerRegistryServer)
if err != nil {
return err
}
return s.docker.EnsureImage(ctx, s.cfg.WorkerImage, s.cfg.WorkerAutoPull, auth)
}
// 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
}
if err := s.ensureWorkerImage(ctx); err != nil {
return wk, err
}
name := "neuralhunt-worker-" + strings.TrimPrefix(wk.ID, "wrk_")
id, err := s.docker.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 {
_ = s.docker.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" {
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
}
if err := s.ensureWorkerImage(r.Context()); 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 != "" {
_ = s.docker.Remove(r.Context(), wk.ContainerID)
}
name := "neuralhunt-worker-" + strings.TrimPrefix(wid, "wrk_")
_ = s.docker.Remove(r.Context(), name) // clears a crash-orphan with the deterministic name
containerID, err := s.docker.CreateWorker(r.Context(), s.workerContainerConfig(wk, name))
if err == nil {
err = s.docker.Start(r.Context(), containerID)
}
if err != nil {
if containerID != "" {
_ = s.docker.Stop(r.Context(), containerID, 2)
_ = s.docker.Remove(r.Context(), containerID)
}
if newlyCharged {
_ = s.store.RefundWorkerStartMinute(r.Context(), wk, chargedAt, "docker_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 {
_ = s.docker.Stop(r.Context(), containerID, 2)
_ = s.docker.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]any{"ok": true, "container_id": containerID})
}
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 {
if wk.ContainerID != "" {
if err := s.docker.Stop(ctx, wk.ContainerID, 5); err != nil && !strings.Contains(err.Error(), "304") {
return err
}
}
return s.store.SetWorkerRuntime(ctx, wk.ID, status, wk.ContainerID, "")
}
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
}
if wk.ContainerID != "" {
_ = s.docker.Stop(r.Context(), wk.ContainerID, 3)
_ = s.docker.Remove(r.Context(), wk.ContainerID)
}
if wk.WorkerClientID != "" {
_ = s.syncDelegation(r.Context(), wk.WorkerClientID, "")
}
if err := s.store.DeleteWorker(r.Context(), cid, wid); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
_ = s.docker.RemoveVolume(r.Context(), wk.Volume)
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
}
b, err := s.docker.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
}
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" {
_ = s.docker.Stop(r.Context(), wk.ContainerID, 5)
}
if err := s.docker.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
}
jsonOut(w, 200, map[string]any{"ok": true, "lease_sec": 45})
}
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
}
if err := s.store.SetWorkerClient(r.Context(), wk.ID, in.ClientID); err != nil {
jsonOut(w, 500, map[string]string{"error": err.Error()})
return
}
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})
}
func (s *Service) redeemRewardLink(ctx context.Context, code string) (string, error) {
if strings.TrimSpace(s.cfg.SharedSecret) == "" {
return "", errors.New("CUSTOMER_SERVICE_SHARED_SECRET missing")
}
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/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 wk.ContainerID == "" {
continue
}
running, err := s.docker.Running(ctx, wk.ContainerID)
if err != nil || !running {
_ = s.store.SetWorkerRuntime(ctx, wk.ID, "stopped", wk.ContainerID, "")
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) {
jsonOut(w, 200, map[string]any{"paypal_enabled": s.paypalAllowed(), "environment": s.cfg.PayPalEnvironment, "packages": s.cfg.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, 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, 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.created_at,COALESCE((SELECT sum(delta_micros) FROM credit_ledger l WHERE l.customer_id=c.id),0),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id),(SELECT count(*) FROM workers w WHERE w.customer_id=c.id AND w.status='running') 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 string
var created, bal int64
var workers, running int
if err := rows.Scan(&id, &user, &reward, &created, &bal, &workers, &running); err != nil {
continue
}
out = append(out, map[string]any{"id": id, "username": user, "reward_client_id": reward, "created_at": time.UnixMilli(created).UTC(), "balance_micros": bal, "workers": workers, "running": running})
}
jsonOut(w, 200, map[string]any{"manual_credits_enabled": s.cfg.AllowManualCredits, "customers": out})
}
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