updated 2-21-26
This commit is contained in:
parent
3078112498
commit
f4f5faf00e
6
go.mod
6
go.mod
@ -1,3 +1,9 @@
|
|||||||
module zlh-agent
|
module zlh-agent
|
||||||
|
|
||||||
go 1.21.6
|
go 1.21.6
|
||||||
|
|
||||||
|
require github.com/creack/pty v1.1.21
|
||||||
|
|
||||||
|
require github.com/gorilla/websocket v1.5.1
|
||||||
|
|
||||||
|
require golang.org/x/net v0.17.0 // indirect
|
||||||
|
|||||||
6
go.sum
Normal file
6
go.sum
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
||||||
|
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
|
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
|
||||||
|
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
|
||||||
|
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||||
|
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||||
@ -8,9 +8,11 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
mcstatus "zlh-agent/internal/minecraft"
|
||||||
"zlh-agent/internal/provision"
|
"zlh-agent/internal/provision"
|
||||||
"zlh-agent/internal/provision/devcontainer"
|
"zlh-agent/internal/provision/devcontainer"
|
||||||
"zlh-agent/internal/provision/devcontainer/go"
|
"zlh-agent/internal/provision/devcontainer/go"
|
||||||
@ -20,11 +22,16 @@ import (
|
|||||||
"zlh-agent/internal/provision/minecraft"
|
"zlh-agent/internal/provision/minecraft"
|
||||||
"zlh-agent/internal/state"
|
"zlh-agent/internal/state"
|
||||||
"zlh-agent/internal/system"
|
"zlh-agent/internal/system"
|
||||||
|
"zlh-agent/internal/update"
|
||||||
|
"zlh-agent/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
Helpers
|
Helpers
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return err == nil
|
return err == nil
|
||||||
@ -35,9 +42,12 @@ func dirExists(path string) bool {
|
|||||||
return err == nil && s.IsDir()
|
return err == nil && s.IsDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
Shared provision pipeline (installer + Minecraft verify)
|
Shared provision pipeline (installer + Minecraft verify)
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func runProvisionPipeline(cfg *state.Config) error {
|
func runProvisionPipeline(cfg *state.Config) error {
|
||||||
state.SetState(state.StateInstalling)
|
state.SetState(state.StateInstalling)
|
||||||
state.SetInstallStep("provision_all")
|
state.SetInstallStep("provision_all")
|
||||||
@ -61,12 +71,15 @@ func runProvisionPipeline(cfg *state.Config) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
ensureProvisioned() — idempotent, unified
|
ensureProvisioned() — idempotent, unified
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func ensureProvisioned(cfg *state.Config) error {
|
func ensureProvisioned(cfg *state.Config) error {
|
||||||
|
|
||||||
if cfg.ContainerType == "dev" {
|
if cfg.ContainerType == "dev" {
|
||||||
|
|
||||||
if !devcontainer.IsProvisioned() {
|
if !devcontainer.IsProvisioned() {
|
||||||
if err := runProvisionPipeline(cfg); err != nil {
|
if err := runProvisionPipeline(cfg); err != nil {
|
||||||
@ -93,12 +106,10 @@ if cfg.ContainerType == "dev" {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✅ DEV READY = RUNNING
|
state.SetState(state.StateIdle)
|
||||||
state.SetState(state.StateRunning)
|
|
||||||
state.SetError(nil)
|
state.SetError(nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
dir := provision.ServerDir(*cfg)
|
dir := provision.ServerDir(*cfg)
|
||||||
game := strings.ToLower(cfg.Game)
|
game := strings.ToLower(cfg.Game)
|
||||||
@ -131,9 +142,12 @@ if cfg.ContainerType == "dev" {
|
|||||||
return runProvisionPipeline(cfg)
|
return runProvisionPipeline(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/config — the REAL provisioning trigger (async)
|
/config — the REAL provisioning trigger (async)
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
func handleConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
@ -193,6 +207,23 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wait for server.properties to exist before enforcing
|
||||||
|
propsPath := filepath.Join(provision.ServerDir(c), "server.properties")
|
||||||
|
propsDeadline := time.Now().Add(2 * time.Minute)
|
||||||
|
for {
|
||||||
|
if _, err := os.Stat(propsPath); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(propsDeadline) {
|
||||||
|
err := fmt.Errorf("forge server.properties not found before timeout")
|
||||||
|
log.Println("[agent] forge post-start error:", err)
|
||||||
|
state.SetError(err)
|
||||||
|
state.SetState(state.StateError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
_ = system.StopServer()
|
_ = system.StopServer()
|
||||||
|
|
||||||
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
if err := minecraft.EnforceForgeServerProperties(c); err != nil {
|
||||||
@ -219,9 +250,12 @@ func handleConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`))
|
_, _ = w.Write([]byte(`{"ok": true, "state": "installing"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/start
|
/start
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleStart(w http.ResponseWriter, r *http.Request) {
|
func handleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := state.LoadConfig()
|
cfg, err := state.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -243,9 +277,12 @@ func handleStart(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/stop
|
/stop
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleStop(w http.ResponseWriter, r *http.Request) {
|
func handleStop(w http.ResponseWriter, r *http.Request) {
|
||||||
if err := system.StopServer(); err != nil {
|
if err := system.StopServer(); err != nil {
|
||||||
http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "stop error: "+err.Error(), http.StatusInternalServerError)
|
||||||
@ -254,9 +291,12 @@ func handleStop(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/restart
|
/restart
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleRestart(w http.ResponseWriter, r *http.Request) {
|
func handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, err := state.LoadConfig()
|
cfg, err := state.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -280,9 +320,12 @@ func handleRestart(w http.ResponseWriter, r *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
_, _ = w.Write([]byte(`{"ok": true, "state": "starting"}`))
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/status
|
/status
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
cfg, _ := state.LoadConfig()
|
cfg, _ := state.LoadConfig()
|
||||||
|
|
||||||
@ -303,9 +346,12 @@ func handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
/console/command
|
/console/command
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func handleSendCommand(w http.ResponseWriter, r *http.Request) {
|
func handleSendCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
cmd := r.URL.Query().Get("cmd")
|
cmd := r.URL.Query().Get("cmd")
|
||||||
if cmd == "" {
|
if cmd == "" {
|
||||||
@ -322,8 +368,158 @@ func handleSendCommand(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
Router
|
/agent/update
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func handleAgentUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := update.CheckAndUpdate(version.AgentVersion)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
/agent/update/status
|
||||||
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func handleAgentUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := update.ReadStatus()
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
/version
|
||||||
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp := map[string]any{
|
||||||
|
"version": version.AgentVersion,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
/game/players
|
||||||
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func handleGamePlayers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
http.Error(w, "GET only", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := state.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "no config loaded", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(cfg.ContainerType) != "game" {
|
||||||
|
http.Error(w, "not a game container", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(cfg.Game) != "minecraft" {
|
||||||
|
http.Error(w, "unsupported game", http.StatusNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ports := make([]int, 0, 3)
|
||||||
|
propsPath := filepath.Join(provision.ServerDir(*cfg), "server.properties")
|
||||||
|
if b, err := os.ReadFile(propsPath); err == nil {
|
||||||
|
lines := strings.Split(string(b), "\n")
|
||||||
|
for _, l := range lines {
|
||||||
|
if strings.HasPrefix(l, "server-port=") {
|
||||||
|
if p, err := strconv.Atoi(strings.TrimPrefix(l, "server-port=")); err == nil && p > 0 {
|
||||||
|
ports = append(ports, p)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(cfg.Ports) > 0 && cfg.Ports[0] > 0 {
|
||||||
|
ports = append(ports, cfg.Ports[0])
|
||||||
|
}
|
||||||
|
ports = append(ports, 25565)
|
||||||
|
|
||||||
|
seenPorts := make(map[int]struct{}, len(ports))
|
||||||
|
uniqPorts := make([]int, 0, len(ports))
|
||||||
|
for _, p := range ports {
|
||||||
|
if _, ok := seenPorts[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenPorts[p] = struct{}{}
|
||||||
|
uniqPorts = append(uniqPorts, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols := []int{mcstatus.ProtocolForVersion(cfg.Version), 767, 765, 763, 762, 754}
|
||||||
|
seenProtocols := make(map[int]struct{}, len(protocols))
|
||||||
|
uniqProtocols := make([]int, 0, len(protocols))
|
||||||
|
for _, pr := range protocols {
|
||||||
|
if _, ok := seenProtocols[pr]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seenProtocols[pr] = struct{}{}
|
||||||
|
uniqProtocols = append(uniqProtocols, pr)
|
||||||
|
}
|
||||||
|
|
||||||
|
var status mcstatus.StatusResponse
|
||||||
|
var lastErr error
|
||||||
|
for _, port := range uniqPorts {
|
||||||
|
for _, protocol := range uniqProtocols {
|
||||||
|
s, err := mcstatus.QueryStatus("127.0.0.1", port, protocol)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
status = s
|
||||||
|
lastErr = nil
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if lastErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
http.Error(w, "status query failed: "+lastErr.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
players := make([]string, 0, len(status.Players.Sample))
|
||||||
|
for _, p := range status.Players.Sample {
|
||||||
|
if p.Name != "" {
|
||||||
|
players = append(players, p.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := map[string]any{
|
||||||
|
"online": status.Players.Online,
|
||||||
|
"max": status.Players.Max,
|
||||||
|
"players": players,
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
Router
|
||||||
|
|
||||||
|
----------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
func NewMux() *http.ServeMux {
|
func NewMux() *http.ServeMux {
|
||||||
m := http.NewServeMux()
|
m := http.NewServeMux()
|
||||||
|
|
||||||
@ -333,6 +529,10 @@ func NewMux() *http.ServeMux {
|
|||||||
m.HandleFunc("/restart", handleRestart)
|
m.HandleFunc("/restart", handleRestart)
|
||||||
m.HandleFunc("/status", handleStatus)
|
m.HandleFunc("/status", handleStatus)
|
||||||
m.HandleFunc("/console/command", handleSendCommand)
|
m.HandleFunc("/console/command", handleSendCommand)
|
||||||
|
m.HandleFunc("/agent/update", handleAgentUpdate)
|
||||||
|
m.HandleFunc("/agent/update/status", handleAgentUpdateStatus)
|
||||||
|
m.HandleFunc("/version", handleVersion)
|
||||||
|
m.HandleFunc("/game/players", handleGamePlayers)
|
||||||
|
|
||||||
registerWebSocket(m)
|
registerWebSocket(m)
|
||||||
|
|
||||||
|
|||||||
189
internal/http/console_sessions.go
Normal file
189
internal/http/console_sessions.go
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
package agenthttp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"zlh-agent/internal/runtime"
|
||||||
|
"zlh-agent/internal/state"
|
||||||
|
"zlh-agent/internal/system"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 60 * time.Second
|
||||||
|
|
||||||
|
type consoleConn struct {
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type consoleSession struct {
|
||||||
|
key string
|
||||||
|
cfg *state.Config
|
||||||
|
ptyFile *os.File
|
||||||
|
createdAt time.Time
|
||||||
|
lastActive time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
conns map[*websocket.Conn]*consoleConn
|
||||||
|
readerOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
sessionMu sync.Mutex
|
||||||
|
sessions = make(map[string]*consoleSession)
|
||||||
|
)
|
||||||
|
|
||||||
|
func sessionKey(cfg *state.Config) string {
|
||||||
|
return fmt.Sprintf("%d:%s", cfg.VMID, cfg.ContainerType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConsoleSession(cfg *state.Config) (*consoleSession, bool, error) {
|
||||||
|
key := sessionKey(cfg)
|
||||||
|
|
||||||
|
sessionMu.Lock()
|
||||||
|
if sess, ok := sessions[key]; ok {
|
||||||
|
sessionMu.Unlock()
|
||||||
|
sess.touch()
|
||||||
|
log.Printf("[ws] session reuse: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||||
|
return sess, true, nil
|
||||||
|
}
|
||||||
|
sessionMu.Unlock()
|
||||||
|
|
||||||
|
ptyFile, err := system.GetConsolePTY(cfg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sess := &consoleSession{
|
||||||
|
key: key,
|
||||||
|
cfg: cfg,
|
||||||
|
ptyFile: ptyFile,
|
||||||
|
createdAt: time.Now(),
|
||||||
|
lastActive: time.Now(),
|
||||||
|
conns: make(map[*websocket.Conn]*consoleConn),
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.startReader()
|
||||||
|
|
||||||
|
sessionMu.Lock()
|
||||||
|
sessions[key] = sess
|
||||||
|
sessionMu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("[ws] session created: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||||
|
return sess, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) touch() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) addConn(conn *websocket.Conn, cc *consoleConn) *consoleConn {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
if cc == nil {
|
||||||
|
cc = &consoleConn{send: make(chan []byte, 128)}
|
||||||
|
}
|
||||||
|
s.conns[conn] = cc
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
log.Printf("[ws] conn add: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||||
|
return cc
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) removeConn(conn *websocket.Conn) int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
cc, ok := s.conns[conn]
|
||||||
|
if ok {
|
||||||
|
delete(s.conns, conn)
|
||||||
|
close(cc.send)
|
||||||
|
}
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
log.Printf("[ws] conn remove: vmid=%d type=%s conns=%d", s.cfg.VMID, s.cfg.ContainerType, len(s.conns))
|
||||||
|
return len(s.conns)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) startReader() {
|
||||||
|
s.readerOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := s.ptyFile.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
out := make([]byte, n)
|
||||||
|
copy(out, buf[:n])
|
||||||
|
log.Printf("[ws] pty read: vmid=%d bytes=%d", s.cfg.VMID, n)
|
||||||
|
s.broadcast(out)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
log.Printf("[ws] pty read loop exit: vmid=%d err=EOF", s.cfg.VMID)
|
||||||
|
} else {
|
||||||
|
log.Printf("[ws] pty read loop exit: vmid=%d err=%v", s.cfg.VMID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if n == 0 && err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) broadcast(data []byte) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.lastActive = time.Now()
|
||||||
|
for _, cc := range s.conns {
|
||||||
|
select {
|
||||||
|
case cc.send <- data:
|
||||||
|
default:
|
||||||
|
select {
|
||||||
|
case <-cc.send:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case cc.send <- data:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) writeInput(data []byte) error {
|
||||||
|
s.touch()
|
||||||
|
return runtime.Write(s.ptyFile, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *consoleSession) scheduleCleanupIfIdle() {
|
||||||
|
s.mu.Lock()
|
||||||
|
last := s.lastActive
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
go func(ts time.Time) {
|
||||||
|
time.Sleep(sessionTTL)
|
||||||
|
s.mu.Lock()
|
||||||
|
conns := len(s.conns)
|
||||||
|
lastActive := s.lastActive
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if conns == 0 && lastActive.Equal(ts) {
|
||||||
|
log.Printf("[ws] session cleanup: vmid=%d type=%s", s.cfg.VMID, s.cfg.ContainerType)
|
||||||
|
if s.cfg.ContainerType == "dev" {
|
||||||
|
_ = system.StopDevShell()
|
||||||
|
}
|
||||||
|
sessionMu.Lock()
|
||||||
|
delete(sessions, s.key)
|
||||||
|
sessionMu.Unlock()
|
||||||
|
}
|
||||||
|
}(last)
|
||||||
|
}
|
||||||
@ -1,19 +1,13 @@
|
|||||||
package agenthttp
|
package agenthttp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"crypto/sha1"
|
|
||||||
"encoding/base64"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"zlh-agent/internal/provision"
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
"zlh-agent/internal/state"
|
"zlh-agent/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,86 +19,14 @@ import (
|
|||||||
GET /console/stream
|
GET /console/stream
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const maxInitialTail = 4096 // 4 KB
|
const maxPayloadSize = 64 * 1024
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
var wsUpgrader = websocket.Upgrader{
|
||||||
Minimal WebSocket Upgrader (stdlib only)
|
ReadBufferSize: 4096,
|
||||||
----------------------------------------------------------------------------*/
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
type WebSocketConn struct {
|
return true
|
||||||
Conn net.Conn
|
},
|
||||||
Rw *bufio.ReadWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
func upgradeToWebSocket(w http.ResponseWriter, r *http.Request) (*WebSocketConn, error) {
|
|
||||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") ||
|
|
||||||
strings.ToLower(r.Header.Get("Upgrade")) != "websocket" {
|
|
||||||
return nil, fmt.Errorf("invalid websocket upgrade request")
|
|
||||||
}
|
|
||||||
|
|
||||||
key := r.Header.Get("Sec-WebSocket-Key")
|
|
||||||
if key == "" {
|
|
||||||
return nil, fmt.Errorf("missing Sec-WebSocket-Key")
|
|
||||||
}
|
|
||||||
|
|
||||||
accept := computeAcceptKey(key)
|
|
||||||
|
|
||||||
h := w.Header()
|
|
||||||
h.Set("Upgrade", "websocket")
|
|
||||||
h.Set("Connection", "Upgrade")
|
|
||||||
h.Set("Sec-WebSocket-Accept", accept)
|
|
||||||
h.Set("Sec-WebSocket-Version", "13")
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusSwitchingProtocols)
|
|
||||||
|
|
||||||
hj, ok := w.(http.Hijacker)
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("websocket: hijacking not supported")
|
|
||||||
}
|
|
||||||
|
|
||||||
conn, rw, err := hj.Hijack()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("websocket hijack: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &WebSocketConn{
|
|
||||||
Conn: conn,
|
|
||||||
Rw: rw,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WebSocketConn) WriteText(msg string) error {
|
|
||||||
payload := []byte(msg)
|
|
||||||
|
|
||||||
// FIN + opcode(1 = text)
|
|
||||||
header := []byte{0x81}
|
|
||||||
|
|
||||||
// Length encoding
|
|
||||||
if len(payload) < 126 {
|
|
||||||
header = append(header, byte(len(payload)))
|
|
||||||
} else {
|
|
||||||
header = append(header, 126, byte(len(payload)>>8), byte(len(payload)))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := c.Conn.Write(header); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err := c.Conn.Write(payload)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *WebSocketConn) Close() error {
|
|
||||||
return c.Conn.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
|
||||||
SHA-1 + Base64 for Sec-WebSocket-Accept
|
|
||||||
----------------------------------------------------------------------------*/
|
|
||||||
|
|
||||||
func computeAcceptKey(key string) string {
|
|
||||||
const magic = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
||||||
h := sha1.Sum([]byte(key + magic))
|
|
||||||
return base64.StdEncoding.EncodeToString(h[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
@ -117,45 +39,131 @@ func handleConsoleStream(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "no config loaded", http.StatusBadRequest)
|
http.Error(w, "no config loaded", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("[ws] console connect: vmid=%d type=%s runtime=%s game=%s", cfg.VMID, cfg.ContainerType, cfg.Runtime, cfg.Game)
|
||||||
|
|
||||||
ws, err := upgradeToWebSocket(w, r)
|
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("[ws] upgrade failed:", err)
|
log.Println("[ws] upgrade failed:", err)
|
||||||
http.Error(w, "websocket upgrade failed", http.StatusBadRequest)
|
http.Error(w, "websocket upgrade failed", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ws.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
dir := provision.ServerDir(*cfg)
|
conn.SetReadLimit(maxPayloadSize)
|
||||||
logfile := filepath.Join(dir, "logs", "latest.log")
|
conn.SetCloseHandler(func(code int, text string) error {
|
||||||
|
log.Printf("[ws] close frame: vmid=%d code=%d text=%q", cfg.VMID, code, text)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
f, err := os.Open(logfile)
|
sendCh := make(chan []byte, 64)
|
||||||
if err != nil {
|
writeErrCh := make(chan error, 1)
|
||||||
_ = ws.WriteText(fmt.Sprintf("[error] cannot open log: %v", err))
|
go func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-sendCh:
|
||||||
|
if !ok {
|
||||||
|
writeErrCh <- nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer f.Close()
|
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
|
||||||
|
writeErrCh <- err
|
||||||
// 1) Send last 4 KB (initial tail)
|
return
|
||||||
stat, _ := f.Stat()
|
|
||||||
sz := stat.Size()
|
|
||||||
if sz > maxInitialTail {
|
|
||||||
_, _ = f.Seek(sz-maxInitialTail, 0)
|
|
||||||
}
|
}
|
||||||
|
case <-ticker.C:
|
||||||
scanner := bufio.NewScanner(f)
|
if err := conn.WriteMessage(websocket.TextMessage, []byte{}); err != nil {
|
||||||
for scanner.Scan() {
|
writeErrCh <- err
|
||||||
_ = ws.WriteText(scanner.Text())
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// 2) Follow the file — stream new log lines live
|
inputCh := make(chan []byte, 32)
|
||||||
|
readErrCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
for {
|
for {
|
||||||
time.Sleep(500 * time.Millisecond)
|
msgType, msg, err := conn.ReadMessage()
|
||||||
for scanner.Scan() {
|
if err != nil {
|
||||||
line := scanner.Text()
|
log.Printf("[ws] read error: vmid=%d err=%v", cfg.VMID, err)
|
||||||
_ = ws.WriteText(line)
|
readErrCh <- err
|
||||||
|
close(inputCh)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
|
||||||
|
log.Printf("[ws] input: vmid=%d bytes=%d type=%d", cfg.VMID, len(msg), msgType)
|
||||||
|
inputCh <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitingNotified := false
|
||||||
|
var sess *consoleSession
|
||||||
|
sessionBound := false
|
||||||
|
for {
|
||||||
|
sess, _, err = getConsoleSession(cfg)
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[ws] console attached: vmid=%d type=%s", cfg.VMID, cfg.ContainerType)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if cfg.ContainerType != "dev" {
|
||||||
|
if !waitingNotified {
|
||||||
|
sendCh <- []byte("[info] waiting for server console...")
|
||||||
|
log.Printf("[ws] waiting for server console: vmid=%d type=%s err=%v", cfg.VMID, cfg.ContainerType, err)
|
||||||
|
waitingNotified = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[ws] dev console unavailable: vmid=%d err=%v", cfg.VMID, err)
|
||||||
|
sendCh <- []byte(fmt.Sprintf("[error] %v", err))
|
||||||
|
if !sessionBound {
|
||||||
|
close(sendCh)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(500 * time.Millisecond):
|
||||||
|
case <-readErrCh:
|
||||||
|
if !sessionBound {
|
||||||
|
close(sendCh)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-writeErrCh:
|
||||||
|
if !sessionBound {
|
||||||
|
close(sendCh)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sess.addConn(conn, &consoleConn{send: sendCh})
|
||||||
|
sessionBound = true
|
||||||
|
defer func() {
|
||||||
|
if sess != nil {
|
||||||
|
if sess.removeConn(conn) == 0 {
|
||||||
|
sess.scheduleCleanupIfIdle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-inputCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := sess.writeInput(msg); err != nil {
|
||||||
|
sendCh <- []byte(fmt.Sprintf("[error] %v", err))
|
||||||
|
}
|
||||||
|
case <-readErrCh:
|
||||||
|
return
|
||||||
|
case err := <-writeErrCh:
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[ws] write error: vmid=%d err=%v", cfg.VMID, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
// on EOF, loop continues and scanner will pick up new lines
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
205
internal/minecraft/status.go
Normal file
205
internal/minecraft/status.go
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
package minecraft
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusResponse struct {
|
||||||
|
Version struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Protocol int `json:"protocol"`
|
||||||
|
} `json:"version"`
|
||||||
|
Players struct {
|
||||||
|
Max int `json:"max"`
|
||||||
|
Online int `json:"online"`
|
||||||
|
Sample []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"sample"`
|
||||||
|
} `json:"players"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProtocolForVersion(version string) int {
|
||||||
|
v := strings.TrimSpace(strings.TrimPrefix(version, "v"))
|
||||||
|
switch v {
|
||||||
|
case "1.21.1":
|
||||||
|
return 767
|
||||||
|
case "1.21":
|
||||||
|
return 767
|
||||||
|
case "1.20.4":
|
||||||
|
return 765
|
||||||
|
case "1.20.1":
|
||||||
|
return 763
|
||||||
|
case "1.19.4":
|
||||||
|
return 762
|
||||||
|
default:
|
||||||
|
return 754
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryStatus(host string, port int, protocol int) (StatusResponse, error) {
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||||
|
if err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
|
||||||
|
if err := writeHandshake(conn, host, port, protocol); err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
if err := writeStatusRequest(conn); err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
r := bytes.NewReader(payload)
|
||||||
|
packetID, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
if packetID != 0x00 {
|
||||||
|
return StatusResponse{}, fmt.Errorf("unexpected packet id: %d", packetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
respStr, err := readString(r)
|
||||||
|
if err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var status StatusResponse
|
||||||
|
if err := json.Unmarshal([]byte(respStr), &status); err != nil {
|
||||||
|
return StatusResponse{}, err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHandshake(w io.Writer, host string, port int, protocol int) error {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
if err := writeVarInt(&payload, 0x00); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeVarInt(&payload, protocol); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeString(&payload, host); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := binary.Write(&payload, binary.BigEndian, uint16(port)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeVarInt(&payload, 0x01); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return writePacket(w, payload.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStatusRequest(w io.Writer) error {
|
||||||
|
var payload bytes.Buffer
|
||||||
|
if err := writeVarInt(&payload, 0x00); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writePacket(w, payload.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
func writePacket(w io.Writer, payload []byte) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := writeVarInt(&buf, len(payload)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := buf.Write(payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := w.Write(buf.Bytes())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readPacket(r io.Reader) ([]byte, error) {
|
||||||
|
length, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if length <= 0 || length > 1<<20 {
|
||||||
|
return nil, fmt.Errorf("invalid packet length: %d", length)
|
||||||
|
}
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeVarInt(w io.Writer, value int) error {
|
||||||
|
for {
|
||||||
|
temp := byte(value & 0x7F)
|
||||||
|
value >>= 7
|
||||||
|
if value != 0 {
|
||||||
|
temp |= 0x80
|
||||||
|
}
|
||||||
|
if _, err := w.Write([]byte{temp}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readVarInt(r io.Reader) (int, error) {
|
||||||
|
numRead := 0
|
||||||
|
result := 0
|
||||||
|
for {
|
||||||
|
if numRead > 5 {
|
||||||
|
return 0, fmt.Errorf("varint too long")
|
||||||
|
}
|
||||||
|
b := make([]byte, 1)
|
||||||
|
if _, err := r.Read(b); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
value := int(b[0] & 0x7F)
|
||||||
|
result |= value << (7 * numRead)
|
||||||
|
numRead++
|
||||||
|
if b[0]&0x80 == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeString(w io.Writer, s string) error {
|
||||||
|
if err := writeVarInt(w, len(s)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err := w.Write([]byte(s))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readString(r io.Reader) (string, error) {
|
||||||
|
length, err := readVarInt(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if length < 0 || length > 1<<20 {
|
||||||
|
return "", fmt.Errorf("invalid string length: %d", length)
|
||||||
|
}
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(buf), nil
|
||||||
|
}
|
||||||
45
internal/runtime/pty.go
Normal file
45
internal/runtime/pty.go
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreatePTY starts cmd attached to a PTY and returns the PTY file.
|
||||||
|
func CreatePTY(cmd *exec.Cmd) (*os.File, error) {
|
||||||
|
ptmx, err := pty.Start(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ptmx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadLoop reads from a PTY in non-blocking mode and streams chunks to onData.
|
||||||
|
func ReadLoop(ptyFile *os.File, stop <-chan struct{}, onData func([]byte) error) error {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := ptyFile.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if writeErr := onData(buf[:n]); writeErr != nil {
|
||||||
|
return writeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write sends data to the PTY.
|
||||||
|
func Write(ptyFile *os.File, data []byte) error {
|
||||||
|
_, err := ptyFile.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -42,8 +43,6 @@ type Config struct {
|
|||||||
AdminPass string `json:"admin_pass,omitempty"`
|
AdminPass string `json:"admin_pass,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
AGENT STATE ENUM
|
AGENT STATE ENUM
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
@ -181,5 +180,24 @@ func LoadConfig() (*Config, error) {
|
|||||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ContainerType == "" {
|
||||||
|
vmidStr := strconv.Itoa(cfg.VMID)
|
||||||
|
if len(vmidStr) > 0 {
|
||||||
|
switch vmidStr[0] {
|
||||||
|
case '6':
|
||||||
|
cfg.ContainerType = "dev"
|
||||||
|
case '5':
|
||||||
|
cfg.ContainerType = "game"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cfg.ContainerType == "" && cfg.Runtime != "" {
|
||||||
|
cfg.ContainerType = "dev"
|
||||||
|
}
|
||||||
|
if cfg.ContainerType == "" {
|
||||||
|
cfg.ContainerType = "game"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,19 +2,18 @@ package system
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings" // <-- ADD THIS
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"zlh-agent/internal/provision"
|
"zlh-agent/internal/provision"
|
||||||
|
"zlh-agent/internal/runtime"
|
||||||
"zlh-agent/internal/state"
|
"zlh-agent/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
GLOBAL PROCESS STATE
|
GLOBAL PROCESS STATE
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
@ -22,7 +21,10 @@ import (
|
|||||||
var (
|
var (
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
serverCmd *exec.Cmd
|
serverCmd *exec.Cmd
|
||||||
serverStdin io.WriteCloser
|
serverPTY *os.File
|
||||||
|
|
||||||
|
devCmd *exec.Cmd
|
||||||
|
devPTY *os.File
|
||||||
)
|
)
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
@ -44,115 +46,40 @@ func StartServer(cfg *state.Config) error {
|
|||||||
cmd := exec.Command("/bin/bash", startScript)
|
cmd := exec.Command("/bin/bash", startScript)
|
||||||
cmd.Dir = dir
|
cmd.Dir = dir
|
||||||
|
|
||||||
stdout, _ := cmd.StdoutPipe()
|
ptmx, err := runtime.CreatePTY(cmd)
|
||||||
stderr, _ := cmd.StderrPipe()
|
if err != nil {
|
||||||
stdin, _ := cmd.StdinPipe()
|
|
||||||
|
|
||||||
serverStdin = stdin
|
|
||||||
serverCmd = cmd
|
|
||||||
|
|
||||||
// Mark STARTING (not running)
|
|
||||||
state.SetState(state.StateStarting)
|
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
|
||||||
serverCmd = nil
|
|
||||||
return fmt.Errorf("start server: %w", err)
|
return fmt.Errorf("start server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------
|
serverCmd = cmd
|
||||||
Log pumps
|
serverPTY = ptmx
|
||||||
--------------------------*/
|
|
||||||
go pumpOutput(stdout, os.Stdout)
|
|
||||||
go pumpOutput(stderr, os.Stderr)
|
|
||||||
|
|
||||||
/* -------------------------
|
state.SetState(state.StateRunning)
|
||||||
Detect "Done" → running
|
state.SetError(nil)
|
||||||
--------------------------*/
|
|
||||||
go detectMinecraftReady(cfg)
|
|
||||||
|
|
||||||
/* -------------------------
|
|
||||||
Crash watcher
|
|
||||||
--------------------------*/
|
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if err != nil {
|
if serverPTY != nil {
|
||||||
state.RecordCrash(err)
|
_ = serverPTY.Close()
|
||||||
serverCmd = nil
|
|
||||||
serverStdin = nil
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal stop
|
if err != nil {
|
||||||
|
state.RecordCrash(err)
|
||||||
|
} else {
|
||||||
state.SetState(state.StateIdle)
|
state.SetState(state.StateIdle)
|
||||||
|
}
|
||||||
|
|
||||||
serverCmd = nil
|
serverCmd = nil
|
||||||
serverStdin = nil
|
serverPTY = nil
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/* helper to pump logs */
|
|
||||||
func pumpOutput(r io.Reader, w *os.File) {
|
|
||||||
buf := make([]byte, 4096)
|
|
||||||
for {
|
|
||||||
n, err := r.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
w.Write(buf[:n])
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Detects Minecraft "Done" and updates state */
|
|
||||||
func detectMinecraftReady(cfg *state.Config) {
|
|
||||||
dir := provision.ServerDir(*cfg)
|
|
||||||
logPath := filepath.Join(dir, "logs", "latest.log")
|
|
||||||
|
|
||||||
deadline := time.Now().Add(5 * time.Minute) // FORGE NEEDS MORE TIME
|
|
||||||
|
|
||||||
lastSize := int64(0)
|
|
||||||
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
|
|
||||||
// Wait for log file to appear
|
|
||||||
st, err := os.Stat(logPath)
|
|
||||||
if err == nil {
|
|
||||||
// ensure file is growing
|
|
||||||
if st.Size() != lastSize {
|
|
||||||
lastSize = st.Size()
|
|
||||||
|
|
||||||
b, _ := os.ReadFile(logPath)
|
|
||||||
s := string(b)
|
|
||||||
|
|
||||||
// UNIVERSAL READY MATCHES
|
|
||||||
if strings.Contains(s, "Done (") ||
|
|
||||||
strings.Contains(s, "For help, type \"help\"") ||
|
|
||||||
strings.Contains(s, "Successfully loaded forge") ||
|
|
||||||
strings.Contains(s, "Preparing spawn area: 100%") {
|
|
||||||
|
|
||||||
state.SetState(state.StateRunning)
|
|
||||||
state.SetError(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
state.SetState(state.StateError)
|
|
||||||
state.SetError(fmt.Errorf("server failed to reach running state before timeout"))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
StopServer
|
StopServer
|
||||||
----------------------------------------------------------------------------*/
|
----------------------------------------------------------------------------*/
|
||||||
@ -168,10 +95,10 @@ func StopServer() error {
|
|||||||
state.SetState(state.StateStopping)
|
state.SetState(state.StateStopping)
|
||||||
|
|
||||||
// Try graceful stop
|
// Try graceful stop
|
||||||
if serverStdin != nil {
|
if serverPTY != nil {
|
||||||
_, _ = serverStdin.Write([]byte("save-all\n"))
|
_ = runtime.Write(serverPTY, []byte("save-all\n"))
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
_, _ = serverStdin.Write([]byte("stop\n"))
|
_ = runtime.Write(serverPTY, []byte("stop\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait a moment
|
// Wait a moment
|
||||||
@ -182,9 +109,6 @@ func StopServer() error {
|
|||||||
_ = serverCmd.Process.Kill()
|
_ = serverCmd.Process.Kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
state.SetState(state.StateIdle)
|
|
||||||
serverCmd = nil
|
|
||||||
serverStdin = nil
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,10 +132,137 @@ func SendConsoleCommand(cmd string) error {
|
|||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
if serverStdin == nil {
|
if serverPTY == nil {
|
||||||
return fmt.Errorf("server console not available")
|
return fmt.Errorf("server console not available")
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := serverStdin.Write([]byte(cmd + "\n"))
|
return runtime.Write(serverPTY, []byte(cmd+"\n"))
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Dev Shell PTY
|
||||||
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func StartDevShell() (*os.File, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if devPTY != nil && devCmd != nil {
|
||||||
|
return devPTY, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
shell := "/bin/bash"
|
||||||
|
if _, err := os.Stat(shell); err != nil {
|
||||||
|
shell = "/bin/sh"
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd *exec.Cmd
|
||||||
|
if shell == "/bin/bash" {
|
||||||
|
cmd = exec.Command(shell, "-l", "-i")
|
||||||
|
} else {
|
||||||
|
cmd = exec.Command(shell, "-i")
|
||||||
|
}
|
||||||
|
cmd.Dir = "/opt"
|
||||||
|
|
||||||
|
ptmx, err := runtime.CreatePTY(cmd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("start dev shell: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devCmd = cmd
|
||||||
|
devPTY = ptmx
|
||||||
|
|
||||||
|
state.SetState(state.StateRunning)
|
||||||
|
state.SetError(nil)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := cmd.Wait()
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if devPTY != nil {
|
||||||
|
_ = devPTY.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
state.RecordCrash(err)
|
||||||
|
} else {
|
||||||
|
state.SetState(state.StateIdle)
|
||||||
|
}
|
||||||
|
|
||||||
|
devCmd = nil
|
||||||
|
devPTY = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
return devPTY, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetConsolePTY(cfg *state.Config) (*os.File, error) {
|
||||||
|
if cfg.ContainerType == "dev" {
|
||||||
|
return StartDevShell()
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if serverPTY == nil {
|
||||||
|
return nil, fmt.Errorf("server console not available")
|
||||||
|
}
|
||||||
|
return serverPTY, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteConsoleInput(cfg *state.Config, input string) error {
|
||||||
|
if strings.HasSuffix(input, "\n") {
|
||||||
|
input = strings.TrimSuffix(input, "\n")
|
||||||
|
}
|
||||||
|
payload := []byte(input + "\n")
|
||||||
|
|
||||||
|
if cfg.ContainerType == "dev" {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if devPTY == nil {
|
||||||
|
return fmt.Errorf("dev shell not available")
|
||||||
|
}
|
||||||
|
return runtime.Write(devPTY, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if serverPTY == nil {
|
||||||
|
return fmt.Errorf("server console not available")
|
||||||
|
}
|
||||||
|
return runtime.Write(serverPTY, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Stop Dev Shell
|
||||||
|
----------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
func StopDevShell() error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if devCmd == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if devPTY != nil {
|
||||||
|
_ = runtime.Write(devPTY, []byte("exit\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
if devCmd.Process != nil {
|
||||||
|
_ = devCmd.Process.Kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
if devPTY != nil {
|
||||||
|
_ = devPTY.Close()
|
||||||
|
devPTY = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
devCmd = nil
|
||||||
|
state.SetState(state.StateIdle)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
530
internal/update/update.go
Normal file
530
internal/update/update.go
Normal file
@ -0,0 +1,530 @@
|
|||||||
|
package update
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
artifactBaseURL = "http://10.60.0.251:8080/agents/"
|
||||||
|
releasesDir = "/opt/zlh-agent/releases"
|
||||||
|
currentLink = "/opt/zlh-agent/current"
|
||||||
|
previousLink = "/opt/zlh-agent/previous"
|
||||||
|
binaryPath = "/opt/zlh-agent/zlh-agent"
|
||||||
|
stateDir = "/opt/zlh-agent/state"
|
||||||
|
statusFile = "/opt/zlh-agent/state/update.json"
|
||||||
|
defaultUnit = "zlh-agent"
|
||||||
|
defaultMode = "notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Manifest struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
Latest string `json:"latest"`
|
||||||
|
MinSupported string `json:"min_supported"`
|
||||||
|
Channels map[string]string `json:"channels"`
|
||||||
|
Artifacts map[string]struct {
|
||||||
|
LinuxAMD64 struct {
|
||||||
|
Binary string `json:"binary"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
} `json:"linux_amd64"`
|
||||||
|
} `json:"artifacts"`
|
||||||
|
ReleasedAt string `json:"released_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Current string `json:"current,omitempty"`
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
CheckedAtUTC string `json:"checked_at_utc,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckAvailable(currentVersion string) Result {
|
||||||
|
currentVersion = normalizeVersion(currentVersion)
|
||||||
|
result := Result{
|
||||||
|
Status: "error",
|
||||||
|
Current: currentVersion,
|
||||||
|
CheckedAtUTC: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
manifest, err := fetchManifest()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if manifest.SchemaVersion != 1 {
|
||||||
|
result.Error = fmt.Sprintf("unsupported manifest schema: %d", manifest.SchemaVersion)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
target := normalizeVersion(manifest.Channels["stable"])
|
||||||
|
if target == "" {
|
||||||
|
result.Error = "missing stable channel version"
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Target = target
|
||||||
|
|
||||||
|
if compareVersions(currentVersion, target) >= 0 {
|
||||||
|
result.Status = "noop"
|
||||||
|
result.Error = ""
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Status = "available"
|
||||||
|
result.Error = ""
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartPeriodic(currentVersion string) {
|
||||||
|
mode := strings.ToLower(strings.TrimSpace(os.Getenv("ZLH_AGENT_UPDATE_MODE")))
|
||||||
|
if mode == "" {
|
||||||
|
mode = defaultMode
|
||||||
|
}
|
||||||
|
|
||||||
|
switch mode {
|
||||||
|
case "off", "disabled":
|
||||||
|
log.Printf("[update] periodic checks disabled (mode=%s)", mode)
|
||||||
|
return
|
||||||
|
case "notify", "auto":
|
||||||
|
default:
|
||||||
|
log.Printf("[update] invalid mode %q, using %q", mode, defaultMode)
|
||||||
|
mode = defaultMode
|
||||||
|
}
|
||||||
|
|
||||||
|
interval := 30 * time.Minute
|
||||||
|
if v := strings.TrimSpace(os.Getenv("ZLH_AGENT_UPDATE_INTERVAL")); v != "" {
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[update] invalid ZLH_AGENT_UPDATE_INTERVAL=%q: %v (using %s)", v, err, interval)
|
||||||
|
} else if d < time.Minute {
|
||||||
|
log.Printf("[update] ZLH_AGENT_UPDATE_INTERVAL too small (%s), using %s", d, interval)
|
||||||
|
} else {
|
||||||
|
interval = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("[update] periodic checks enabled (mode=%s interval=%s)", mode, interval)
|
||||||
|
|
||||||
|
run := func() {
|
||||||
|
switch mode {
|
||||||
|
case "auto":
|
||||||
|
res := CheckAndUpdate(currentVersion)
|
||||||
|
switch res.Status {
|
||||||
|
case "updated":
|
||||||
|
log.Printf("[update] applied update current=%s target=%s", res.Current, res.Target)
|
||||||
|
case "noop":
|
||||||
|
log.Printf("[update] no update available current=%s target=%s", res.Current, res.Target)
|
||||||
|
default:
|
||||||
|
log.Printf("[update] auto check failed status=%s current=%s target=%s err=%s", res.Status, res.Current, res.Target, res.Error)
|
||||||
|
}
|
||||||
|
case "notify":
|
||||||
|
res := CheckAvailable(currentVersion)
|
||||||
|
switch res.Status {
|
||||||
|
case "available":
|
||||||
|
log.Printf("[update] update available current=%s target=%s", res.Current, res.Target)
|
||||||
|
case "noop":
|
||||||
|
log.Printf("[update] no update available current=%s target=%s", res.Current, res.Target)
|
||||||
|
default:
|
||||||
|
log.Printf("[update] notify check failed status=%s current=%s target=%s err=%s", res.Status, res.Current, res.Target, res.Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
run()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
run()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckAndUpdate(currentVersion string) Result {
|
||||||
|
currentVersion = normalizeVersion(currentVersion)
|
||||||
|
result := Result{
|
||||||
|
Status: "error",
|
||||||
|
Current: currentVersion,
|
||||||
|
CheckedAtUTC: time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" {
|
||||||
|
result.Error = fmt.Sprintf("unsupported platform: %s_%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
lockPath := filepath.Join(stateDir, "update.lock")
|
||||||
|
if err := acquireLock(lockPath); err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
defer releaseLock(lockPath)
|
||||||
|
|
||||||
|
manifest, err := fetchManifest()
|
||||||
|
if err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if manifest.SchemaVersion != 1 {
|
||||||
|
result.Error = fmt.Sprintf("unsupported manifest schema: %d", manifest.SchemaVersion)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
target := normalizeVersion(manifest.Channels["stable"])
|
||||||
|
if target == "" {
|
||||||
|
result.Error = "missing stable channel version"
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
result.Target = target
|
||||||
|
|
||||||
|
if compareVersions(currentVersion, target) == 0 {
|
||||||
|
result.Status = "noop"
|
||||||
|
result.Error = ""
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if compareVersions(target, normalizeVersion(manifest.MinSupported)) < 0 {
|
||||||
|
result.Error = "target below min_supported"
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
artifact, ok := manifest.Artifacts[target]
|
||||||
|
if !ok {
|
||||||
|
result.Error = "missing artifacts for target version"
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
binPath := artifact.LinuxAMD64.Binary
|
||||||
|
shaPath := artifact.LinuxAMD64.SHA256
|
||||||
|
if binPath == "" || shaPath == "" {
|
||||||
|
result.Error = "artifact paths missing"
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ensureCurrentSymlinks(currentVersion); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("prepare current symlink: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(releasesDir, target), 0o755); err != nil {
|
||||||
|
result.Error = err.Error()
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpPath := filepath.Join(releasesDir, target, "zlh-agent.new")
|
||||||
|
finalPath := filepath.Join(releasesDir, target, "zlh-agent")
|
||||||
|
|
||||||
|
binURL := artifactBaseURL + binPath
|
||||||
|
shaURL := artifactBaseURL + shaPath
|
||||||
|
|
||||||
|
if err := downloadFile(binURL, tmpPath); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("download binary: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
expected, err := downloadSHA256(shaURL)
|
||||||
|
if err != nil {
|
||||||
|
result.Error = fmt.Sprintf("download sha256: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := verifySHA256(tmpPath, expected); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("sha256 verify failed: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(tmpPath, 0o755); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("chmod: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, finalPath); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("install: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := updateSymlinks(target); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("update symlinks: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(binaryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
result.Error = fmt.Sprintf("remove binary path: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(currentLink, "zlh-agent"), binaryPath); err != nil {
|
||||||
|
result.Error = fmt.Sprintf("update current symlink: %v", err)
|
||||||
|
writeStatus(result)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Status = "updated"
|
||||||
|
result.Error = ""
|
||||||
|
writeStatus(result)
|
||||||
|
go func() {
|
||||||
|
if err := restartService(); err != nil {
|
||||||
|
log.Printf("[update] restart failed: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchManifest() (Manifest, error) {
|
||||||
|
url := artifactBaseURL + "manifest.json"
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return Manifest{}, fmt.Errorf("manifest status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
var m Manifest
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&m); err != nil {
|
||||||
|
return Manifest{}, err
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadFile(url, dest string) error {
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
f, err := os.Create(dest)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(f, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadSHA256(url string) (string, error) {
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
b, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
fields := strings.Fields(string(b))
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", errors.New("empty sha256 file")
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(fields[0]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifySHA256(path, expected string) error {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, f); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum := hex.EncodeToString(h.Sum(nil))
|
||||||
|
if !strings.EqualFold(sum, expected) {
|
||||||
|
return fmt.Errorf("checksum mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func restartService() error {
|
||||||
|
unit := os.Getenv("ZLH_AGENT_UNIT")
|
||||||
|
if unit == "" {
|
||||||
|
unit = defaultUnit
|
||||||
|
}
|
||||||
|
cmd := exec.Command("systemctl", "restart", unit)
|
||||||
|
return cmd.Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeVersion(v string) string {
|
||||||
|
return strings.TrimPrefix(strings.TrimSpace(v), "v")
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareVersions(a, b string) int {
|
||||||
|
as := strings.Split(a, ".")
|
||||||
|
bs := strings.Split(b, ".")
|
||||||
|
for len(as) < 3 {
|
||||||
|
as = append(as, "0")
|
||||||
|
}
|
||||||
|
for len(bs) < 3 {
|
||||||
|
bs = append(bs, "0")
|
||||||
|
}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
ai := parseInt(as[i])
|
||||||
|
bi := parseInt(bs[i])
|
||||||
|
if ai < bi {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if ai > bi {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string) int {
|
||||||
|
n := 0
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
n = n*10 + int(r-'0')
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeStatus(res Result) {
|
||||||
|
_ = os.MkdirAll(stateDir, 0o755)
|
||||||
|
b, _ := json.MarshalIndent(res, "", " ")
|
||||||
|
_ = os.WriteFile(statusFile, b, 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadStatus() Result {
|
||||||
|
b, err := os.ReadFile(statusFile)
|
||||||
|
if err != nil {
|
||||||
|
return Result{}
|
||||||
|
}
|
||||||
|
var res Result
|
||||||
|
_ = json.Unmarshal(b, &res)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
|
func acquireLock(path string) error {
|
||||||
|
_ = os.MkdirAll(stateDir, 0o755)
|
||||||
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update already in progress")
|
||||||
|
}
|
||||||
|
_ = f.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func releaseLock(path string) {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureCurrentSymlinks(currentVersion string) error {
|
||||||
|
if _, err := os.Lstat(currentLink); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(releasesDir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentRelease := filepath.Join(releasesDir, currentVersion)
|
||||||
|
if err := os.MkdirAll(currentRelease, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBinary := filepath.Join(currentRelease, "zlh-agent")
|
||||||
|
if _, err := os.Stat(currentBinary); errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err := copyFile(binaryPath, currentBinary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(currentBinary, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Symlink(filepath.Join("releases", currentVersion), currentLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(binaryPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(currentLink, "zlh-agent"), binaryPath); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSymlinks(target string) error {
|
||||||
|
if err := os.RemoveAll(previousLink); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := os.Lstat(currentLink); err == nil {
|
||||||
|
if err := os.Symlink("current", previousLink); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.RemoveAll(currentLink); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Symlink(filepath.Join("releases", target), currentLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer out.Close()
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ package util
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
@ -21,6 +22,7 @@ func InitLogFile(path string) error {
|
|||||||
}
|
}
|
||||||
logFile = f
|
logFile = f
|
||||||
logReady = true
|
logReady = true
|
||||||
|
log.SetOutput(io.MultiWriter(os.Stdout, logFile))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
3
internal/version/version.go
Normal file
3
internal/version/version.go
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
const AgentVersion = "v1.0.0"
|
||||||
11
main.go
11
main.go
@ -10,14 +10,14 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
agenthttp "zlh-agent/internal/http"
|
agenthttp "zlh-agent/internal/http"
|
||||||
"zlh-agent/internal/system" // <-- ADD THIS
|
"zlh-agent/internal/system"
|
||||||
|
"zlh-agent/internal/update"
|
||||||
"zlh-agent/internal/util"
|
"zlh-agent/internal/util"
|
||||||
|
"zlh-agent/internal/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
const AgentVersion = "v1.0.0" // Consolidated agent version tag
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Printf("[agent] starting ZeroLagHub Agent %s", AgentVersion)
|
log.Printf("[agent] starting ZeroLagHub Agent %s", version.AgentVersion)
|
||||||
|
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
// Optional: enable log file (safe if path doesn't exist yet)
|
// Optional: enable log file (safe if path doesn't exist yet)
|
||||||
@ -46,7 +46,8 @@ func main() {
|
|||||||
// Enable autostart subsystem
|
// Enable autostart subsystem
|
||||||
// (does nothing unless AutoStartEnabled=true)
|
// (does nothing unless AutoStartEnabled=true)
|
||||||
// ------------------------------------------------------------
|
// ------------------------------------------------------------
|
||||||
system.InitAutoStart() // <-- ADD THIS
|
system.InitAutoStart()
|
||||||
|
update.StartPeriodic(version.AgentVersion)
|
||||||
|
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
@ -1,20 +1,46 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Required env (installer contract)
|
||||||
|
############################################
|
||||||
|
|
||||||
: "${RUNTIME:?RUNTIME required}"
|
: "${RUNTIME:?RUNTIME required}"
|
||||||
: "${RUNTIME_VERSION:?RUNTIME_VERSION required}"
|
: "${RUNTIME_VERSION:?RUNTIME_VERSION required}"
|
||||||
: "${ARCHIVE_EXT:?ARCHIVE_EXT required}"
|
: "${ARCHIVE_EXT:?ARCHIVE_EXT required}"
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Optional env
|
||||||
|
############################################
|
||||||
|
|
||||||
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
|
ZLH_ARTIFACT_BASE_URL="${ZLH_ARTIFACT_BASE_URL:-http://10.60.0.251:8080}"
|
||||||
ZLH_RUNTIME_ROOT="${ZLH_RUNTIME_ROOT:-/opt/zlh/runtime}"
|
ZLH_RUNTIME_ROOT="${ZLH_RUNTIME_ROOT:-/opt/zlh/runtime}"
|
||||||
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-${RUNTIME}}"
|
ARCHIVE_PREFIX="${ARCHIVE_PREFIX:-${RUNTIME}}"
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Derived paths
|
||||||
|
############################################
|
||||||
|
|
||||||
RUNTIME_ROOT="${ZLH_RUNTIME_ROOT}/${RUNTIME}"
|
RUNTIME_ROOT="${ZLH_RUNTIME_ROOT}/${RUNTIME}"
|
||||||
DEST_DIR="${RUNTIME_ROOT}/${RUNTIME_VERSION}"
|
DEST_DIR="${RUNTIME_ROOT}/${RUNTIME_VERSION}"
|
||||||
CURRENT_LINK="${RUNTIME_ROOT}/current"
|
CURRENT_LINK="${RUNTIME_ROOT}/current"
|
||||||
|
|
||||||
log() { echo "[zlh-installer:${RUNTIME}] $*"; }
|
############################################
|
||||||
fail() { echo "[zlh-installer:${RUNTIME}] ERROR: $*" >&2; exit 1; }
|
# Logging helpers
|
||||||
|
############################################
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[zlh-installer:${RUNTIME}] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[zlh-installer:${RUNTIME}] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Artifact helpers
|
||||||
|
############################################
|
||||||
|
|
||||||
artifact_name() {
|
artifact_name() {
|
||||||
echo "${ARCHIVE_PREFIX}-${RUNTIME_VERSION}.${ARCHIVE_EXT}"
|
echo "${ARCHIVE_PREFIX}-${RUNTIME_VERSION}.${ARCHIVE_EXT}"
|
||||||
@ -24,6 +50,10 @@ artifact_url() {
|
|||||||
echo "${ZLH_ARTIFACT_BASE_URL%/}/devcontainer/${RUNTIME}/${RUNTIME_VERSION}/$(artifact_name)"
|
echo "${ZLH_ARTIFACT_BASE_URL%/}/devcontainer/${RUNTIME}/${RUNTIME_VERSION}/$(artifact_name)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Download / extract
|
||||||
|
############################################
|
||||||
|
|
||||||
download_artifact() {
|
download_artifact() {
|
||||||
local url out
|
local url out
|
||||||
url="$(artifact_url)"
|
url="$(artifact_url)"
|
||||||
@ -56,6 +86,10 @@ extract_artifact() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Runtime wiring
|
||||||
|
############################################
|
||||||
|
|
||||||
update_symlinks() {
|
update_symlinks() {
|
||||||
ln -sfn "${DEST_DIR}" "${CURRENT_LINK}"
|
ln -sfn "${DEST_DIR}" "${CURRENT_LINK}"
|
||||||
ln -sfn "${CURRENT_LINK}/bin" "${RUNTIME_ROOT}/bin"
|
ln -sfn "${CURRENT_LINK}/bin" "${RUNTIME_ROOT}/bin"
|
||||||
@ -68,6 +102,35 @@ EOF
|
|||||||
chmod +x /etc/profile.d/zlh-${RUNTIME}.sh
|
chmod +x /etc/profile.d/zlh-${RUNTIME}.sh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# SSH host key initialization
|
||||||
|
############################################
|
||||||
|
|
||||||
|
ensure_ssh_host_keys() {
|
||||||
|
# SSH may not be installed in all templates — do not fail
|
||||||
|
if ! command -v ssh-keygen >/dev/null 2>&1; then
|
||||||
|
log "ssh-keygen not present, skipping SSH host key setup"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ls /etc/ssh/ssh_host_*_key >/dev/null 2>&1; then
|
||||||
|
log "SSH host keys already exist"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Generating SSH host keys"
|
||||||
|
ssh-keygen -A
|
||||||
|
|
||||||
|
# Best-effort service restart (container init systems vary)
|
||||||
|
systemctl enable ssh >/dev/null 2>&1 || true
|
||||||
|
systemctl restart ssh >/dev/null 2>&1 || \
|
||||||
|
systemctl restart sshd >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
############################################
|
||||||
|
# Entry point
|
||||||
|
############################################
|
||||||
|
|
||||||
install_runtime() {
|
install_runtime() {
|
||||||
log "Installing ${RUNTIME} ${RUNTIME_VERSION}"
|
log "Installing ${RUNTIME} ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
@ -84,5 +147,7 @@ install_runtime() {
|
|||||||
write_profile
|
write_profile
|
||||||
chmod -R 755 "${DEST_DIR}"
|
chmod -R 755 "${DEST_DIR}"
|
||||||
|
|
||||||
|
ensure_ssh_host_keys
|
||||||
|
|
||||||
log "Install complete"
|
log "Install complete"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user