@@ -2,6 +2,7 @@ package main
22
33import (
44 "encoding/json"
5+ "flag"
56 "fmt"
67 "log"
78 "net"
@@ -15,19 +16,34 @@ import (
1516 "github.com/hashicorp/memberlist"
1617)
1718
19+ type cliOptions struct {
20+ List bool
21+ }
22+
1823const (
19- envDB = "PSSTD_DB"
20- envHTTP = "PSSTD_HTTP"
21- envGossip = "PSSTD_GOSSIP"
22- envSeeds = "PSSTD_SEEDS"
23- envHTTPAd = "PSSTD_ADVERTISE_HTTP"
24- envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
25- gossipPort = 7946
26- httpPort = 8080
24+ envDB = "PSSTD_DB"
25+ envHTTP = "PSSTD_HTTP"
26+ envGossip = "PSSTD_GOSSIP"
27+ envSeeds = "PSSTD_SEEDS"
28+ envHTTPAd = "PSSTD_ADVERTISE_HTTP"
29+ envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
30+ envNodeName = "PSSTD_NODE_NAME"
31+ envNodeTTL = "PSSTD_NODE_TTL"
32+ gossipPort = 7946
33+ httpPort = 8080
2734)
2835
2936func main () {
37+ opts := parseCLI (os .Args [1 :])
3038 hostname , _ := os .Hostname ()
39+ nodeName , err := nodeNameFromEnv (hostname )
40+ if err != nil {
41+ log .Fatalf ("node name: %v" , err )
42+ }
43+ nodeTTL , err := nodeTTLFromEnv ()
44+ if err != nil {
45+ log .Fatalf ("node ttl: %v" , err )
46+ }
3147
3248 dbPath := envOr (envDB , "./data" )
3349 httpAddr := envOr (envHTTP , fmt .Sprintf (":%d" , httpPort ))
@@ -47,7 +63,7 @@ func main() {
4763 if err != nil {
4864 if pebbleLockHeld (err ) {
4965 log .Printf ("psstd already appears to own %s; starting terminal mirror instead" , dbPath )
50- runTerminalMirror (hostname , gossipAddr , seeds )
66+ runTerminalMirror (nodeName , gossipAddr , seeds , opts . List )
5167 return
5268 }
5369 log .Fatalf ("pebble open: %v" , err )
@@ -65,7 +81,7 @@ func main() {
6581 delegate := newKVDelegate (db , appVersion )
6682
6783 cfg := memberlist .DefaultLANConfig ()
68- cfg .Name = hostname
84+ cfg .Name = nodeName
6985 cfg .BindAddr , cfg .BindPort = splitHostPort (gossipAddr )
7086 cfg .Delegate = delegate
7187 cfg .Events = newEventDelegate (db , appVersion )
@@ -79,7 +95,7 @@ func main() {
7995 }
8096 db = nil
8197 log .Printf ("psstd already appears to be listening on %s; starting terminal mirror instead" , gossipAddr )
82- runTerminalMirror (hostname , gossipAddr , seeds )
98+ runTerminalMirror (nodeName , gossipAddr , seeds , opts . List )
8399 return
84100 }
85101 log .Fatalf ("memberlist create: %v" , err )
@@ -88,39 +104,87 @@ func main() {
88104
89105 // ── Discovery ────────────────────────────────────────────────────────────
90106 // 1. Register ourselves via mDNS so peers can find us on LAN
91- stopMDNS := registerMDNS (hostname , cfg .BindPort )
107+ stopMDNS := registerMDNS (nodeName , cfg .BindPort )
92108 defer stopMDNS ()
93109
94110 // 2. Scan for existing peers (mDNS + any explicit seeds)
95111 discovered := discoverPeers ()
96112 allSeeds := append (seeds , discovered ... )
113+ joinedPeers := 0
114+ joinErr := error (nil )
97115 if len (allSeeds ) > 0 {
98- if n , err := list .Join (allSeeds ); err != nil {
99- log .Printf ("join warning (joined %d): %v" , n , err )
100- } else {
101- log .Printf ("joined cluster, %d peer(s)" , n )
102- }
103- } else {
104- log .Println ("no peers found — running solo, will be discovered by others" )
116+ joinedPeers , joinErr = list .Join (allSeeds )
105117 }
118+ logStartupConfig (startupConfig {
119+ NodeName : nodeName ,
120+ DBPath : dbPath ,
121+ HTTPAddr : httpAddr ,
122+ WebURL : webURL ,
123+ GossipAddr : gossipAddr ,
124+ WebEnabled : webEnabled ,
125+ Version : appVersion ,
126+ NodeTTL : nodeTTL ,
127+ SeedCount : len (seeds ),
128+ MDNSCount : len (discovered ),
129+ JoinedPeers : joinedPeers ,
130+ JoinErr : joinErr ,
131+ })
106132
107133 // ── Stats heartbeat ─────────────────────────────────────────────────────
108- go statsLoop (hostname , webURL , appVersion , db , delegate )
134+ go statsLoop (nodeName , webURL , appVersion , nodeTTL , db , delegate )
109135
110136 // ── HTTP ─────────────────────────────────────────────────────────────────
111137 if webEnabled {
112138 mux := http .NewServeMux ()
113- mux .HandleFunc ("/" , makeHandler (db , hostname ))
114- log .Printf ("psstd version=%s node=%s http=%s advertise=%s gossip=%s web=true" , appVersion , hostname , httpAddr , webURL , gossipAddr )
139+ mux .HandleFunc ("/" , makeHandler (db , nodeName ))
115140 if err := http .ListenAndServe (httpAddr , mux ); err != nil {
116141 log .Fatalf ("http: %v" , err )
117142 }
118143 } else {
119- log .Printf ("psstd version=%s node=%s gossip=%s web=false" , appVersion , hostname , gossipAddr )
120144 select {} // block forever
121145 }
122146}
123147
148+ func parseCLI (args []string ) cliOptions {
149+ fs := flag .NewFlagSet ("psstd" , flag .ExitOnError )
150+ fs .SetOutput (os .Stderr )
151+ var opts cliOptions
152+ fs .BoolVar (& opts .List , "l" , false , "render terminal mirror as a vertical node list" )
153+ fs .BoolVar (& opts .List , "list" , false , "render terminal mirror as a vertical node list" )
154+ _ = fs .Parse (args )
155+ return opts
156+ }
157+
158+ type startupConfig struct {
159+ NodeName string
160+ DBPath string
161+ HTTPAddr string
162+ WebURL string
163+ GossipAddr string
164+ WebEnabled bool
165+ Version string
166+ NodeTTL time.Duration
167+ SeedCount int
168+ MDNSCount int
169+ JoinedPeers int
170+ JoinErr error
171+ }
172+
173+ func logStartupConfig (cfg startupConfig ) {
174+ log .Print (startupSummary (cfg ))
175+ }
176+
177+ func startupSummary (cfg startupConfig ) string {
178+ join := "solo"
179+ if cfg .JoinErr != nil {
180+ join = fmt .Sprintf ("warning joined=%d error=%q" , cfg .JoinedPeers , cfg .JoinErr )
181+ } else if cfg .JoinedPeers > 0 {
182+ join = fmt .Sprintf ("joined=%d" , cfg .JoinedPeers )
183+ }
184+ return fmt .Sprintf ("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s ttl=%s seeds=%d mdns=%d join=%s" ,
185+ cfg .Version , cfg .NodeName , cfg .DBPath , cfg .WebEnabled , cfg .HTTPAddr , cfg .WebURL , cfg .GossipAddr , cfg .NodeTTL , cfg .SeedCount , cfg .MDNSCount , join )
186+ }
187+
124188func pebbleLockHeld (err error ) bool {
125189 msg := strings .ToLower (err .Error ())
126190 return strings .Contains (msg , "lock" ) &&
@@ -137,7 +201,7 @@ func addressInUse(err error) bool {
137201 strings .Contains (msg , "bind: only one usage of each socket address" )
138202}
139203
140- func runTerminalMirror (hostname , gossipAddr string , seeds []string ) {
204+ func runTerminalMirror (hostname , gossipAddr string , seeds []string , listMode bool ) {
141205 tmpDir , err := os .MkdirTemp ("" , "psstd-view-*" )
142206 if err != nil {
143207 log .Fatalf ("terminal mirror temp db: %v" , err )
@@ -172,7 +236,7 @@ func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
172236 log .Printf ("terminal mirror joined cluster, %d peer(s)" , n )
173237 }
174238
175- terminalRenderLoop (db )
239+ terminalRenderLoop (db , listMode )
176240}
177241
178242func terminalMirrorSeeds (gossipAddr string , seeds []string ) []string {
@@ -188,11 +252,11 @@ func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
188252
189253// ── Stats loop ───────────────────────────────────────────────────────────────
190254
191- func statsLoop (hostname , webURL , version string , db * pebble.DB , d * kvDelegate ) {
255+ func statsLoop (hostname , webURL , version string , ttl time. Duration , db * pebble.DB , d * kvDelegate ) {
192256 ticker := time .NewTicker (2 * time .Second )
193257 defer ticker .Stop ()
194258 for range ticker .C {
195- stats , err := collectStats (hostname , webURL , version )
259+ stats , err := collectStats (hostname , webURL , version , ttl )
196260 if err != nil {
197261 log .Printf ("stats error: %v" , err )
198262 continue
@@ -255,6 +319,38 @@ func envOr(key, def string) string {
255319 return def
256320}
257321
322+ func nodeNameFromEnv (hostname string ) (string , error ) {
323+ override , ok := os .LookupEnv (envNodeName )
324+ if ! ok || override == "" {
325+ if strings .TrimSpace (hostname ) == "" {
326+ return "" , fmt .Errorf ("hostname is empty; set %s" , envNodeName )
327+ }
328+ return hostname , nil
329+ }
330+ if strings .TrimSpace (override ) != override || override == "" {
331+ return "" , fmt .Errorf ("%s must not be empty or have leading/trailing whitespace" , envNodeName )
332+ }
333+ if strings .ContainsAny (override , " \t \r \n " ) {
334+ return "" , fmt .Errorf ("%s must not contain whitespace" , envNodeName )
335+ }
336+ return override , nil
337+ }
338+
339+ func nodeTTLFromEnv () (time.Duration , error ) {
340+ value , ok := os .LookupEnv (envNodeTTL )
341+ if ! ok || value == "" {
342+ return defaultNodeTTL , nil
343+ }
344+ ttl , err := time .ParseDuration (value )
345+ if err != nil {
346+ return 0 , fmt .Errorf ("%s must be a duration such as 15s or 1m: %w" , envNodeTTL , err )
347+ }
348+ if ttl < 2 * time .Second {
349+ return 0 , fmt .Errorf ("%s must be at least 2s" , envNodeTTL )
350+ }
351+ return ttl , nil
352+ }
353+
258354func splitCSV (s string ) []string {
259355 var out []string
260356 for _ , p := range strings .Split (s , "," ) {
0 commit comments