This commit is contained in:
@@ -190,18 +190,23 @@ type taskCard struct {
|
||||
}
|
||||
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
ServerMinIntervalSec int `json:"server_min_interval_sec"`
|
||||
ClientSubmitIntervalSec int `json:"client_submit_interval_sec"`
|
||||
DefaultMaxNodes int `json:"default_max_nodes"`
|
||||
GuessLotteryWindowSec int `json:"guess_lottery_window_sec"`
|
||||
GuessLotteryMaxAccepted int `json:"guess_lottery_max_accepted"`
|
||||
BeaconHuntEnabled int `json:"beacon_hunt_enabled"`
|
||||
BeaconBonusWeight int `json:"beacon_bonus_weight"`
|
||||
BeaconPaths []string `json:"beacon_paths"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
ParentTaskID *string `json:"parent_task_id"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
@@ -243,6 +248,15 @@ type publicArtifact struct {
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
type ownedArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
DownloadURI string `json:"download_uri"`
|
||||
}
|
||||
|
||||
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
@@ -273,6 +287,18 @@ func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
return out, err
|
||||
}
|
||||
|
||||
type hostedLinkCode struct {
|
||||
Code string `json:"code"`
|
||||
ClientID string `json:"client_id"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (c *apiClient) hostedLinkCode(ctx context.Context) (hostedLinkCode, error) {
|
||||
var out hostedLinkCode
|
||||
err := c.do(ctx, http.MethodPost, "/api/me/customer-link", map[string]any{}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) leaderboard(ctx context.Context) ([]leader, error) {
|
||||
var out []leader
|
||||
err := c.do(ctx, http.MethodGet, "/api/leaderboard", nil, &out)
|
||||
@@ -285,14 +311,25 @@ func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact,
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (bool, error) {
|
||||
func (c *apiClient) ownedArtifacts(ctx context.Context, limit int) ([]ownedArtifact, error) {
|
||||
var out []ownedArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/me/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64, beaconPath string) (bool, error) {
|
||||
guess := expectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, err := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
msg := fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess)
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
beaconPath = strings.ToUpper(strings.TrimSpace(beaconPath))
|
||||
msg += "|" + beaconPath
|
||||
}
|
||||
sig, err := signRaw(c.key, msg)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
var correct bool
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &correct)
|
||||
err = c.do(ctx, http.MethodPost, "/api/tasks/"+url.PathEscape(t.ID)+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig, "beacon_path": beaconPath}, &correct)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
@@ -348,6 +385,38 @@ func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) er
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadOwnedArtifact(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/me/artifacts/" + url.PathEscape(taskID) + "/download"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode/100 != 2 {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if dir := filepath.Dir(dest); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, io.LimitReader(resp.Body, 64<<20))
|
||||
return err
|
||||
}
|
||||
|
||||
func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
// Keep this exactly aligned with internal/core.ExpectedGuess without importing
|
||||
// implementation details into the terminal UX layer.
|
||||
@@ -355,3 +424,81 @@ func expectedGuess(taskID, seed, clientID string, seq int64, bits int) string {
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
|
||||
// registerHostedWorker binds this worker's freshly authenticated cryptographic
|
||||
// identity to its Customer Service worker record. The endpoint is reachable
|
||||
// only on the private Docker network and additionally requires the per-worker
|
||||
// one-time bearer token injected by the Customer Service.
|
||||
func (c *apiClient) registerHostedWorker(ctx context.Context) error {
|
||||
registerURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if registerURL == "" && workerID == "" && token == "" {
|
||||
return nil
|
||||
}
|
||||
if registerURL == "" || workerID == "" || token == "" {
|
||||
return errors.New("hosted worker registration requires NEURALHUNT_WORKER_REGISTER_URL, NEURALHUNT_WORKER_ID and NEURALHUNT_WORKER_REGISTER_TOKEN")
|
||||
}
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID, "client_id": c.cid})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, registerURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
hc := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hosted worker register: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("hosted worker register HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hostedWorkerLeaseLoop is a fail-closed control-plane lease. Billing remains
|
||||
// authoritative in Customer Service; this loop merely prevents a managed
|
||||
// worker from running indefinitely if the Customer Service/Docker controller
|
||||
// disappears. After three consecutive missed 20-second renewals it cancels the
|
||||
// client context and the managed container exits.
|
||||
func (c *apiClient) hostedWorkerLeaseLoop(ctx context.Context, cancel context.CancelFunc) {
|
||||
leaseURL := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_LEASE_URL"))
|
||||
workerID := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_ID"))
|
||||
token := strings.TrimSpace(os.Getenv("NEURALHUNT_WORKER_REGISTER_TOKEN"))
|
||||
if leaseURL == "" || workerID == "" || token == "" {
|
||||
return
|
||||
}
|
||||
t := time.NewTicker(20 * time.Second)
|
||||
defer t.Stop()
|
||||
failures := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
body, _ := json.Marshal(map[string]string{"worker_id": workerID})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, leaseURL, bytes.NewReader(body))
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, callErr := (&http.Client{Timeout: 8 * time.Second}).Do(req)
|
||||
if callErr == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
if resp.StatusCode/100 == 2 {
|
||||
failures = 0
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
failures++
|
||||
if failures >= 3 {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,15 @@ type identityFile struct {
|
||||
PrivateJWK privateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
const identityKDFIterations = 250000
|
||||
|
||||
type encryptedIdentity struct {
|
||||
Version int `json:"version"`
|
||||
Format string `json:"format,omitempty"`
|
||||
ClientID string `json:"clientId,omitempty"`
|
||||
KDF string `json:"kdf,omitempty"`
|
||||
Iterations int `json:"iterations,omitempty"`
|
||||
Cipher string `json:"cipher,omitempty"`
|
||||
Salt string `json:"salt"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
@@ -161,6 +168,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.Format != "" && enc.Format != "neuralhunt-identity-export" {
|
||||
return identityFile{}, errors.New("unsupported identity export format")
|
||||
}
|
||||
if enc.KDF != "" && enc.KDF != "PBKDF2-HMAC-SHA256" {
|
||||
return identityFile{}, errors.New("unsupported identity KDF")
|
||||
}
|
||||
if enc.Cipher != "" && enc.Cipher != "AES-256-GCM" {
|
||||
return identityFile{}, errors.New("unsupported identity cipher")
|
||||
}
|
||||
salt, err := rawURL.DecodeString(enc.Salt)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -173,7 +189,14 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
iterations := enc.Iterations
|
||||
if iterations == 0 {
|
||||
iterations = identityKDFIterations // compatibility with v1 exports
|
||||
}
|
||||
if iterations < 100000 || iterations > 2000000 {
|
||||
return identityFile{}, errors.New("unsupported identity KDF iteration count")
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, iterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
@@ -193,6 +216,15 @@ func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if enc.ClientID != "" {
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if cid != enc.ClientID {
|
||||
return identityFile{}, errors.New("identity export client ID mismatch")
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -200,6 +232,9 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if passphrase == "" {
|
||||
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
if len(passphrase) < 12 {
|
||||
return errors.New("identity export passphrase must be at least 12 characters")
|
||||
}
|
||||
plain, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -212,7 +247,7 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, identityKDFIterations, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -222,7 +257,15 @@ func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
return err
|
||||
}
|
||||
ct := gcm.Seal(nil, iv, plain, nil)
|
||||
enc := encryptedIdentity{Version: 1, Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct)}
|
||||
cid, err := auth.ClientID(id.PublicJWK)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
enc := encryptedIdentity{
|
||||
Version: 1, Format: "neuralhunt-identity-export", ClientID: cid,
|
||||
KDF: "PBKDF2-HMAC-SHA256", Iterations: identityKDFIterations, Cipher: "AES-256-GCM",
|
||||
Salt: rawURL.EncodeToString(salt), IV: rawURL.EncodeToString(iv), Ciphertext: rawURL.EncodeToString(ct),
|
||||
}
|
||||
b, err := json.MarshalIndent(enc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -24,6 +25,17 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var enc encryptedIdentity
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if enc.Format != "neuralhunt-identity-export" || enc.ClientID == "" || enc.Iterations != identityKDFIterations || enc.Cipher != "AES-256-GCM" {
|
||||
t.Fatalf("missing portable export metadata: %#v", enc)
|
||||
}
|
||||
got, err := readIdentityImport(path, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -36,6 +48,16 @@ func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityExportRejectsWeakPassphrase(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := exportBrowserIdentity(filepath.Join(t.TempDir(), "weak.json"), "too-short", id); err == nil {
|
||||
t.Fatal("expected short export passphrase to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawIdentityImport(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
|
||||
@@ -18,6 +18,7 @@ func main() {
|
||||
maxNodes := flag.Int("max-nodes", 500, "maximum target-field working set")
|
||||
nonInteractive := flag.Bool("non-interactive", false, "run unattended without command prompt")
|
||||
quiet := flag.Bool("quiet", false, "suppress connection/status chatter")
|
||||
beaconPath := flag.String("beacon-path", envOr("NEURALHUNT_BEACON_PATH", "auto"), "Beacon Hunt path: auto, pulse, flux or orbit")
|
||||
importPath := flag.String("import", "", "import browser/terminal identity JSON before login")
|
||||
exportPath := flag.String("export", "", "export current identity in browser-compatible encrypted format and exit")
|
||||
passphrase := flag.String("passphrase", os.Getenv("NEURALHUNT_IDENTITY_PASSPHRASE"), "identity import/export passphrase (prefer environment variable)")
|
||||
@@ -52,15 +53,22 @@ func main() {
|
||||
if err := api.login(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := api.registerHostedWorker(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go api.hostedWorkerLeaseLoop(ctx, stop)
|
||||
if created {
|
||||
fmt.Println("Neue Terminal-Identität erzeugt:", *identityPath)
|
||||
}
|
||||
fmt.Println("NEURAL HUNT SHELL")
|
||||
fmt.Println("Identity:", api.cid)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
fmt.Println("Client-ID :", api.cid)
|
||||
fmt.Println("Identity-Datei:", *identityPath)
|
||||
fmt.Println("Server :", strings.TrimRight(*base, "/"))
|
||||
fmt.Println("Backup : identity export <datei> (verschlüsselt, im Browser importierbar)")
|
||||
fmt.Println("Hinweis : Dieselbe Identity darf nicht gleichzeitig im Browser verbunden sein.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
a.beaconPathMode = strings.ToLower(strings.TrimSpace(*beaconPath))
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
102
cmd/client/ui.go
102
cmd/client/ui.go
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -31,18 +32,19 @@ type app struct {
|
||||
quiet bool
|
||||
unattended bool
|
||||
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
mu sync.RWMutex
|
||||
task taskDTO
|
||||
seq int64
|
||||
points map[string]point
|
||||
me meDTO
|
||||
ws *websocket.Conn
|
||||
wsConnected bool
|
||||
sessionCancel context.CancelFunc
|
||||
switching bool
|
||||
leaderWatch bool
|
||||
lastGuessAt time.Time
|
||||
lastGuessOK bool
|
||||
beaconPathMode string
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
@@ -428,6 +430,16 @@ func (a *app) refreshTask(taskID string) {
|
||||
}
|
||||
}
|
||||
|
||||
func chooseBeaconPath(mode, clientID string, seq int64) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "PULSE", "FLUX", "ORBIT":
|
||||
return strings.ToUpper(strings.TrimSpace(mode))
|
||||
}
|
||||
h := sha256.Sum256([]byte(fmt.Sprintf("nh-cli-beacon-auto|%s|%d", clientID, seq)))
|
||||
paths := []string{"PULSE", "FLUX", "ORBIT"}
|
||||
return paths[int(h[0])%len(paths)]
|
||||
}
|
||||
|
||||
func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
@@ -461,7 +473,14 @@ func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
if t.Paused || !connected {
|
||||
continue
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq)
|
||||
path := ""
|
||||
if t.BeaconHuntEnabled == 1 && t.GuessLotteryMaxAccepted > 0 {
|
||||
path = chooseBeaconPath(a.beaconPathMode, a.api.cid, seq)
|
||||
if !a.quiet {
|
||||
fmt.Printf("[beacon] Pfad %s · Bonusgewicht bei Treffer ×%d\n", path, t.BeaconBonusWeight)
|
||||
}
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq, path)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -613,6 +632,31 @@ func (a *app) printNFTs(ctx context.Context, limit int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) printMyNFTs(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := a.api.ownedArtifacts(ctx, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("\nMEINE NFTS / ORIGINAL-ARTEFAKTE")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
for i, n := range items {
|
||||
name := strings.TrimSpace(n.DisplayName)
|
||||
if name == "" {
|
||||
name = shortID(n.TaskID)
|
||||
}
|
||||
fmt.Printf("%2d %-24s task=%-16s %3dbit %s\n", i+1, name, shortID(n.TaskID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" Original: %s%s\n", a.api.base, n.DownloadURI)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("(für diese Identität noch keine fertigen Gewinner-Artefakte)")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) watcher(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
@@ -638,7 +682,7 @@ func (a *app) watcher(ctx context.Context) {
|
||||
|
||||
func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
s := bufio.NewScanner(in)
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], nft get <task-id> <datei>, identity, identity export <datei>, quit")
|
||||
fmt.Println("Befehle: help, tasks, use <nr|id|name>, status, map [n], leaderboard [n|watch|stop], nfts [n], my-nfts [n], nft get <task-id> <datei>, nft original <task-id> <datei>, identity, identity export <datei>, hosted-code, quit")
|
||||
for {
|
||||
fmt.Print("neuralhunt> ")
|
||||
if !s.Scan() {
|
||||
@@ -658,10 +702,13 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println(" map [n] textuelles Target Field / Nähe")
|
||||
fmt.Println(" leaderboard [n] Live-Rangliste")
|
||||
fmt.Println(" leaderboard watch|stop Rangliste alle 5s ein/aus")
|
||||
fmt.Println(" nfts [n] Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" nfts [n] öffentliche Wasserzeichen-NFTs anzeigen")
|
||||
fmt.Println(" my-nfts [n] eigene Gewinner-Artefakte anzeigen")
|
||||
fmt.Println(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
|
||||
fmt.Println(" nft original <task-id> <datei> eigenes Original speichern")
|
||||
fmt.Println(" identity Client-ID und Identity-Datei")
|
||||
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
|
||||
fmt.Println(" hosted-code 10-Minuten-Code zum Koppeln als Reward-Identität")
|
||||
fmt.Println(" quit beenden")
|
||||
case "tasks":
|
||||
if _, err := a.printTasks(ctx); err != nil {
|
||||
@@ -723,6 +770,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
if err := a.printNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "my-nfts", "mine":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printMyNFTs(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nft":
|
||||
if len(parts) == 4 && strings.EqualFold(parts[1], "get") {
|
||||
if err := a.api.downloadPreview(ctx, parts[2], parts[3]); err != nil {
|
||||
@@ -730,8 +785,14 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
} else {
|
||||
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
|
||||
}
|
||||
} else if len(parts) == 4 && (strings.EqualFold(parts[1], "original") || strings.EqualFold(parts[1], "download")) {
|
||||
if err := a.api.downloadOwnedArtifact(ctx, parts[2], parts[3]); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Original-Artefakt gespeichert:", parts[3])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("nft get <task-id> <datei>")
|
||||
fmt.Println("nft get <task-id> <datei> | nft original <task-id> <datei>")
|
||||
}
|
||||
case "identity", "id":
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
|
||||
@@ -748,6 +809,15 @@ func (a *app) commandLoop(ctx context.Context, in io.Reader) error {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "hosted-code", "hosted-link":
|
||||
x, err := a.api.hostedLinkCode(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Hosted-Code:", x.Code)
|
||||
fmt.Println("Client-ID :", x.ClientID)
|
||||
fmt.Println("Gültig bis :", x.ExpiresAt.Local().Format(time.RFC3339))
|
||||
}
|
||||
case "quit", "exit", "q":
|
||||
return nil
|
||||
default:
|
||||
|
||||
204
cmd/customer-service/main.go
Normal file
204
cmd/customer-service/main.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/customer"
|
||||
"neuralhunt/internal/customerui"
|
||||
)
|
||||
|
||||
func loadDotEnv(path string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
s := bufio.NewScanner(f)
|
||||
for s.Scan() {
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "export ") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "export "))
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(k); exists {
|
||||
continue
|
||||
}
|
||||
if len(v) >= 2 && ((v[0] == '"' && v[len(v)-1] == '"') || (v[0] == '\'' && v[len(v)-1] == '\'')) {
|
||||
v = v[1 : len(v)-1]
|
||||
}
|
||||
_ = os.Setenv(k, v)
|
||||
}
|
||||
}
|
||||
func env(k, d string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
func boolEnv(k string, d bool) bool {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(k)))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
func intEnv(k string, d int) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(os.Getenv(k)))
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
func floatEnv(k string, d float64) float64 {
|
||||
v, err := strconv.ParseFloat(strings.TrimSpace(os.Getenv(k)), 64)
|
||||
if err != nil {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
func durationEnv(k string, d time.Duration) time.Duration {
|
||||
v := strings.TrimSpace(os.Getenv(k))
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
if x, err := time.ParseDuration(v); err == nil {
|
||||
return x
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// CS_CREDIT_PACKAGES format: id:amount_cents:CURRENCY:credits;...
|
||||
// Example: starter:499:EUR:60;plus:1999:EUR:300
|
||||
func packagesFromEnv() ([]customer.CreditPackage, error) {
|
||||
raw := env("CS_CREDIT_PACKAGES", "starter:499:EUR:60;plus:1999:EUR:300;power:4999:EUR:900")
|
||||
var out []customer.CreditPackage
|
||||
for _, part := range strings.Split(raw, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
f := strings.Split(part, ":")
|
||||
if len(f) != 4 {
|
||||
return nil, fmt.Errorf("invalid CS_CREDIT_PACKAGES entry %q", part)
|
||||
}
|
||||
cents, err := strconv.ParseInt(f[1], 10, 64)
|
||||
if err != nil || cents <= 0 {
|
||||
return nil, fmt.Errorf("invalid package cents %q", f[1])
|
||||
}
|
||||
credits, err := strconv.ParseFloat(f[3], 64)
|
||||
if err != nil || credits <= 0 {
|
||||
return nil, fmt.Errorf("invalid package credits %q", f[3])
|
||||
}
|
||||
out = append(out, customer.CreditPackage{ID: strings.TrimSpace(f[0]), AmountCents: cents, Currency: strings.ToUpper(strings.TrimSpace(f[2])), CreditsMicros: int64(credits*1_000_000 + 0.5)})
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, fmt.Errorf("no credit packages configured")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func validate(cfg customer.Config) error {
|
||||
shared := strings.TrimSpace(cfg.SharedSecret)
|
||||
if len(shared) < 32 || strings.Contains(strings.ToLower(shared), "replace-with") || strings.Contains(strings.ToLower(shared), "change-me") {
|
||||
return fmt.Errorf("CUSTOMER_SERVICE_SHARED_SECRET must be a unique random value of at least 32 characters; example placeholders are rejected")
|
||||
}
|
||||
adminPass := strings.TrimSpace(cfg.AdminPassword)
|
||||
if len(adminPass) < 16 || strings.Contains(strings.ToLower(adminPass), "replace-with") || strings.Contains(strings.ToLower(adminPass), "change-me") {
|
||||
return fmt.Errorf("CS_ADMIN_PASSWORD must be a unique value of at least 16 characters; example placeholders are rejected")
|
||||
}
|
||||
if cfg.PublicBaseURL == "" {
|
||||
return fmt.Errorf("CS_PUBLIC_BASE_URL is required (the HTTPS customer portal URL)")
|
||||
}
|
||||
if cfg.WorkerImage == "" {
|
||||
return fmt.Errorf("CS_WORKER_IMAGE is required")
|
||||
}
|
||||
if _, err := customer.RegistryAuthHeader(cfg.WorkerRegistryUsername, cfg.WorkerRegistryPassword, cfg.WorkerRegistryServer); err != nil {
|
||||
return fmt.Errorf("worker registry auth: %w", err)
|
||||
}
|
||||
if cfg.PayPalEnabled && strings.EqualFold(cfg.PayPalEnvironment, "live") && cfg.PayPalLiveApprovalAck != "I_HAVE_PAYPAL_APPROVAL" {
|
||||
log.Printf("WARNING: PayPal live remains disabled until PAYPAL_LIVE_APPROVAL_ACK=I_HAVE_PAYPAL_APPROVAL is set after provider/legal approval")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
loadDotEnv(".env")
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
pkgs, err := packagesFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
rate := floatEnv("CS_WORKER_CREDITS_PER_MINUTE", 1.0)
|
||||
if rate <= 0 {
|
||||
log.Fatal("CS_WORKER_CREDITS_PER_MINUTE must be > 0")
|
||||
}
|
||||
cfg := customer.Config{
|
||||
PublicAddr: env("CS_HTTP_ADDR", ":8090"), AdminAddr: env("CS_ADMIN_HTTP_ADDR", ":8091"), InternalAddr: env("CS_INTERNAL_ADDR", ":8092"),
|
||||
PublicBaseURL: strings.TrimRight(env("CS_PUBLIC_BASE_URL", ""), "/"), GamePublicURL: env("CS_GAME_PUBLIC_URL", "http://app:8080"), GameAdminURL: env("CS_GAME_ADMIN_URL", "http://app:8081"), SharedSecret: env("CUSTOMER_SERVICE_SHARED_SECRET", ""),
|
||||
DockerHost: env("DOCKER_HOST", "unix:///var/run/docker.sock"), WorkerImage: env("CS_WORKER_IMAGE", "neuralhunt-worker:local"), WorkerEntrypoint: env("CS_WORKER_ENTRYPOINT", ""), WorkerNetwork: env("CS_WORKER_NETWORK", "neuralhunt_backend"), WorkerRegisterURL: env("CS_WORKER_REGISTER_URL", "http://customer-service:8092/internal/workers/register"), WorkerAutoPull: boolEnv("CS_WORKER_AUTO_PULL", true), WorkerRegistryUsername: env("CS_WORKER_REGISTRY_USERNAME", ""), WorkerRegistryPassword: env("CS_WORKER_REGISTRY_PASSWORD", ""), WorkerRegistryServer: env("CS_WORKER_REGISTRY_SERVER", ""), WorkerRateMicrosPerMinute: int64(rate*1_000_000 + 0.5), MaxWorkersPerCustomer: intEnv("CS_MAX_WORKERS_PER_CUSTOMER", 20), MaxWorkersGlobal: intEnv("CS_MAX_WORKERS_GLOBAL", 1000), MaxRunningPerCustomer: intEnv("CS_MAX_RUNNING_WORKERS_PER_CUSTOMER", 10), MaxRunningGlobal: intEnv("CS_MAX_RUNNING_WORKERS_GLOBAL", 100),
|
||||
SessionTTL: durationEnv("CS_SESSION_TTL", 24*time.Hour), CookieSecure: boolEnv("CS_COOKIE_SECURE", true), AdminUser: env("CS_ADMIN_USER", "admin"), AdminPassword: env("CS_ADMIN_PASSWORD", ""), AllowManualCredits: boolEnv("CS_ALLOW_MANUAL_CREDITS", false),
|
||||
PayPalEnabled: boolEnv("PAYPAL_ENABLED", false), PayPalEnvironment: env("PAYPAL_ENVIRONMENT", "sandbox"), PayPalWebhookID: env("PAYPAL_WEBHOOK_ID", ""), PayPalLiveApprovalAck: env("PAYPAL_LIVE_APPROVAL_ACK", ""), Packages: pkgs,
|
||||
}
|
||||
if err := validate(cfg); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
st, err := customer.Open(ctx, env("CUSTOMER_SQLITE_PATH", "/customer-data/customer-service.db"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer st.DB.Close()
|
||||
if err := st.RecoverStartingWorkers(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
dc, err := customer.NewDockerClient(cfg.DockerHost)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := dc.Ping(ctx); err != nil {
|
||||
log.Fatalf("Docker Engine API unavailable: %v", err)
|
||||
}
|
||||
pp := customer.NewPayPalClient(env("PAYPAL_CLIENT_ID", ""), env("PAYPAL_CLIENT_SECRET", ""), cfg.PayPalEnvironment)
|
||||
svc := customer.NewService(st, dc, pp, cfg)
|
||||
go svc.RunBilling(ctx)
|
||||
servers := []*http.Server{
|
||||
{Addr: cfg.PublicAddr, Handler: svc.PublicRoutes(customerui.Public()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second},
|
||||
{Addr: cfg.AdminAddr, Handler: svc.AdminRoutes(customerui.Admin()), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second},
|
||||
{Addr: cfg.InternalAddr, Handler: svc.InternalRoutes(), ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second},
|
||||
}
|
||||
names := []string{"customer public", "customer admin/private", "customer worker/internal"}
|
||||
for i, s := range servers {
|
||||
go func(n string, hs *http.Server) {
|
||||
log.Printf("%s listener on %s", n, hs.Addr)
|
||||
if err := hs.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(names[i], s)
|
||||
}
|
||||
<-ctx.Done()
|
||||
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer done()
|
||||
for _, s := range servers {
|
||||
_ = s.Shutdown(shutdown)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user