@@ -31,6 +31,33 @@ const selfSignedAgent = new https.Agent({ rejectUnauthorized: false });
3131// Mark node offline after this many consecutive health check failures (1 check/min)
3232const HEALTH_FAILURE_THRESHOLD = 3 ;
3333
34+ // Fields whose change requires regenerating the runtime config on the node.
35+ // Anything not listed here (name, groups, flag, rankingCoefficient, ssh.*, ...)
36+ // is treated as cosmetic and does not trigger an auto-push.
37+ // Dotted keys (e.g. "xray.realityPrivateKey") are matched via their root.
38+ const CONFIG_AFFECTING_FIELDS = new Set ( [
39+ // Shared
40+ 'domain' , 'sni' , 'port' , 'portRange' , 'statsPort' , 'statsSecret' ,
41+ 'useCustomConfig' , 'customConfig' ,
42+ // Hysteria
43+ 'obfs' , 'hopInterval' , 'acme' , 'masquerade' , 'bandwidth' ,
44+ 'ignoreClientBandwidth' , 'speedTest' , 'disableUDP' , 'udpIdleTimeout' ,
45+ 'sniff' , 'quic' , 'resolver' , 'acl' , 'aclRules' , 'outbounds' , 'useTlsFiles' ,
46+ // Xray (any xray.* sub-path triggers regeneration)
47+ 'xray' ,
48+ ] ) ;
49+
50+ function hasConfigRelevantUpdates ( updates ) {
51+ // null/undefined means "unknown" — err on the side of pushing.
52+ if ( ! updates ) return true ;
53+ const keys = Object . keys ( updates ) ;
54+ if ( keys . length === 0 ) return false ;
55+ return keys . some ( k => {
56+ const root = k . split ( '.' ) [ 0 ] ;
57+ return CONFIG_AFFECTING_FIELDS . has ( k ) || CONFIG_AFFECTING_FIELDS . has ( root ) ;
58+ } ) ;
59+ }
60+
3461class SyncService {
3562 constructor ( ) {
3663 this . isSyncing = false ;
@@ -509,6 +536,40 @@ class SyncService {
509536 return this . _updateHysteriaNodeConfig ( node ) ;
510537 }
511538
539+ /**
540+ * Fire-and-forget config push after a settings save.
541+ *
542+ * Non-blocking: defers to the next tick via setImmediate so the HTTP
543+ * response is flushed before any SSH/Agent round-trip starts.
544+ *
545+ * Silently skipped when:
546+ * - `updates` contains only cosmetic fields (see CONFIG_AFFECTING_FIELDS);
547+ * - node is inactive or acts as a cascade bridge (cascade deploy owns those);
548+ * - node has neither SSH credentials nor an agent token (never set up yet).
549+ *
550+ * @param {string } nodeId - Node _id
551+ * @param {Object } [updates] - $set payload applied to the node. When omitted,
552+ * the push is assumed relevant and runs unconditionally.
553+ */
554+ schedulePush ( nodeId , updates = null ) {
555+ if ( ! hasConfigRelevantUpdates ( updates ) ) return ;
556+ setImmediate ( async ( ) => {
557+ try {
558+ const node = await HyNode . findById ( nodeId ) ;
559+ if ( ! node || ! node . active ) return ;
560+ if ( node . cascadeRole === 'bridge' ) return ;
561+
562+ const hasSsh = ! ! ( node . ssh ?. password || node . ssh ?. privateKey ) ;
563+ const hasAgent = ! ! ( node . xray && node . xray . agentToken ) ;
564+ if ( ! hasSsh && ! hasAgent ) return ;
565+
566+ await this . updateNodeConfig ( node ) ;
567+ } catch ( error ) {
568+ logger . warn ( `[AutoPush] node ${ nodeId } : ${ error . message } ` ) ;
569+ }
570+ } ) ;
571+ }
572+
512573 /**
513574 * Update Hysteria config on a specific node
514575 */
0 commit comments