mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
d4d652b455
- add shared netbind planning for strict tcp4/tcp6 bind semantics - support launcher/gateway host env overrides and launcher-to-gateway forwarding - cover host binding and forwarding with network and subprocess env tests
547 lines
16 KiB
Go
547 lines
16 KiB
Go
// PicoClaw Web Console - Web-based chat and management interface
|
|
//
|
|
// Provides a web UI for chatting with PicoClaw via the Pico Channel WebSocket,
|
|
// with configuration management and gateway process control.
|
|
//
|
|
// Usage:
|
|
//
|
|
// go build -o picoclaw-web ./web/backend/
|
|
// ./picoclaw-web [config.json]
|
|
// ./picoclaw-web -public config.json
|
|
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
|
"github.com/sipeed/picoclaw/pkg/netbind"
|
|
"github.com/sipeed/picoclaw/web/backend/api"
|
|
"github.com/sipeed/picoclaw/web/backend/dashboardauth"
|
|
"github.com/sipeed/picoclaw/web/backend/launcherconfig"
|
|
"github.com/sipeed/picoclaw/web/backend/middleware"
|
|
"github.com/sipeed/picoclaw/web/backend/utils"
|
|
)
|
|
|
|
const (
|
|
appName = "PicoClaw"
|
|
|
|
logPath = "logs"
|
|
panicFile = "launcher_panic.log"
|
|
logFile = "launcher.log"
|
|
)
|
|
|
|
var (
|
|
appVersion = config.Version
|
|
|
|
servers []*http.Server
|
|
serverAddr string
|
|
// browserLaunchURL is opened by openBrowser() (auto-open + tray "open console").
|
|
// Includes ?token= for same-machine dashboard login; keep serverAddr without secrets for other use.
|
|
browserLaunchURL string
|
|
apiHandler *api.Handler
|
|
|
|
noBrowser *bool
|
|
)
|
|
|
|
func shouldEnableLauncherFileLogging(enableConsole, debug bool) bool {
|
|
return !enableConsole || debug
|
|
}
|
|
|
|
func dashboardTokenConfigHelpPath(source launcherconfig.DashboardTokenSource, launcherPath string) string {
|
|
if source != launcherconfig.DashboardTokenSourceConfig {
|
|
return ""
|
|
}
|
|
return launcherPath
|
|
}
|
|
|
|
func resolveLauncherHostInput(flagHost string, explicitFlag bool, envHost string) (string, bool, error) {
|
|
if explicitFlag {
|
|
normalized, err := netbind.NormalizeHostInput(flagHost)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return normalized, true, nil
|
|
}
|
|
|
|
envHost = strings.TrimSpace(envHost)
|
|
if envHost == "" {
|
|
return "", false, nil
|
|
}
|
|
|
|
normalized, err := netbind.NormalizeHostInput(envHost)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
return normalized, true, nil
|
|
}
|
|
|
|
func openLauncherListeners(hostInput string, public bool, port string) (netbind.OpenResult, error) {
|
|
defaultMode := netbind.DefaultLoopback
|
|
if strings.TrimSpace(hostInput) == "" && public {
|
|
defaultMode = netbind.DefaultAny
|
|
}
|
|
|
|
plan, err := netbind.BuildPlan(hostInput, defaultMode)
|
|
if err != nil {
|
|
return netbind.OpenResult{}, err
|
|
}
|
|
return netbind.OpenPlan(plan, port)
|
|
}
|
|
|
|
func appendUniqueHost(hosts []string, seen map[string]struct{}, host string) []string {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return hosts
|
|
}
|
|
key := strings.ToLower(host)
|
|
if _, ok := seen[key]; ok {
|
|
return hosts
|
|
}
|
|
seen[key] = struct{}{}
|
|
return append(hosts, host)
|
|
}
|
|
|
|
func hasWildcardBindHosts(bindHosts []string) bool {
|
|
for _, bindHost := range bindHosts {
|
|
if netbind.IsUnspecifiedHost(bindHost) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func wildcardAdvertiseIP(bindHosts []string, ipv4, ipv6 string) string {
|
|
if !hasWildcardBindHosts(bindHosts) {
|
|
return ""
|
|
}
|
|
|
|
if v6 := strings.TrimSpace(ipv6); v6 != "" {
|
|
return v6
|
|
}
|
|
return strings.TrimSpace(ipv4)
|
|
}
|
|
|
|
func advertiseIPForWildcardBindHosts(bindHosts []string) string {
|
|
return wildcardAdvertiseIP(bindHosts, utils.GetLocalIPv4(), utils.GetLocalIPv6())
|
|
}
|
|
|
|
func launcherConsoleHosts(bindHosts []string, probeHost string) []string {
|
|
hosts := make([]string, 0, 6)
|
|
seen := make(map[string]struct{}, 6)
|
|
|
|
hosts = appendUniqueHost(hosts, seen, probeHost)
|
|
|
|
for _, bindHost := range bindHosts {
|
|
switch {
|
|
case netbind.IsUnspecifiedHost(bindHost):
|
|
if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil && ip.To4() != nil {
|
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
|
} else {
|
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
|
}
|
|
case netbind.IsLoopbackHost(bindHost):
|
|
hosts = appendUniqueHost(hosts, seen, "localhost")
|
|
if ip := net.ParseIP(strings.Trim(bindHost, "[]")); ip != nil {
|
|
if ip.To4() != nil {
|
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
|
} else {
|
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
|
}
|
|
}
|
|
default:
|
|
hosts = appendUniqueHost(hosts, seen, bindHost)
|
|
}
|
|
}
|
|
|
|
if hasWildcardBindHosts(bindHosts) {
|
|
hosts = appendUniqueHost(hosts, seen, "localhost")
|
|
hosts = appendUniqueHost(hosts, seen, "::1")
|
|
hosts = appendUniqueHost(hosts, seen, "127.0.0.1")
|
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv6())
|
|
hosts = appendUniqueHost(hosts, seen, utils.GetLocalIPv4())
|
|
}
|
|
|
|
return hosts
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// maskSecret masks a secret for display. It always shows up to the first 3
|
|
// runes. The last 4 runes are only appended when at least 5 runes remain
|
|
// hidden in the middle (i.e. string length >= 12), so an 8-char minimum
|
|
// password never exposes its tail. Strings of 3 chars or fewer are fully
|
|
// masked.
|
|
func maskSecret(s string) string {
|
|
runes := []rune(s)
|
|
n := len(runes)
|
|
const prefixLen, suffixLen, minHidden = 3, 4, 5
|
|
if n < prefixLen+suffixLen+minHidden {
|
|
if n <= prefixLen {
|
|
return "**********"
|
|
}
|
|
return string(runes[:prefixLen]) + "**********"
|
|
}
|
|
return string(runes[:prefixLen]) + "**********" + string(runes[n-suffixLen:])
|
|
}
|
|
|
|
func main() {
|
|
port := flag.String("port", "18800", "Port to listen on")
|
|
host := flag.String("host", "", "Host to listen on (overrides -public when set)")
|
|
public := flag.Bool("public", false, "Listen on all interfaces (dual-stack) instead of localhost only")
|
|
noBrowser = flag.Bool("no-browser", false, "Do not auto-open browser on startup")
|
|
lang := flag.String("lang", "", "Language: en (English) or zh (Chinese). Default: auto-detect from system locale")
|
|
console := flag.Bool("console", false, "Console mode, no GUI")
|
|
|
|
var debug bool
|
|
flag.BoolVar(&debug, "d", false, "Enable debug logging")
|
|
flag.BoolVar(&debug, "debug", false, "Enable debug logging")
|
|
|
|
flag.Usage = func() {
|
|
fmt.Fprintf(os.Stderr, "%s Launcher - Web console and gateway manager\n\n", appName)
|
|
fmt.Fprintf(os.Stderr, "Usage: %s [options] [config.json]\n\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, "Arguments:\n")
|
|
fmt.Fprintf(os.Stderr, " config.json Path to the configuration file (default: ~/.picoclaw/config.json)\n\n")
|
|
fmt.Fprintf(os.Stderr, "Options:\n")
|
|
flag.PrintDefaults()
|
|
fmt.Fprintf(os.Stderr, "\nExamples:\n")
|
|
fmt.Fprintf(os.Stderr, " %s\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " Use default config path in GUI mode\n")
|
|
fmt.Fprintf(os.Stderr, " %s ./config.json\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " Specify a config file\n")
|
|
fmt.Fprintf(
|
|
os.Stderr,
|
|
" %s -public ./config.json\n",
|
|
os.Args[0],
|
|
)
|
|
fmt.Fprintf(os.Stderr, " Allow access from other devices on the local network\n")
|
|
fmt.Fprintf(os.Stderr, " %s -host :: ./config.json\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " Bind launcher host explicitly with exact host semantics\n")
|
|
fmt.Fprintf(os.Stderr, " %s -console -d ./config.json\n", os.Args[0])
|
|
fmt.Fprintf(os.Stderr, " Run in the terminal with debug logs enabled\n")
|
|
}
|
|
flag.Parse()
|
|
|
|
// Initialize logger
|
|
picoHome := utils.GetPicoclawHome()
|
|
|
|
f := filepath.Join(picoHome, logPath, panicFile)
|
|
panicFunc, err := logger.InitPanic(f)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("error initializing panic log: %v", err))
|
|
}
|
|
defer panicFunc()
|
|
|
|
enableConsole := *console
|
|
fileLoggingEnabled := shouldEnableLauncherFileLogging(enableConsole, debug)
|
|
if fileLoggingEnabled {
|
|
// GUI mode writes launcher logs to file. Debug mode keeps file logging enabled in console mode too.
|
|
if !debug {
|
|
logger.DisableConsole()
|
|
}
|
|
|
|
f := filepath.Join(picoHome, logPath, logFile)
|
|
if err = logger.EnableFileLogging(f); err != nil {
|
|
panic(fmt.Sprintf("error enabling file logging: %v", err))
|
|
}
|
|
defer logger.DisableFileLogging()
|
|
}
|
|
if debug {
|
|
logger.SetLevel(logger.DEBUG)
|
|
}
|
|
|
|
// Set language from command line or auto-detect
|
|
if *lang != "" {
|
|
SetLanguage(*lang)
|
|
}
|
|
|
|
// Resolve config path
|
|
configPath := utils.GetDefaultConfigPath()
|
|
if flag.NArg() > 0 {
|
|
configPath = flag.Arg(0)
|
|
}
|
|
|
|
absPath, err := filepath.Abs(configPath)
|
|
if err != nil {
|
|
logger.Fatalf("Failed to resolve config path: %v", err)
|
|
}
|
|
err = utils.EnsureOnboarded(absPath)
|
|
if err != nil {
|
|
logger.Errorf("Warning: Failed to initialize %s config automatically: %v", appName, err)
|
|
}
|
|
if !debug {
|
|
logger.SetLevelFromString(config.ResolveGatewayLogLevel(absPath))
|
|
}
|
|
|
|
logger.InfoC("web", fmt.Sprintf("%s launcher starting (version %s)...", appName, appVersion))
|
|
logger.InfoC("web", fmt.Sprintf("%s Home: %s", appName, picoHome))
|
|
if debug {
|
|
logger.InfoC("web", "Debug mode enabled")
|
|
logger.DebugC(
|
|
"web",
|
|
fmt.Sprintf(
|
|
"Launcher flags: console=%t host=%q public=%t no_browser=%t config=%s",
|
|
enableConsole,
|
|
*host,
|
|
*public,
|
|
*noBrowser,
|
|
absPath,
|
|
),
|
|
)
|
|
}
|
|
|
|
var explicitPort bool
|
|
var explicitPublic bool
|
|
var explicitHost bool
|
|
flag.Visit(func(f *flag.Flag) {
|
|
switch f.Name {
|
|
case "port":
|
|
explicitPort = true
|
|
case "host":
|
|
explicitHost = true
|
|
case "public":
|
|
explicitPublic = true
|
|
}
|
|
})
|
|
|
|
launcherPath := launcherconfig.PathForAppConfig(absPath)
|
|
launcherCfg, err := launcherconfig.Load(launcherPath, launcherconfig.Default())
|
|
if err != nil {
|
|
logger.ErrorC("web", fmt.Sprintf("Warning: Failed to load %s: %v", launcherPath, err))
|
|
launcherCfg = launcherconfig.Default()
|
|
}
|
|
|
|
effectivePort := *port
|
|
effectivePublic := *public
|
|
if !explicitPort {
|
|
effectivePort = strconv.Itoa(launcherCfg.Port)
|
|
}
|
|
if !explicitPublic {
|
|
effectivePublic = launcherCfg.Public
|
|
}
|
|
envHost := strings.TrimSpace(os.Getenv(launcherconfig.EnvLauncherHost))
|
|
|
|
hostInput, hostOverrideActive, err := resolveLauncherHostInput(*host, explicitHost, envHost)
|
|
if err != nil {
|
|
logger.Fatalf("Invalid host %q: %v", firstNonEmpty(strings.TrimSpace(*host), envHost), err)
|
|
}
|
|
if hostOverrideActive {
|
|
effectivePublic = false
|
|
}
|
|
|
|
if !explicitHost && hostOverrideActive {
|
|
logger.InfoC("web", "Using launcher host from environment PICOCLAW_LAUNCHER_HOST")
|
|
}
|
|
|
|
if hostOverrideActive && explicitPublic {
|
|
logger.InfoC("web", "Ignoring -public because launcher host was explicitly set")
|
|
}
|
|
|
|
portNum, err := strconv.Atoi(effectivePort)
|
|
if err != nil || portNum < 1 || portNum > 65535 {
|
|
if err == nil {
|
|
err = errors.New("must be in range 1-65535")
|
|
}
|
|
logger.Fatalf("Invalid port %q: %v", effectivePort, err)
|
|
}
|
|
|
|
openResult, err := openLauncherListeners(hostInput, effectivePublic, effectivePort)
|
|
if err != nil {
|
|
logger.Fatalf("Failed to open launcher listener(s): %v", err)
|
|
}
|
|
listeners := openResult.Listeners
|
|
|
|
dashboardToken, dashboardSigningKey, dashboardTokenSource, dashErr := launcherconfig.EnsureDashboardSecrets(
|
|
launcherCfg,
|
|
)
|
|
if dashErr != nil {
|
|
logger.Fatalf("Dashboard auth setup failed: %v", dashErr)
|
|
}
|
|
dashboardSessionCookie := middleware.SessionCookieValue(dashboardSigningKey, dashboardToken)
|
|
|
|
// Open the bcrypt password store (creates the DB file on first run).
|
|
authStore, authStoreErr := dashboardauth.New(picoHome)
|
|
var passwordStore api.PasswordStore
|
|
if authStoreErr == nil {
|
|
passwordStore = authStore
|
|
defer authStore.Close()
|
|
} else if errors.Is(authStoreErr, dashboardauth.ErrUnsupportedPlatform) {
|
|
logger.InfoC(
|
|
"web",
|
|
fmt.Sprintf(
|
|
"Dashboard password store unavailable on this platform; falling back to token login: %v",
|
|
authStoreErr,
|
|
),
|
|
)
|
|
authStoreErr = nil
|
|
} else {
|
|
logger.ErrorC("web", fmt.Sprintf("Warning: could not open auth store: %v", authStoreErr))
|
|
}
|
|
|
|
// Initialize Server components
|
|
mux := http.NewServeMux()
|
|
|
|
api.RegisterLauncherAuthRoutes(mux, api.LauncherAuthRouteOpts{
|
|
DashboardToken: dashboardToken,
|
|
SessionCookie: dashboardSessionCookie,
|
|
PasswordStore: passwordStore,
|
|
StoreError: authStoreErr,
|
|
})
|
|
|
|
// API Routes (e.g. /api/status)
|
|
apiHandler = api.NewHandler(absPath)
|
|
apiHandler.SetDebug(debug)
|
|
if _, err = apiHandler.EnsurePicoChannel(""); err != nil {
|
|
logger.ErrorC("web", fmt.Sprintf("Warning: failed to ensure pico channel on startup: %v", err))
|
|
}
|
|
apiHandler.SetServerOptions(portNum, effectivePublic, explicitPublic, launcherCfg.AllowedCIDRs)
|
|
apiHandler.SetServerBindHost(hostInput, hostOverrideActive)
|
|
apiHandler.RegisterRoutes(mux)
|
|
|
|
// Frontend Embedded Assets
|
|
registerEmbedRoutes(mux)
|
|
|
|
accessControlledMux, err := middleware.IPAllowlist(launcherCfg.AllowedCIDRs, mux)
|
|
if err != nil {
|
|
logger.Fatalf("Invalid allowed CIDR configuration: %v", err)
|
|
}
|
|
|
|
dashAuth := middleware.LauncherDashboardAuth(middleware.LauncherDashboardAuthConfig{
|
|
ExpectedCookie: dashboardSessionCookie,
|
|
Token: dashboardToken,
|
|
}, accessControlledMux)
|
|
|
|
// Apply middleware stack
|
|
handler := middleware.Recoverer(
|
|
middleware.Logger(
|
|
middleware.ReferrerPolicyNoReferrer(
|
|
middleware.JSONContentType(dashAuth),
|
|
),
|
|
),
|
|
)
|
|
|
|
// Print startup banner and token (console mode only).
|
|
if enableConsole || debug {
|
|
consoleHosts := launcherConsoleHosts(openResult.BindHosts, openResult.ProbeHost)
|
|
|
|
fmt.Print(utils.Banner)
|
|
fmt.Println()
|
|
fmt.Println(" Open the following URL in your browser:")
|
|
fmt.Println()
|
|
for _, host := range consoleHosts {
|
|
fmt.Printf(" >> http://%s <<\n", net.JoinHostPort(host, effectivePort))
|
|
}
|
|
fmt.Println()
|
|
switch dashboardTokenSource {
|
|
case launcherconfig.DashboardTokenSourceRandom:
|
|
fmt.Printf(" Dashboard password (this run): %s\n", maskSecret(dashboardToken))
|
|
case launcherconfig.DashboardTokenSourceEnv:
|
|
fmt.Printf(" Dashboard password: from environment variable PICOCLAW_LAUNCHER_TOKEN\n")
|
|
case launcherconfig.DashboardTokenSourceConfig:
|
|
fmt.Printf(" Dashboard password: configured in %s\n", launcherPath)
|
|
}
|
|
fmt.Println()
|
|
}
|
|
|
|
switch dashboardTokenSource {
|
|
case launcherconfig.DashboardTokenSourceEnv:
|
|
logger.InfoC("web", "Dashboard password: environment PICOCLAW_LAUNCHER_TOKEN")
|
|
case launcherconfig.DashboardTokenSourceConfig:
|
|
logger.InfoC("web", fmt.Sprintf("Dashboard password: configured in %s", launcherPath))
|
|
case launcherconfig.DashboardTokenSourceRandom:
|
|
if !enableConsole {
|
|
logger.InfoC("web", "Dashboard password (this run): "+maskSecret(dashboardToken))
|
|
}
|
|
}
|
|
|
|
// Log startup info to file
|
|
for _, ln := range listeners {
|
|
logger.InfoC("web", fmt.Sprintf("Server will listen on http://%s", ln.Addr().String()))
|
|
}
|
|
if hasWildcardBindHosts(openResult.BindHosts) {
|
|
if ip := advertiseIPForWildcardBindHosts(openResult.BindHosts); ip != "" {
|
|
logger.InfoC("web", fmt.Sprintf("Public access enabled at http://%s", net.JoinHostPort(ip, effectivePort)))
|
|
}
|
|
}
|
|
|
|
// Share the local URL with the launcher runtime.
|
|
serverAddr = fmt.Sprintf("http://%s", net.JoinHostPort(openResult.ProbeHost, effectivePort))
|
|
if dashboardToken != "" {
|
|
browserLaunchURL = serverAddr + "?token=" + url.QueryEscape(dashboardToken)
|
|
} else {
|
|
browserLaunchURL = serverAddr
|
|
}
|
|
|
|
// Auto-open browser will be handled by the launcher runtime.
|
|
|
|
// Auto-start gateway after backend starts listening.
|
|
go func() {
|
|
time.Sleep(1 * time.Second)
|
|
apiHandler.TryAutoStartGateway()
|
|
}()
|
|
|
|
// Start the server(s) in goroutines.
|
|
servers = make([]*http.Server, 0, len(listeners))
|
|
for _, ln := range listeners {
|
|
srv := &http.Server{Handler: handler}
|
|
servers = append(servers, srv)
|
|
|
|
go func(s *http.Server, l net.Listener) {
|
|
logger.InfoC("web", fmt.Sprintf("Server listening on %s", l.Addr().String()))
|
|
if serveErr := s.Serve(l); serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
|
logger.Fatalf("Server failed to start on %s: %v", l.Addr().String(), serveErr)
|
|
}
|
|
}(srv, ln)
|
|
}
|
|
|
|
defer shutdownApp()
|
|
|
|
// Start system tray or run in console mode
|
|
if enableConsole {
|
|
if !*noBrowser {
|
|
// Auto-open browser after systray is ready (if not disabled)
|
|
// Check no-browser flag via environment or pass as parameter if needed
|
|
if err := openBrowser(); err != nil {
|
|
logger.Errorf("Warning: Failed to auto-open browser: %v", err)
|
|
}
|
|
}
|
|
|
|
sigChan := make(chan os.Signal, 1)
|
|
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
|
|
|
// Main event loop - wait for signals or config changes
|
|
for {
|
|
select {
|
|
case <-sigChan:
|
|
logger.Info("Shutting down...")
|
|
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
// GUI mode: start system tray
|
|
runTray()
|
|
}
|
|
}
|