This commit is contained in:
326
cmd/client/api.go
Normal file
326
cmd/client/api.go
Normal file
@@ -0,0 +1,326 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"neuralhunt/internal/core"
|
||||
)
|
||||
|
||||
func pad32Int(x *big.Int) []byte {
|
||||
b := x.Bytes()
|
||||
out := make([]byte, 32)
|
||||
if len(b) > 32 {
|
||||
b = b[len(b)-32:]
|
||||
}
|
||||
copy(out[32-len(b):], b)
|
||||
return out
|
||||
}
|
||||
|
||||
func signRaw(key *ecdsa.PrivateKey, message string) (string, error) {
|
||||
h := sha256.Sum256([]byte(message))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, key, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := append(pad32Int(r), pad32Int(s)...)
|
||||
return base64.RawURLEncoding.EncodeToString(raw), nil
|
||||
}
|
||||
|
||||
type apiError struct {
|
||||
Status int
|
||||
Body string
|
||||
}
|
||||
|
||||
func (e *apiError) Error() string {
|
||||
return fmt.Sprintf("HTTP %d: %s", e.Status, strings.TrimSpace(e.Body))
|
||||
}
|
||||
|
||||
func (e *apiError) Code() string {
|
||||
var v struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if json.Unmarshal([]byte(e.Body), &v) == nil {
|
||||
return v.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
token string
|
||||
cid string
|
||||
id identityFile
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
func newAPI(base string, id identityFile, key *ecdsa.PrivateKey) *apiClient {
|
||||
return &apiClient{
|
||||
base: strings.TrimRight(base, "/"),
|
||||
hc: &http.Client{Timeout: 15 * time.Second},
|
||||
id: id,
|
||||
key: key,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *apiClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
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()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return &apiError{Status: resp.StatusCode, Body: string(b)}
|
||||
}
|
||||
if out != nil && len(bytes.TrimSpace(b)) != 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return fmt.Errorf("decode %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *apiClient) login(ctx context.Context) error {
|
||||
var ch struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/challenge", map[string]any{"public_jwk": c.id.PublicJWK}, &ch); err != nil {
|
||||
return fmt.Errorf("challenge: %w", err)
|
||||
}
|
||||
sig, err := signRaw(c.key, "login|"+ch.Challenge+"|"+ch.ClientID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var lg struct {
|
||||
Token string `json:"token"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodPost, "/api/auth/login", map[string]any{
|
||||
"public_jwk": c.id.PublicJWK,
|
||||
"challenge": ch.Challenge,
|
||||
"signature": sig,
|
||||
}, &lg); err != nil {
|
||||
return fmt.Errorf("login: %w", err)
|
||||
}
|
||||
c.token, c.cid = lg.Token, lg.ClientID
|
||||
return nil
|
||||
}
|
||||
|
||||
type taskCard struct {
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Description string `json:"description"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
Paused bool `json:"paused"`
|
||||
Revision int64 `json:"revision"`
|
||||
PointCount int `json:"point_count"`
|
||||
OwnScore float64 `json:"own_score"`
|
||||
OwnRank int64 `json:"own_rank"`
|
||||
Selected bool `json:"selected"`
|
||||
GuessMinIntervalSec *int `json:"guess_min_interval_sec,omitempty"`
|
||||
ClientSubmitIntervalSec *int `json:"client_submit_interval_sec,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type point struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Score float64 `json:"score"`
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
Z float64 `json:"z"`
|
||||
GuessCount int64 `json:"guess_count"`
|
||||
Rank int64 `json:"rank"`
|
||||
}
|
||||
|
||||
type meDTO struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Score float64 `json:"score"`
|
||||
Rank int64 `json:"rank"`
|
||||
Wins int `json:"wins"`
|
||||
Unlocks []string `json:"unlocks"`
|
||||
}
|
||||
|
||||
type leader struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Wins int `json:"wins"`
|
||||
BestScore float64 `json:"best_score"`
|
||||
LiveScore float64 `json:"live_score"`
|
||||
GuessCount int64 `json:"guess_count"`
|
||||
Connected bool `json:"connected"`
|
||||
Unlocks []string `json:"unlocks"`
|
||||
NFTCount int `json:"nft_count"`
|
||||
NFTTaskID *string `json:"nft_task_id,omitempty"`
|
||||
NFTPreviewURI *string `json:"nft_preview_uri,omitempty"`
|
||||
}
|
||||
|
||||
type publicArtifact struct {
|
||||
TaskID string `json:"task_id"`
|
||||
WinnerClientID string `json:"winner_client_id"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
PreviewURI string `json:"preview_uri"`
|
||||
}
|
||||
|
||||
func (c *apiClient) tasks(ctx context.Context) ([]taskCard, error) {
|
||||
var out []taskCard
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) selectTask(ctx context.Context, taskID string) (taskDTO, error) {
|
||||
var out taskDTO
|
||||
err := c.do(ctx, http.MethodPost, "/api/tasks/select", map[string]string{"task_id": taskID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) currentTask(ctx context.Context) (taskDTO, error) {
|
||||
var out taskDTO
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks/current", nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) points(ctx context.Context, taskID string, limit int) ([]point, error) {
|
||||
var out []point
|
||||
err := c.do(ctx, http.MethodGet, "/api/tasks/"+url.PathEscape(taskID)+"/points?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) me(ctx context.Context) (meDTO, error) {
|
||||
var out meDTO
|
||||
err := c.do(ctx, http.MethodGet, "/api/me", nil, &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)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) artifacts(ctx context.Context, limit int) ([]publicArtifact, error) {
|
||||
var out []publicArtifact
|
||||
err := c.do(ctx, http.MethodGet, "/api/public/artifacts?limit="+strconv.Itoa(limit), nil, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (c *apiClient) guess(ctx context.Context, t taskDTO, seq int64) (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))
|
||||
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)
|
||||
return correct, err
|
||||
}
|
||||
|
||||
func (c *apiClient) dialWS(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("max_nodes", strconv.Itoa(maxNodes))
|
||||
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
|
||||
conn, resp, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
|
||||
if err != nil && resp != nil {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
_ = resp.Body.Close()
|
||||
return nil, fmt.Errorf("websocket %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func (c *apiClient) downloadPreview(ctx context.Context, taskID, dest string) error {
|
||||
path := "/api/public/artifacts/" + url.PathEscape(taskID) + "/preview"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.base+path, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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, 32<<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.
|
||||
return core.ExpectedGuess(taskID, seed, clientID, seq, bits)
|
||||
}
|
||||
|
||||
var errSwitching = errors.New("task switch in progress")
|
||||
261
cmd/client/identity.go
Normal file
261
cmd/client/identity.go
Normal file
@@ -0,0 +1,261 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"neuralhunt/internal/auth"
|
||||
)
|
||||
|
||||
var rawURL = base64.RawURLEncoding
|
||||
|
||||
type privateJWK struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D string `json:"d"`
|
||||
Ext bool `json:"ext,omitempty"`
|
||||
KeyOps []string `json:"key_ops,omitempty"`
|
||||
Alg string `json:"alg,omitempty"`
|
||||
Use string `json:"use,omitempty"`
|
||||
Kid string `json:"kid,omitempty"`
|
||||
}
|
||||
|
||||
type identityFile struct {
|
||||
Version int `json:"version"`
|
||||
PublicJWK auth.PublicJWK `json:"publicJwk"`
|
||||
PrivateJWK privateJWK `json:"privateJwk"`
|
||||
}
|
||||
|
||||
type encryptedIdentity struct {
|
||||
Version int `json:"version"`
|
||||
Salt string `json:"salt"`
|
||||
IV string `json:"iv"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
func pad32Bytes(b []byte) []byte {
|
||||
out := make([]byte, 32)
|
||||
if len(b) > len(out) {
|
||||
b = b[len(b)-len(out):]
|
||||
}
|
||||
copy(out[len(out)-len(b):], b)
|
||||
return out
|
||||
}
|
||||
|
||||
func generateIdentity() (identityFile, *ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return identityFile{}, nil, err
|
||||
}
|
||||
x := rawURL.EncodeToString(pad32Bytes(key.X.Bytes()))
|
||||
y := rawURL.EncodeToString(pad32Bytes(key.Y.Bytes()))
|
||||
d := rawURL.EncodeToString(pad32Bytes(key.D.Bytes()))
|
||||
pub := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, Ext: true, KeyOps: []string{"verify"}}
|
||||
priv := privateJWK{Kty: "EC", Crv: "P-256", X: x, Y: y, D: d, Ext: true, KeyOps: []string{"sign"}}
|
||||
return identityFile{Version: 1, PublicJWK: pub, PrivateJWK: priv}, key, nil
|
||||
}
|
||||
|
||||
func privateKeyFromIdentity(id identityFile) (*ecdsa.PrivateKey, error) {
|
||||
if id.Version != 1 || id.PrivateJWK.Kty != "EC" || id.PrivateJWK.Crv != "P-256" || id.PrivateJWK.D == "" {
|
||||
return nil, errors.New("unsupported identity; expected version 1 P-256 JWK")
|
||||
}
|
||||
db, err := rawURL.DecodeString(id.PrivateJWK.D)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode private JWK: %w", err)
|
||||
}
|
||||
d := new(big.Int).SetBytes(db)
|
||||
curve := elliptic.P256()
|
||||
if d.Sign() <= 0 || d.Cmp(curve.Params().N) >= 0 {
|
||||
return nil, errors.New("invalid P-256 private scalar")
|
||||
}
|
||||
x, y := curve.ScalarBaseMult(pad32Bytes(db))
|
||||
if rawURL.EncodeToString(pad32Bytes(x.Bytes())) != id.PublicJWK.X || rawURL.EncodeToString(pad32Bytes(y.Bytes())) != id.PublicJWK.Y {
|
||||
return nil, errors.New("identity public/private key mismatch")
|
||||
}
|
||||
return &ecdsa.PrivateKey{PublicKey: ecdsa.PublicKey{Curve: curve, X: x, Y: y}, D: d}, nil
|
||||
}
|
||||
|
||||
func defaultIdentityPath() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return "./neuralhunt-identity.json"
|
||||
}
|
||||
return filepath.Join(home, ".neuralhunt", "identity.json")
|
||||
}
|
||||
|
||||
func saveIdentity(path string, id identityFile) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(id, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Chmod(path, 0o600)
|
||||
}
|
||||
|
||||
func loadOrCreateIdentity(path string) (identityFile, *ecdsa.PrivateKey, bool, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(b, &id); err != nil {
|
||||
return identityFile{}, nil, false, fmt.Errorf("parse identity %q: %w", path, err)
|
||||
}
|
||||
key, err := privateKeyFromIdentity(id)
|
||||
return id, key, false, err
|
||||
}
|
||||
if !os.IsNotExist(err) {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
id, key, err := generateIdentity()
|
||||
if err != nil {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
if err := saveIdentity(path, id); err != nil {
|
||||
return identityFile{}, nil, false, err
|
||||
}
|
||||
return id, key, true, nil
|
||||
}
|
||||
|
||||
func readIdentityImport(path, passphrase string) (identityFile, error) {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
var probe map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &probe); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, encrypted := probe["ciphertext"]; !encrypted {
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(b, &id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
if passphrase == "" {
|
||||
return identityFile{}, errors.New("encrypted browser identity requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
var enc encryptedIdentity
|
||||
if err := json.Unmarshal(b, &enc); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
salt, err := rawURL.DecodeString(enc.Salt)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
iv, err := rawURL.DecodeString(enc.IV)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
ct, err := rawURL.DecodeString(enc.Ciphertext)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
plain, err := gcm.Open(nil, iv, ct, nil)
|
||||
if err != nil {
|
||||
return identityFile{}, errors.New("identity decrypt failed (wrong passphrase or damaged export)")
|
||||
}
|
||||
var id identityFile
|
||||
if err := json.Unmarshal(plain, &id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
if _, err := privateKeyFromIdentity(id); err != nil {
|
||||
return identityFile{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func exportBrowserIdentity(path, passphrase string, id identityFile) error {
|
||||
if passphrase == "" {
|
||||
return errors.New("export requires --passphrase or NEURALHUNT_IDENTITY_PASSPHRASE")
|
||||
}
|
||||
plain, err := json.Marshal(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
salt := make([]byte, 16)
|
||||
iv := make([]byte, 12)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := rand.Read(iv); err != nil {
|
||||
return err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(passphrase), salt, 250000, 32)
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
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)}
|
||||
b, err := json.MarshalIndent(enc, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return os.WriteFile(path, b, 0o600)
|
||||
}
|
||||
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
hLen := sha256.Size
|
||||
blocks := (keyLen + hLen - 1) / hLen
|
||||
out := make([]byte, 0, blocks*hLen)
|
||||
for block := 1; block <= blocks; block++ {
|
||||
mac := hmac.New(sha256.New, password)
|
||||
mac.Write(salt)
|
||||
var n [4]byte
|
||||
binary.BigEndian.PutUint32(n[:], uint32(block))
|
||||
mac.Write(n[:])
|
||||
u := mac.Sum(nil)
|
||||
t := append([]byte(nil), u...)
|
||||
for i := 1; i < iterations; i++ {
|
||||
mac = hmac.New(sha256.New, password)
|
||||
mac.Write(u)
|
||||
u = mac.Sum(nil)
|
||||
for j := range t {
|
||||
t[j] ^= u[j]
|
||||
}
|
||||
}
|
||||
out = append(out, t...)
|
||||
}
|
||||
return out[:keyLen]
|
||||
}
|
||||
55
cmd/client/identity_test.go
Normal file
55
cmd/client/identity_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPBKDF2SHA256Vector(t *testing.T) {
|
||||
got := hex.EncodeToString(pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32))
|
||||
const want = "ae4d0c95af6b46d32d0adff928f06dd02a303f8ef3c251dfd6e2d85a95474c43"
|
||||
if got != want {
|
||||
t.Fatalf("pbkdf2 mismatch: got %s want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserIdentityExportImportRoundTrip(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "identity-export.json")
|
||||
if err := exportBrowserIdentity(path, "correct horse battery staple", id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readIdentityImport(path, "correct horse battery staple")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PublicJWK.X != id.PublicJWK.X || got.PublicJWK.Y != id.PublicJWK.Y || got.PrivateJWK.D != id.PrivateJWK.D {
|
||||
t.Fatal("identity changed during export/import")
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawIdentityImport(t *testing.T) {
|
||||
id, _, err := generateIdentity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "identity.json")
|
||||
if err := saveIdentity(path, id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := readIdentityImport(path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.PrivateJWK.D != id.PrivateJWK.D {
|
||||
t.Fatal("raw identity import mismatch")
|
||||
}
|
||||
}
|
||||
89
cmd/client/main.go
Normal file
89
cmd/client/main.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
base := flag.String("url", envOr("NEURALHUNT_URL", "http://127.0.0.1:8080"), "Neural Hunt server URL")
|
||||
identityPath := flag.String("identity", envOr("NEURALHUNT_IDENTITY", defaultIdentityPath()), "persistent terminal identity file")
|
||||
taskSelector := flag.String("task", "", "initial task: number, ID prefix or display name")
|
||||
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")
|
||||
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)")
|
||||
flag.Parse()
|
||||
|
||||
if strings.TrimSpace(*importPath) != "" {
|
||||
id, err := readIdentityImport(*importPath, *passphrase)
|
||||
if err != nil {
|
||||
log.Fatal("identity import: ", err)
|
||||
}
|
||||
if err := saveIdentity(*identityPath, id); err != nil {
|
||||
log.Fatal("save imported identity: ", err)
|
||||
}
|
||||
fmt.Println("Identity importiert nach", *identityPath)
|
||||
}
|
||||
|
||||
id, key, created, err := loadOrCreateIdentity(*identityPath)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if *exportPath != "" {
|
||||
if err := exportBrowserIdentity(*exportPath, *passphrase, id); err != nil {
|
||||
log.Fatal("identity export: ", err)
|
||||
}
|
||||
fmt.Println("Browser-kompatibler verschlüsselter Export:", *exportPath)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
api := newAPI(*base, id, key)
|
||||
if err := api.login(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
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.")
|
||||
|
||||
a := newApp(api, *identityPath, *passphrase, *maxNodes, *quiet, *nonInteractive)
|
||||
initial, err := selectInitialTask(ctx, a, *taskSelector, !*nonInteractive)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
if err := a.startTask(ctx, initial); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go a.watcher(ctx)
|
||||
|
||||
if *nonInteractive {
|
||||
<-ctx.Done()
|
||||
a.stopSession()
|
||||
return
|
||||
}
|
||||
if err := a.commandLoop(ctx, os.Stdin); err != nil && ctx.Err() == nil {
|
||||
log.Println(err)
|
||||
}
|
||||
a.stopSession()
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if v := strings.TrimSpace(os.Getenv(name)); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
782
cmd/client/ui.go
Normal file
782
cmd/client/ui.go
Normal file
@@ -0,0 +1,782 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type wsEvent struct {
|
||||
Type string `json:"type"`
|
||||
TaskID string `json:"task_id"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type app struct {
|
||||
api *apiClient
|
||||
identityPath string
|
||||
passphrase string
|
||||
maxNodes int
|
||||
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
|
||||
}
|
||||
|
||||
func newApp(api *apiClient, identityPath, passphrase string, maxNodes int, quiet, unattended bool) *app {
|
||||
if maxNodes < 50 {
|
||||
maxNodes = 50
|
||||
}
|
||||
if maxNodes > 10000 {
|
||||
maxNodes = 10000
|
||||
}
|
||||
return &app{api: api, identityPath: identityPath, passphrase: passphrase, maxNodes: maxNodes, quiet: quiet, unattended: unattended, points: make(map[string]point)}
|
||||
}
|
||||
|
||||
func shortID(s string) string {
|
||||
if len(s) <= 12 {
|
||||
return s
|
||||
}
|
||||
return s[:6] + "…" + s[len(s)-4:]
|
||||
}
|
||||
|
||||
func taskName(t taskCard) string {
|
||||
if strings.TrimSpace(t.DisplayName) != "" {
|
||||
return strings.TrimSpace(t.DisplayName)
|
||||
}
|
||||
return "Task " + shortID(t.ID)
|
||||
}
|
||||
|
||||
func dtoName(t taskDTO) string {
|
||||
if strings.TrimSpace(t.DisplayName) != "" {
|
||||
return strings.TrimSpace(t.DisplayName)
|
||||
}
|
||||
return "Task " + shortID(t.ID)
|
||||
}
|
||||
|
||||
func (a *app) printTasks(ctx context.Context) ([]taskCard, error) {
|
||||
ts, err := a.api.tasks(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Println("\nACTIVE TASKS")
|
||||
fmt.Println("────────────────────────────────────────────────────────────────────────────")
|
||||
for i, t := range ts {
|
||||
mark := " "
|
||||
if t.Selected {
|
||||
mark = "*"
|
||||
}
|
||||
state := "RUN"
|
||||
if t.Paused {
|
||||
state = "PAUSED"
|
||||
}
|
||||
rank := "—"
|
||||
if t.OwnRank > 0 {
|
||||
rank = fmt.Sprintf("#%d", t.OwnRank)
|
||||
}
|
||||
fmt.Printf("%s %2d %-24s %3dbit %-6s nodes=%-5d score=%7.3f rank=%s\n", mark, i+1, clip(taskName(t), 24), t.RangeBits, state, t.PointCount, t.OwnScore, rank)
|
||||
if d := strings.TrimSpace(t.Description); d != "" {
|
||||
fmt.Printf(" %s\n", clip(d, 70))
|
||||
}
|
||||
fmt.Printf(" id=%s\n", t.ID)
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
fmt.Println("(keine aktiven Tasks)")
|
||||
}
|
||||
fmt.Println()
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func clip(s string, n int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= n {
|
||||
return string(r)
|
||||
}
|
||||
if n < 2 {
|
||||
return string(r[:n])
|
||||
}
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
func resolveTask(tasks []taskCard, selector string) (taskCard, error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
for _, t := range tasks {
|
||||
if t.Selected {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
if len(tasks) > 0 {
|
||||
return tasks[0], nil
|
||||
}
|
||||
return taskCard{}, fmt.Errorf("no active task")
|
||||
}
|
||||
if n, err := strconv.Atoi(selector); err == nil && n >= 1 && n <= len(tasks) {
|
||||
return tasks[n-1], nil
|
||||
}
|
||||
low := strings.ToLower(selector)
|
||||
var matches []taskCard
|
||||
for _, t := range tasks {
|
||||
if strings.EqualFold(t.ID, selector) || strings.HasPrefix(strings.ToLower(t.ID), low) || strings.Contains(strings.ToLower(t.DisplayName), low) {
|
||||
matches = append(matches, t)
|
||||
}
|
||||
}
|
||||
if len(matches) == 1 {
|
||||
return matches[0], nil
|
||||
}
|
||||
if len(matches) > 1 {
|
||||
return taskCard{}, fmt.Errorf("task selector %q is ambiguous", selector)
|
||||
}
|
||||
return taskCard{}, fmt.Errorf("task %q not found", selector)
|
||||
}
|
||||
|
||||
func (a *app) stopSession() {
|
||||
a.mu.Lock()
|
||||
cancel := a.sessionCancel
|
||||
conn := a.ws
|
||||
a.sessionCancel = nil
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
a.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if conn != nil {
|
||||
_ = conn.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "switch task"), time.Now().Add(200*time.Millisecond))
|
||||
_ = conn.Close()
|
||||
}
|
||||
// Give the server's single-identity presence cleanup a short chance to run.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
}
|
||||
|
||||
func (a *app) startTask(ctx context.Context, taskID string) error {
|
||||
a.mu.Lock()
|
||||
if a.switching {
|
||||
a.mu.Unlock()
|
||||
return errSwitching
|
||||
}
|
||||
a.switching = true
|
||||
a.mu.Unlock()
|
||||
defer func() {
|
||||
a.mu.Lock()
|
||||
a.switching = false
|
||||
a.mu.Unlock()
|
||||
}()
|
||||
|
||||
a.stopSession()
|
||||
var (
|
||||
t taskDTO
|
||||
err error
|
||||
)
|
||||
if taskID != "" {
|
||||
t, err = a.api.selectTask(ctx, taskID)
|
||||
} else {
|
||||
t, err = a.api.currentTask(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ws, err := a.api.dialWS(ctx, a.maxNodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
|
||||
m, _ := a.api.me(ctx)
|
||||
|
||||
sctx, cancel := context.WithCancel(context.Background())
|
||||
a.mu.Lock()
|
||||
a.task, a.seq, a.ws, a.sessionCancel, a.me = t, t.NextSeq, ws, cancel, m
|
||||
a.wsConnected = true
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
a.mu.Unlock()
|
||||
|
||||
go a.wsLoop(sctx, ws, t.ID)
|
||||
go a.guessLoop(sctx, t.ID)
|
||||
if !a.quiet {
|
||||
fmt.Printf("\n▶ %s [%s] %d bit\n", dtoName(t), shortID(t.ID), t.RangeBits)
|
||||
if t.Paused {
|
||||
fmt.Println(" Task ist pausiert; automatische Tipps warten.")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) wsLoop(ctx context.Context, initial *websocket.Conn, taskID string) {
|
||||
conn := initial
|
||||
backoff := 500 * time.Millisecond
|
||||
for {
|
||||
completed, err := a.readWSOnce(ctx, conn, taskID)
|
||||
a.mu.Lock()
|
||||
if a.ws == conn {
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if completed || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil && !a.quiet {
|
||||
fmt.Printf("\n[ws] Verbindung beendet: %v; Reconnect folgt automatisch\n", err)
|
||||
}
|
||||
|
||||
for {
|
||||
timer := time.NewTimer(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
next, dialErr := a.api.dialWS(ctx, a.maxNodes)
|
||||
if dialErr != nil {
|
||||
if !a.quiet {
|
||||
fmt.Printf("[ws] Reconnect fehlgeschlagen: %v\n", dialErr)
|
||||
}
|
||||
backoff = time.Duration(minInt64(int64(8*time.Second), int64(float64(backoff)*1.7)))
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.task.ID != taskID || ctx.Err() != nil {
|
||||
a.mu.Unlock()
|
||||
_ = next.Close()
|
||||
return
|
||||
}
|
||||
a.ws = next
|
||||
a.wsConnected = true
|
||||
a.mu.Unlock()
|
||||
conn = next
|
||||
backoff = 500 * time.Millisecond
|
||||
go a.refreshTask(taskID)
|
||||
if !a.quiet {
|
||||
fmt.Println("[ws] wieder verbunden")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minInt64(a, b int64) int64 {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (a *app) forceWSReconnect() {
|
||||
a.mu.Lock()
|
||||
conn := a.ws
|
||||
a.ws = nil
|
||||
a.wsConnected = false
|
||||
a.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) readWSOnce(ctx context.Context, conn *websocket.Conn, taskID string) (bool, error) {
|
||||
for {
|
||||
_, b, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return false, ctx.Err()
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
var ev wsEvent
|
||||
if json.Unmarshal(b, &ev) != nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Type {
|
||||
case "snapshot":
|
||||
var ps []point
|
||||
if json.Unmarshal(ev.Data, &ps) == nil {
|
||||
a.replacePoints(ps)
|
||||
}
|
||||
case "point":
|
||||
var p point
|
||||
if json.Unmarshal(ev.Data, &p) == nil {
|
||||
a.upsertPoint(p)
|
||||
}
|
||||
case "points":
|
||||
var ps []point
|
||||
if json.Unmarshal(ev.Data, &ps) == nil {
|
||||
for _, p := range ps {
|
||||
a.upsertPoint(p)
|
||||
}
|
||||
}
|
||||
case "task_changed":
|
||||
if ev.TaskID == "" || ev.TaskID == taskID {
|
||||
go a.refreshTask(taskID)
|
||||
}
|
||||
case "task_completed":
|
||||
if ev.TaskID == taskID {
|
||||
if !a.quiet {
|
||||
fmt.Println("\n✓ Task abgeschlossen. Wechsle auf den Folge-Task …")
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(450 * time.Millisecond)
|
||||
if err := a.startTask(context.Background(), ""); err != nil && !a.quiet {
|
||||
fmt.Printf("[task] Folge-Task noch nicht bereit: %v\n", err)
|
||||
}
|
||||
}()
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) replacePoints(ps []point) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) upsertPoint(p point) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.points == nil {
|
||||
a.points = make(map[string]point)
|
||||
}
|
||||
a.points[p.ClientID] = p
|
||||
if p.ClientID == a.api.cid {
|
||||
a.me.Score, a.me.Rank = p.Score, p.Rank
|
||||
}
|
||||
if len(a.points) > a.maxNodes*3 {
|
||||
a.trimPointsLocked()
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) trimPointsLocked() {
|
||||
ps := make([]point, 0, len(a.points))
|
||||
for _, p := range a.points {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
sort.Slice(ps, func(i, j int) bool { return ps[i].Score > ps[j].Score })
|
||||
keep := a.maxNodes * 3
|
||||
if keep > len(ps) {
|
||||
keep = len(ps)
|
||||
}
|
||||
next := make(map[string]point, keep+1)
|
||||
for _, p := range ps[:keep] {
|
||||
next[p.ClientID] = p
|
||||
}
|
||||
if own, ok := a.points[a.api.cid]; ok {
|
||||
next[own.ClientID] = own
|
||||
}
|
||||
a.points = next
|
||||
}
|
||||
|
||||
func (a *app) refreshTask(taskID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
t, err := a.api.currentTask(ctx)
|
||||
if err != nil || t.ID != taskID {
|
||||
return
|
||||
}
|
||||
ps, _ := a.api.points(ctx, t.ID, a.maxNodes*3)
|
||||
m, _ := a.api.me(ctx)
|
||||
a.mu.Lock()
|
||||
oldSeed := a.task.PublicSeed
|
||||
a.task = t
|
||||
// Preserve the hot sequence for same-seed changes (bits/intervals/config).
|
||||
// A reroll changes public_seed and intentionally resets to the server state.
|
||||
if oldSeed != t.PublicSeed {
|
||||
a.seq = t.NextSeq
|
||||
} else if t.NextSeq > a.seq {
|
||||
a.seq = t.NextSeq
|
||||
}
|
||||
if m.ClientID != "" {
|
||||
a.me = m
|
||||
}
|
||||
if ps != nil {
|
||||
a.points = make(map[string]point, len(ps))
|
||||
for _, p := range ps {
|
||||
a.points[p.ClientID] = p
|
||||
}
|
||||
}
|
||||
a.mu.Unlock()
|
||||
if !a.quiet {
|
||||
fmt.Printf("\n[task] Konfiguration aktualisiert: %d bit, Intervall %ds, paused=%v\n", t.RangeBits, t.ClientSubmitIntervalSec, t.Paused)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) guessLoop(ctx context.Context, taskID string) {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
t := a.task
|
||||
a.mu.RUnlock()
|
||||
if t.ID != taskID {
|
||||
return
|
||||
}
|
||||
sec := t.ClientSubmitIntervalSec
|
||||
if sec <= 0 {
|
||||
sec = 11
|
||||
}
|
||||
timer := time.NewTimer(time.Duration(sec) * time.Second)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
|
||||
// Re-read state after sleeping. Admin actions may have changed seed/bits,
|
||||
// sequence or pause state while this iteration was waiting.
|
||||
a.mu.RLock()
|
||||
t = a.task
|
||||
seq := a.seq
|
||||
connected := a.wsConnected
|
||||
a.mu.RUnlock()
|
||||
if t.ID != taskID {
|
||||
return
|
||||
}
|
||||
if t.Paused || !connected {
|
||||
continue
|
||||
}
|
||||
correct, err := a.api.guess(ctx, t, seq)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if ae := new(apiError); errorsAs(err, &ae) && (ae.Status == 409 || ae.Status == 423 || ae.Status == 429) {
|
||||
a.refreshTask(taskID)
|
||||
if ae.Status == 409 && ae.Code() == "presence_required" {
|
||||
a.forceWSReconnect()
|
||||
}
|
||||
} else if !a.quiet {
|
||||
fmt.Printf("\n[guess] %v\n", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
a.mu.Lock()
|
||||
if a.task.ID == taskID && a.seq == seq {
|
||||
a.seq++
|
||||
}
|
||||
a.lastGuessAt = time.Now()
|
||||
a.lastGuessOK = true
|
||||
a.mu.Unlock()
|
||||
if correct {
|
||||
fmt.Printf("\n★ GEWONNEN: %s ★\n", dtoName(t))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errorsAs is a tiny wrapper to keep the UI file's imports readable.
|
||||
func errorsAs(err error, target any) bool { return errors.As(err, target) }
|
||||
|
||||
func (a *app) printStatus(ctx context.Context) {
|
||||
m, err := a.api.me(ctx)
|
||||
if err == nil {
|
||||
a.mu.Lock()
|
||||
a.me = m
|
||||
a.mu.Unlock()
|
||||
}
|
||||
a.mu.RLock()
|
||||
t, seq, points, me, last := a.task, a.seq, len(a.points), a.me, a.lastGuessAt
|
||||
a.mu.RUnlock()
|
||||
fmt.Println("\nSTATUS")
|
||||
fmt.Println("────────────────────────────────────────────────────────")
|
||||
fmt.Printf("Identity : %s\n", a.api.cid)
|
||||
fmt.Printf("Task : %s (%s)\n", dtoName(t), t.ID)
|
||||
fmt.Printf("Raum : %d bit Revision %d Paused %v\n", t.RangeBits, t.Revision, t.Paused)
|
||||
fmt.Printf("Score : %.4f Rank #%d Wins %d\n", me.Score, me.Rank, me.Wins)
|
||||
fmt.Printf("Sequence : %d Nodes im Working Set %d\n", seq, points)
|
||||
if !last.IsZero() {
|
||||
fmt.Printf("Letzter Tipp: %s\n", last.Format("15:04:05"))
|
||||
}
|
||||
if len(me.Unlocks) > 0 {
|
||||
fmt.Printf("Unlocks : %s\n", strings.Join(me.Unlocks, ", "))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func (a *app) printMap(limit int) {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
a.mu.RLock()
|
||||
ps := make([]point, 0, len(a.points))
|
||||
for _, p := range a.points {
|
||||
ps = append(ps, p)
|
||||
}
|
||||
cid := a.api.cid
|
||||
t := a.task
|
||||
a.mu.RUnlock()
|
||||
sort.Slice(ps, func(i, j int) bool {
|
||||
if ps[i].Score == ps[j].Score {
|
||||
return ps[i].ClientID < ps[j].ClientID
|
||||
}
|
||||
return ps[i].Score > ps[j].Score
|
||||
})
|
||||
if len(ps) > limit {
|
||||
ps = ps[:limit]
|
||||
}
|
||||
fmt.Printf("\nTARGET FIELD — %s\n", dtoName(t))
|
||||
fmt.Println("TASK ◉ ← höherer Score bedeutet näher am Zentrum")
|
||||
fmt.Println("────────────────────────────────────────────────────────────────")
|
||||
bands := []struct {
|
||||
name string
|
||||
min float64
|
||||
max float64
|
||||
}{{"99+ INNER CORE", 99, 101}, {"95–99 NEAR", 95, 99}, {"90–95 CLOSE", 90, 95}, {"75–90 MID", 75, 90}, {"50–75 FAR", 50, 75}, {"0–50 OUTER", 0, 50}}
|
||||
for _, b := range bands {
|
||||
var names []string
|
||||
for _, p := range ps {
|
||||
if p.Score >= b.min && p.Score < b.max {
|
||||
mark := ""
|
||||
if p.ClientID == cid {
|
||||
mark = "*"
|
||||
}
|
||||
names = append(names, fmt.Sprintf("%s%s %.3f", mark, shortID(p.ClientID), p.Score))
|
||||
}
|
||||
}
|
||||
fmt.Printf("%-14s │ %s\n", b.name, strings.Join(names, " "))
|
||||
}
|
||||
fmt.Println("* = deine Identität")
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func (a *app) printLeaderboard(ctx context.Context, limit int) error {
|
||||
ls, err := a.api.leaderboard(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if limit <= 0 || limit > len(ls) {
|
||||
limit = minInt(25, len(ls))
|
||||
}
|
||||
fmt.Println("\nREALTIME LEADERBOARD")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
fmt.Printf("%-5s %-15s %6s %10s %10s %8s %5s\n", "RANK", "CLIENT", "WINS", "LIVE", "BEST", "GUESSES", "NFT")
|
||||
for i, l := range ls[:limit] {
|
||||
conn := " "
|
||||
if l.Connected {
|
||||
conn = "●"
|
||||
}
|
||||
self := ""
|
||||
if l.ClientID == a.api.cid {
|
||||
self = "*"
|
||||
}
|
||||
fmt.Printf("#%-4d %-15s %6d %10.4f %10.4f %8d %5d\n", i+1, conn+self+shortID(l.ClientID), l.Wins, l.LiveScore, l.BestScore, l.GuessCount, l.NFTCount)
|
||||
}
|
||||
fmt.Println("● online * du")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) printNFTs(ctx context.Context, limit int) error {
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
items, err := a.api.artifacts(ctx, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("\nNFT / WINNER ARTIFACTS (öffentliche Wasserzeichen-Previews)")
|
||||
fmt.Println("──────────────────────────────────────────────────────────────────────────")
|
||||
for i, n := range items {
|
||||
fmt.Printf("%2d task=%-16s winner=%-14s %3dbit %s\n", i+1, shortID(n.TaskID), shortID(n.WinnerClientID), n.RangeBits, n.CompletedAt.Local().Format("2006-01-02 15:04"))
|
||||
fmt.Printf(" %s%s\n", a.api.base, n.PreviewURI)
|
||||
}
|
||||
if len(items) == 0 {
|
||||
fmt.Println("(noch keine fertigen Artefakte)")
|
||||
}
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *app) watcher(ctx context.Context) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
a.mu.RLock()
|
||||
watch := a.leaderWatch
|
||||
task := a.task
|
||||
seq := a.seq
|
||||
me := a.me
|
||||
a.mu.RUnlock()
|
||||
if watch {
|
||||
_ = a.printLeaderboard(ctx, 15)
|
||||
} else if a.unattended && !a.quiet {
|
||||
fmt.Printf("%s task=%s score=%.4f rank=%d seq=%d paused=%v\n", time.Now().Format("15:04:05"), shortID(task.ID), me.Score, me.Rank, seq, task.Paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
for {
|
||||
fmt.Print("neuralhunt> ")
|
||||
if !s.Scan() {
|
||||
return s.Err()
|
||||
}
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
parts := strings.Fields(line)
|
||||
cmd := strings.ToLower(parts[0])
|
||||
switch cmd {
|
||||
case "help", "?":
|
||||
fmt.Println(" tasks aktive Tasks anzeigen")
|
||||
fmt.Println(" use <nr|id|name> Task wechseln")
|
||||
fmt.Println(" status Score, Rank, Sequence, Task")
|
||||
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(" nft get <task-id> <datei> Wasserzeichen-Preview speichern")
|
||||
fmt.Println(" identity Client-ID und Identity-Datei")
|
||||
fmt.Println(" identity export <datei> browser-kompatiblen verschlüsselten Export schreiben")
|
||||
fmt.Println(" quit beenden")
|
||||
case "tasks":
|
||||
if _, err := a.printTasks(ctx); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "use":
|
||||
if len(parts) < 2 {
|
||||
fmt.Println("use benötigt Nummer, ID-Präfix oder Namen")
|
||||
continue
|
||||
}
|
||||
ts, err := a.api.tasks(ctx)
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
continue
|
||||
}
|
||||
t, err := resolveTask(ts, strings.Join(parts[1:], " "))
|
||||
if err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
continue
|
||||
}
|
||||
if err := a.startTask(ctx, t.ID); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "status":
|
||||
a.printStatus(ctx)
|
||||
case "map":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
a.printMap(n)
|
||||
case "leaderboard", "lb":
|
||||
if len(parts) > 1 && strings.EqualFold(parts[1], "watch") {
|
||||
a.mu.Lock()
|
||||
a.leaderWatch = true
|
||||
a.mu.Unlock()
|
||||
fmt.Println("Leaderboard-Watch aktiviert.")
|
||||
continue
|
||||
}
|
||||
if len(parts) > 1 && (strings.EqualFold(parts[1], "stop") || strings.EqualFold(parts[1], "off")) {
|
||||
a.mu.Lock()
|
||||
a.leaderWatch = false
|
||||
a.mu.Unlock()
|
||||
fmt.Println("Leaderboard-Watch deaktiviert.")
|
||||
continue
|
||||
}
|
||||
n := 25
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printLeaderboard(ctx, n); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
}
|
||||
case "nfts":
|
||||
n := 30
|
||||
if len(parts) > 1 {
|
||||
n, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
if err := a.printNFTs(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 {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Wasserzeichen-Preview gespeichert:", parts[3])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("nft get <task-id> <datei>")
|
||||
}
|
||||
case "identity", "id":
|
||||
if len(parts) >= 2 && strings.EqualFold(parts[1], "export") {
|
||||
if len(parts) != 3 {
|
||||
fmt.Println("identity export <datei>")
|
||||
continue
|
||||
}
|
||||
if err := exportBrowserIdentity(parts[2], a.passphrase, a.api.id); err != nil {
|
||||
fmt.Println("Fehler:", err)
|
||||
} else {
|
||||
fmt.Println("Verschlüsselter Browser-Export geschrieben:", parts[2])
|
||||
}
|
||||
} else {
|
||||
fmt.Println("Client-ID:", a.api.cid)
|
||||
fmt.Println("Identity :", a.identityPath)
|
||||
}
|
||||
case "quit", "exit", "q":
|
||||
return nil
|
||||
default:
|
||||
fmt.Println("Unbekannter Befehl. 'help' zeigt die Befehle.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func selectInitialTask(ctx context.Context, a *app, selector string, interactive bool) (string, error) {
|
||||
ts, err := a.printTasks(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
return "", fmt.Errorf("server has no active tasks")
|
||||
}
|
||||
if selector != "" || !interactive {
|
||||
t, err := resolveTask(ts, selector)
|
||||
return t.ID, err
|
||||
}
|
||||
fmt.Print("Task auswählen [Nummer/ID/Name, Enter = markierter Task]: ")
|
||||
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
||||
t, err := resolveTask(ts, strings.TrimSpace(line))
|
||||
return t.ID, err
|
||||
}
|
||||
239
cmd/loadtest/main.go
Normal file
239
cmd/loadtest/main.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"neuralhunt/internal/auth"
|
||||
"neuralhunt/internal/core"
|
||||
)
|
||||
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
type apiClient struct {
|
||||
base string
|
||||
hc *http.Client
|
||||
token, cid string
|
||||
key *ecdsa.PrivateKey
|
||||
}
|
||||
type taskDTO struct {
|
||||
ID string `json:"id"`
|
||||
PublicSeed string `json:"public_seed"`
|
||||
RangeBits int `json:"range_bits"`
|
||||
NextSeq int64 `json:"next_seq"`
|
||||
SubmitSec int `json:"client_submit_interval_sec"`
|
||||
Paused bool `json:"paused"`
|
||||
}
|
||||
|
||||
func pad32(x *big.Int) []byte {
|
||||
b := x.Bytes()
|
||||
out := make([]byte, 32)
|
||||
copy(out[32-len(b):], b)
|
||||
return out
|
||||
}
|
||||
func signRaw(k *ecdsa.PrivateKey, msg string) (string, error) {
|
||||
h := sha256.Sum256([]byte(msg))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, k, h[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
raw := append(pad32(r), pad32(s)...)
|
||||
return b64.EncodeToString(raw), nil
|
||||
}
|
||||
func (c *apiClient) do(method, path string, body any, out any) error {
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, _ := json.Marshal(body)
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, _ := http.NewRequest(method, c.base+path, rd)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
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()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("%s: %s", resp.Status, string(b))
|
||||
}
|
||||
if out != nil {
|
||||
return json.Unmarshal(b, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (c *apiClient) authn() error {
|
||||
k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.key = k
|
||||
j := auth.PublicJWK{Kty: "EC", Crv: "P-256", X: b64.EncodeToString(pad32(k.X)), Y: b64.EncodeToString(pad32(k.Y)), Ext: true}
|
||||
var ch struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Challenge string `json:"challenge"`
|
||||
}
|
||||
if err = c.do("POST", "/api/auth/challenge", map[string]any{"public_jwk": j}, &ch); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cid = ch.ClientID
|
||||
sig, _ := signRaw(k, "login|"+ch.Challenge+"|"+c.cid)
|
||||
var lg struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err = c.do("POST", "/api/auth/login", map[string]any{"public_jwk": j, "challenge": ch.Challenge, "signature": sig}, &lg); err != nil {
|
||||
return err
|
||||
}
|
||||
c.token = lg.Token
|
||||
return nil
|
||||
}
|
||||
func (c *apiClient) current() (taskDTO, error) {
|
||||
var t taskDTO
|
||||
err := c.do("GET", "/api/tasks/current", nil, &t)
|
||||
return t, err
|
||||
}
|
||||
func (c *apiClient) ws(ctx context.Context, maxNodes int) (*websocket.Conn, error) {
|
||||
u, err := url.Parse(c.base)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scheme := "ws"
|
||||
if u.Scheme == "https" {
|
||||
scheme = "wss"
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("token", c.token)
|
||||
q.Set("max_nodes", fmt.Sprint(maxNodes))
|
||||
wu := scheme + "://" + u.Host + "/api/ws?" + q.Encode()
|
||||
conn, _, err := websocket.DefaultDialer.DialContext(ctx, wu, nil)
|
||||
return conn, err
|
||||
}
|
||||
|
||||
func main() {
|
||||
base := flag.String("url", "http://127.0.0.1:8080", "server URL")
|
||||
clients := flag.Int("clients", 1000, "virtual clients")
|
||||
ramp := flag.Duration("ramp", 30*time.Second, "connection ramp")
|
||||
duration := flag.Duration("duration", 2*time.Minute, "test duration after ramp")
|
||||
nodes := flag.Int("max-nodes", 250, "snapshot budget per client")
|
||||
flag.Parse()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var connected, guesses, errs atomic.Uint64
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
step := time.Duration(0)
|
||||
if *clients > 0 {
|
||||
step = *ramp / time.Duration(*clients)
|
||||
}
|
||||
for i := 0; i < *clients; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
if step > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(step * time.Duration(i)):
|
||||
}
|
||||
}
|
||||
c := &apiClient{base: strings.TrimRight(*base, "/"), hc: &http.Client{Timeout: 10 * time.Second}}
|
||||
if err := c.authn(); err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
t, err := c.current()
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
ws, err := c.ws(ctx, *nodes)
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
return
|
||||
}
|
||||
defer ws.Close()
|
||||
connected.Add(1)
|
||||
go func() {
|
||||
for {
|
||||
if _, _, e := ws.ReadMessage(); e != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
interval := time.Duration(t.SubmitSec) * time.Second
|
||||
if interval <= 0 {
|
||||
interval = 11 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
seq := t.NextSeq
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if t.Paused {
|
||||
continue
|
||||
}
|
||||
guess := core.ExpectedGuess(t.ID, t.PublicSeed, c.cid, seq, t.RangeBits)
|
||||
sig, _ := signRaw(c.key, fmt.Sprintf("guess|%s|%d|%s", t.ID, seq, guess))
|
||||
var ok bool
|
||||
err := c.do("POST", "/api/tasks/"+t.ID+"/guess", map[string]any{"seq": seq, "guess": guess, "signature": sig}, &ok)
|
||||
if err != nil {
|
||||
errs.Add(1)
|
||||
nt, e := c.current()
|
||||
if e == nil {
|
||||
t = nt
|
||||
seq = t.NextSeq
|
||||
}
|
||||
continue
|
||||
}
|
||||
guesses.Add(1)
|
||||
seq++
|
||||
if ok {
|
||||
nt, e := c.current()
|
||||
if e == nil {
|
||||
t = nt
|
||||
seq = t.NextSeq
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
tick := time.NewTicker(5 * time.Second)
|
||||
defer tick.Stop()
|
||||
end := time.After(*ramp + *duration)
|
||||
for {
|
||||
select {
|
||||
case <-end:
|
||||
cancel()
|
||||
wg.Wait()
|
||||
fmt.Printf("done connected=%d accepted_guesses=%d errors=%d elapsed=%s\n", connected.Load(), guesses.Load(), errs.Load(), time.Since(start).Round(time.Second))
|
||||
return
|
||||
case <-tick.C:
|
||||
log.Printf("connected=%d guesses=%d errors=%d", connected.Load(), guesses.Load(), errs.Load())
|
||||
}
|
||||
}
|
||||
}
|
||||
109
cmd/server/main.go
Normal file
109
cmd/server/main.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"neuralhunt/internal/artifact"
|
||||
"neuralhunt/internal/auth"
|
||||
"neuralhunt/internal/data"
|
||||
rtx "neuralhunt/internal/runtime"
|
||||
"neuralhunt/internal/server"
|
||||
"neuralhunt/internal/settings"
|
||||
wsx "neuralhunt/internal/ws"
|
||||
)
|
||||
|
||||
func env(k, d string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
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 = strings.TrimSpace(k)
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
if _, exists := os.LookupEnv(k); exists {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
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 main() {
|
||||
loadDotEnv(".env")
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||
defer cancel()
|
||||
|
||||
db, err := data.OpenSQLite(ctx, env("SQLITE_PATH", "./data/neuralhunt.db"))
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
sm, err := settings.New(ctx, db)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go sm.Run(ctx)
|
||||
|
||||
store := data.New(db)
|
||||
a := auth.New(db, env("JWT_SECRET", "dev-secret-change-me"))
|
||||
hub := wsx.New()
|
||||
runtimeState := rtx.New()
|
||||
go hub.Run(ctx)
|
||||
|
||||
artifactDir := env("ARTIFACT_DIR", "./data/artifacts")
|
||||
aw, err := artifact.New(db, artifactDir, sm)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
go aw.Run(ctx)
|
||||
|
||||
srv := server.New(store, a, sm, hub, runtimeState, artifactDir, aw)
|
||||
go srv.Scheduler(ctx)
|
||||
|
||||
httpSrv := &http.Server{Addr: env("HTTP_ADDR", ":8080"), Handler: srv.Routes(), ReadHeaderTimeout: 5 * time.Second}
|
||||
go func() {
|
||||
log.Printf("listening on %s", httpSrv.Addr)
|
||||
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
shutdown, done := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer done()
|
||||
_ = httpSrv.Shutdown(shutdown)
|
||||
}
|
||||
Reference in New Issue
Block a user