RC-7
Some checks failed
release-tag / release-image (push) Failing after 2m44s

This commit is contained in:
2026-08-11 16:58:07 +02:00
parent 185ccf1101
commit bcfbef390f
44 changed files with 4778 additions and 172 deletions

View File

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

View File

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

View File

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

View File

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

View File

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