diff --git a/.npmrc b/.npmrc index a4994ac..0ef8e44 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1 @@ side-effects-cache = false -public-hoist-pattern[]=*hono* diff --git a/Dockerfile b/Dockerfile index 7797a43..efcfcdd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,15 @@ +FROM golang:1.24 AS agent-build +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY agent/ ./agent +RUN CGO_ENABLED=0 GOOS=linux go build \ + -trimpath \ + -ldflags "-s -w" \ + -o /app/hp_agent ./agent/cmd/hp_agent + FROM node:22-alpine AS build WORKDIR /app @@ -11,8 +23,12 @@ COPY . . RUN pnpm run build FROM node:22-alpine +RUN apk add --no-cache ca-certificates RUN mkdir -p /var/lib/headplane +RUN mkdir -p /usr/libexec/headplane WORKDIR /app COPY --from=build /app/build /app/build +COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent +RUN chmod +x /usr/libexec/headplane/agent CMD [ "node", "./build/server/index.js" ] diff --git a/agent/cmd/hp_agent/hp_agent.go b/agent/cmd/hp_agent/hp_agent.go index 61ffc08..8212059 100644 --- a/agent/cmd/hp_agent/hp_agent.go +++ b/agent/cmd/hp_agent/hp_agent.go @@ -8,6 +8,11 @@ import ( "github.com/tale/headplane/agent/internal/util" ) +type Register struct { + Type string + ID string +} + func main() { log := util.GetLogger() cfg, err := config.Load() @@ -21,11 +26,10 @@ func main() { agent.Connect() defer agent.Shutdown() - ws, err := hpagent.NewSocket(agent, cfg) - if err != nil { - log.Fatal("Failed to create websocket: %s", err) - } + log.Msg(&Register{ + Type: "register", + ID: agent.ID, + }) - defer ws.StopListening() - ws.FollowMaster() + hpagent.FollowMaster(agent) } diff --git a/agent/internal/config/config.go b/agent/internal/config/config.go index 64f14a3..d33c014 100644 --- a/agent/internal/config/config.go +++ b/agent/internal/config/config.go @@ -8,8 +8,7 @@ type Config struct { Hostname string TSControlURL string TSAuthKey string - HPControlURL string - HPAuthKey string + WorkDir string } const ( @@ -17,8 +16,7 @@ const ( HostnameEnv = "HEADPLANE_AGENT_HOSTNAME" TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER" TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY" - HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER" - HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY" + WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR" ) // Load reads the agent configuration from environment variables. @@ -28,8 +26,7 @@ func Load() (*Config, error) { Hostname: os.Getenv(HostnameEnv), TSControlURL: os.Getenv(TSControlURLEnv), TSAuthKey: os.Getenv(TSAuthKeyEnv), - HPControlURL: os.Getenv(HPControlURLEnv), - HPAuthKey: os.Getenv(HPAuthKeyEnv), + WorkDir: os.Getenv(WorkDirEnv), } if os.Getenv(DebugEnv) == "true" { @@ -44,9 +41,5 @@ func Load() (*Config, error) { return nil, err } - if err := validateHPReady(c); err != nil { - return nil, err - } - return c, nil } diff --git a/agent/internal/config/preflight.go b/agent/internal/config/preflight.go index 3f74958..9634d8c 100644 --- a/agent/internal/config/preflight.go +++ b/agent/internal/config/preflight.go @@ -16,16 +16,12 @@ func validateRequired(config *Config) error { return fmt.Errorf("%s is required", TSControlURLEnv) } - if config.HPControlURL == "" { - return fmt.Errorf("%s is required", HPControlURLEnv) - } - if config.TSAuthKey == "" { return fmt.Errorf("%s is required", TSAuthKeyEnv) } - if config.HPAuthKey == "" { - return fmt.Errorf("%s is required", HPAuthKeyEnv) + if config.WorkDir == "" { + return fmt.Errorf("%s is required", WorkDirEnv) } return nil @@ -50,23 +46,3 @@ func validateTSReady(config *Config) error { return nil } - -// Pings the Headplane server to make sure it's up and running -func validateHPReady(config *Config) error { - testURL := config.HPControlURL - if strings.HasSuffix(testURL, "/") { - testURL = testURL[:len(testURL)-1] - } - - testURL = fmt.Sprintf("%s/healthz", testURL) - resp, err := http.Get(testURL) - if err != nil { - return fmt.Errorf("Failed to connect to HP control server: %s", err) - } - - if resp.StatusCode != 200 { - return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status) - } - - return nil -} diff --git a/agent/internal/hpagent/handler.go b/agent/internal/hpagent/handler.go index 8523bf1..1228194 100644 --- a/agent/internal/hpagent/handler.go +++ b/agent/internal/hpagent/handler.go @@ -1,9 +1,12 @@ package hpagent import ( + "bufio" "encoding/json" + "os" "sync" + "github.com/tale/headplane/agent/internal/tsnet" "github.com/tale/headplane/agent/internal/util" "tailscale.com/tailcfg" ) @@ -13,30 +16,32 @@ type RecvMessage struct { NodeIDs []string } -// Starts listening for messages from the Headplane master -func (s *Socket) FollowMaster() { - log := util.GetLogger() +type SendMessage struct { + Type string + Data any +} - for { - _, message, err := s.ReadMessage() - if err != nil { - log.Error("Error reading message: %s", err) - return - } +// Starts listening for messages from stdin +func FollowMaster(agent *tsnet.TSAgent) { + log := util.GetLogger() + scanner := bufio.NewScanner(os.Stdin) + + for scanner.Scan() { + line := scanner.Bytes() var msg RecvMessage - err = json.Unmarshal(message, &msg) + err := json.Unmarshal(line, &msg) if err != nil { log.Error("Unable to unmarshal message: %s", err) log.Debug("Full Error: %v", err) continue } - log.Debug("Recieved message from master: %v", message) + log.Debug("Recieved message from master: %v", line) if len(msg.NodeIDs) == 0 { log.Debug("Message recieved had no node IDs") - log.Debug("Full message: %s", message) + log.Debug("Full message: %s", line) continue } @@ -49,7 +54,7 @@ func (s *Socket) FollowMaster() { wg.Add(1) go func(nodeID string) { defer wg.Done() - result, err := s.Agent.GetStatusForPeer(nodeID) + result, err := agent.GetStatusForPeer(nodeID) if err != nil { log.Error("Unable to get status for node %s: %s", nodeID, err) return @@ -70,15 +75,13 @@ func (s *Socket) FollowMaster() { // Send the results back to the Headplane master log.Debug("Sending status back to master: %v", results) - err = s.SendStatus(results) - if err != nil { - log.Error("Error sending status: %s", err) - return - } + log.Msg(&SendMessage{ + Type: "status", + Data: results, + }) + } + + if err := scanner.Err(); err != nil { + log.Fatal("Error reading from stdin: %s", err) } } - -// Stops listening for messages from the Headplane master -func (s *Socket) StopListening() { - s.Close() -} diff --git a/agent/internal/hpagent/sender.go b/agent/internal/hpagent/sender.go deleted file mode 100644 index 10b95eb..0000000 --- a/agent/internal/hpagent/sender.go +++ /dev/null @@ -1,11 +0,0 @@ -package hpagent - -import ( - "tailscale.com/tailcfg" -) - -// Sends the status to the Headplane master -func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error { - err := s.WriteJSON(status) - return err -} diff --git a/agent/internal/hpagent/websocket.go b/agent/internal/hpagent/websocket.go deleted file mode 100644 index faffe71..0000000 --- a/agent/internal/hpagent/websocket.go +++ /dev/null @@ -1,67 +0,0 @@ -package hpagent - -import ( - "fmt" - "net/http" - "net/url" - - "github.com/gorilla/websocket" - "github.com/tale/headplane/agent/internal/config" - "github.com/tale/headplane/agent/internal/tsnet" - "github.com/tale/headplane/agent/internal/util" -) - -type Socket struct { - *websocket.Conn - Agent *tsnet.TSAgent -} - -// Creates a new websocket connection to the Headplane server. -func NewSocket(agent *tsnet.TSAgent, cfg *config.Config) (*Socket, error) { - log := util.GetLogger() - - wsURL, err := httpToWs(cfg.HPControlURL) - if err != nil { - return nil, err - } - - headers := http.Header{} - headers.Add("X-Headplane-Tailnet-ID", agent.ID) - auth := fmt.Sprintf("Bearer %s", cfg.HPAuthKey) - headers.Add("Authorization", auth) - - log.Info("Dialing WebSocket with master: %s", wsURL) - ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers) - if err != nil { - log.Debug("Failed to dial WebSocket: %s", err) - return nil, err - } - - return &Socket{ws, agent}, nil -} - -// We need to convert the control URL to a websocket URL -func httpToWs(controlURL string) (string, error) { - log := util.GetLogger() - u, err := url.Parse(controlURL) - if err != nil { - log.Debug("Failed to parse control URL: %s", err) - return "", err - } - - if u.Scheme == "http" { - u.Scheme = "ws" - } else if u.Scheme == "https" { - u.Scheme = "wss" - } else { - return "", fmt.Errorf("unsupported scheme: %s", u.Scheme) - } - - // We also need to append /_dial to the path - if u.Path[len(u.Path)-1] != '/' { - u.Path += "/" - } - - u.Path += "_dial" - return u.String(), nil -} diff --git a/agent/internal/tsnet/server.go b/agent/internal/tsnet/server.go index f41c73a..eb4ffe8 100644 --- a/agent/internal/tsnet/server.go +++ b/agent/internal/tsnet/server.go @@ -2,6 +2,9 @@ package tsnet import ( "context" + "os" + "path/filepath" + "github.com/tale/headplane/agent/internal/config" "github.com/tale/headplane/agent/internal/util" "tailscale.com/client/tailscale" @@ -17,15 +20,27 @@ type TSAgent struct { // Creates a new tsnet agent and returns an instance of the server. func NewAgent(cfg *config.Config) *TSAgent { + log := util.GetLogger() + + dir, err := filepath.Abs(cfg.WorkDir) + if err != nil { + log.Fatal("Failed to get absolute path: %s", err) + } + + if err := os.MkdirAll(dir, 0700); err != nil { + log.Fatal("Cannot create agent work directory: %s", err) + } + server := &tsnet.Server{ + Dir: dir, Hostname: cfg.Hostname, ControlURL: cfg.TSControlURL, AuthKey: cfg.TSAuthKey, - Logf: func(string, ...interface{}) {}, // Disabled by default + Logf: func(string, ...any) {}, // Disabled by default + UserLogf: log.Info, } if cfg.Debug { - log := util.GetLogger() server.Logf = log.Debug } @@ -47,8 +62,13 @@ func (s *TSAgent) Connect() { log.Fatal("Failed to initialize local Tailscale client: %s", err) } + id, err := status.Self.PublicKey.MarshalText() + if err != nil { + log.Fatal("Failed to marshal public key: %s", err) + } + log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey) - s.ID = string(status.Self.ID) + s.ID = string(id) } // Shuts down the tsnet agent. diff --git a/agent/internal/util/logger.go b/agent/internal/util/logger.go index 070e061..213b452 100644 --- a/agent/internal/util/logger.go +++ b/agent/internal/util/logger.go @@ -1,66 +1,117 @@ package util import ( - "log" + "encoding/json" + "fmt" "os" + "strings" "sync" + "time" ) -type Logger struct { - debug *log.Logger - info *log.Logger - error *log.Logger +type LogLevel string + +const ( + LevelInfo LogLevel = "info" + LevelDebug LogLevel = "debug" + LevelError LogLevel = "error" + LevelFatal LogLevel = "fatal" + LevelMsg LogLevel = "msg" +) + +type LogMessage struct { + Level LogLevel + Time string + Message any } -var lock = &sync.Mutex{} -var logger *Logger +type Logger struct { + debugEnabled bool + encoder *json.Encoder + pool *sync.Pool +} + +var logger = NewLogger() func GetLogger() *Logger { - if logger == nil { - lock.Lock() - defer lock.Unlock() - if logger == nil { - logger = NewLogger() - } - } - return logger } func NewLogger() *Logger { - // Create a new Logger for stdout and stderr - // Errors still go to both stdout and stderr + enc := json.NewEncoder(os.Stdout) + enc.SetEscapeHTML(false) + return &Logger{ - debug: nil, - info: log.New(os.Stdout, "[INFO] ", log.LstdFlags), - error: log.New(os.Stderr, "[ERROR] ", log.LstdFlags), + encoder: enc, + pool: &sync.Pool{ + New: func() any { + return &LogMessage{} + }, + }, } } -func (logger *Logger) SetDebug(debug bool) { - if debug { - logger.Info("Enabling Debug logging for headplane-agent") - logger.Info("Be careful, this will spam a lot of information") - logger.debug = log.New(os.Stdout, "[DEBUG] ", log.LstdFlags) - } else { - logger.debug = nil +func (l *Logger) SetDebug(enabled bool) { + if enabled { + l.debugEnabled = true + l.Info("Enabling Debug logging for headplane-agent") + l.Info("Be careful, this will spam a lot of information") } } -func (logger *Logger) Info(fmt string, v ...any) { - logger.info.Printf(fmt, v...) -} +func (l *Logger) log(level LogLevel, format string, v ...any) { + msg := fmt.Sprintf(format, v...) + timestamp := time.Now().Format(time.RFC3339) -func (logger *Logger) Debug(fmt string, v ...any) { - if logger.debug != nil { - logger.debug.Printf(fmt, v...) + // Manually construct compact JSON line for performance + line := `{"Level":"` + string(level) + + `","Time":"` + timestamp + + `","Message":"` + escapeString(msg) + `"}` + "\n" + + if level == LevelError || level == LevelFatal { + os.Stderr.WriteString(line) + } + + // Always write to stdout but also write to stderr for errors + os.Stdout.WriteString(line) + if level == LevelFatal { + os.Exit(1) } } -func (logger *Logger) Error(fmt string, v ...any) { - logger.error.Printf(fmt, v...) +func (l *Logger) Debug(format string, v ...any) { + if l.debugEnabled { + l.log(LevelDebug, format, v...) + } } -func (logger *Logger) Fatal(fmt string, v ...any) { - logger.error.Fatalf(fmt, v...) +func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) } +func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) } +func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) } + +func (l *Logger) Msg(obj any) { + entry := l.pool.Get().(*LogMessage) + defer l.pool.Put(entry) + + entry.Level = LevelMsg + entry.Time = time.Now().Format(time.RFC3339) + entry.Message = obj + + // Because the encoder is tied to STDOUT we get a message + _ = l.encoder.Encode(entry) + + // Reset the entry for reuse + entry.Level = "" + entry.Time = "" + entry.Message = nil +} + +func escapeString(s string) string { + replacer := strings.NewReplacer( + `"`, `\"`, + `\`, `\\`, + "\n", `\n`, + "\t", `\t`, + ) + return replacer.Replace(s) } diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index 63b8923..e7a56b5 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -66,8 +66,10 @@ export async function loader({ magic, server: context.config.headscale.url, publicServer: context.config.headscale.public_url, - agents: context.agents?.tailnetIDs(), - stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)), + agent: context.agents?.agentID(), + stats: await context.agents?.lookup( + machines.nodes.map((node) => node.nodeKey), + ), writable: writablePermission, preAuth: await context.sessions.check( request, @@ -129,7 +131,7 @@ export default function Page() { {/* We only want to show the version column if there are agents */} - {data.agents !== undefined ? ( + {data.agent !== undefined ? ( Version ) : undefined} Last Seen @@ -152,7 +154,7 @@ export default function Page() { magic={data.magic} // If we pass undefined, the column will not be rendered // This is useful for when there are no agents configured - isAgent={data.agents?.includes(machine.id)} + isAgent={data.agent === machine.nodeKey} stats={data.stats?.[machine.nodeKey]} isDisabled={ data.writable diff --git a/app/server/config/schema.ts b/app/server/config/schema.ts index e45a687..85497f4 100644 --- a/app/server/config/schema.ts +++ b/app/server/config/schema.ts @@ -1,22 +1,26 @@ import { type } from 'arktype'; -const stringToBool = type('string | boolean').pipe((v) => Boolean(v)); +const stringToBool = type('string | boolean').pipe((v) => { + if (typeof v === 'string') { + if (v === '1' || v === 'true' || v === 'yes') { + return true; + } + + if (v === '0' || v === 'false' || v === 'no') { + return false; + } + + throw new Error(`Invalid string value for boolean: ${v}`); + } + + return Boolean(v); +}); + const serverConfig = type({ host: 'string.ip', port: type('string | number.integer').pipe((v) => Number(v)), cookie_secret: '32 <= string <= 32', cookie_secure: stringToBool, - agent: type({ - authkey: 'string = ""', - ttl: 'number.integer = 180000', // Default to 3 minutes - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', - }) - .onDeepUndeclaredKey('reject') - .default(() => ({ - authkey: '', - ttl: 180000, - cache_path: '/var/lib/headplane/agent_cache.json', - })), }); const oidcConfig = type({ @@ -46,6 +50,16 @@ const containerLabel = type({ value: 'string', }).optional(); +const agentConfig = type({ + enabled: stringToBool.default(false), + host_name: 'string = "headplane-agent"', + pre_authkey: 'string = ""', + cache_ttl: 'number.integer = 180000', + cache_path: 'string = "/var/lib/headplane/agent_cache.json"', + executable_path: 'string = "/usr/libexec/headplane/agent"', + work_dir: 'string = "/var/lib/headplane/agent"', +}); + const dockerConfig = type({ enabled: stringToBool, container_name: 'string', @@ -67,12 +81,14 @@ const integrationConfig = type({ 'docker?': dockerConfig, 'kubernetes?': kubernetesConfig, 'proc?': procConfig, + 'agent?': agentConfig, }).onDeepUndeclaredKey('reject'); const partialIntegrationConfig = type({ 'docker?': dockerConfig.partial(), 'kubernetes?': kubernetesConfig.partial(), 'proc?': procConfig.partial(), + 'agent?': agentConfig.partial(), }).partial(); export const headplaneConfig = type({ diff --git a/app/server/index.ts b/app/server/index.ts index df070bf..fbf4231 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -1,7 +1,6 @@ import { env, versions } from 'node:process'; -import type { UpgradeWebSocket } from 'hono/ws'; import { createHonoServer } from 'react-router-hono-server/node'; -import type { WebSocket } from 'ws'; + import log from '~/utils/log'; import { configureConfig, configureLogger, envVariables } from './config/env'; import { loadIntegration } from './config/integration'; @@ -57,11 +56,9 @@ const appLoadContext = { ), agents: await loadAgentSocket( - config.server.agent.authkey, - config.server.agent.cache_path, - config.server.agent.ttl, + config.integration?.agent, + config.headscale.url, ), - integration: await loadIntegration(config.integration), oidc: config.oidc ? await createOidcClient(config.oidc) : undefined, }; @@ -71,7 +68,6 @@ declare module 'react-router' { } export default createHonoServer({ - useWebSocket: true, overrideGlobalObjects: true, port: config.server.port, hostname: config.server.host, @@ -85,20 +81,6 @@ export default createHonoServer({ return appLoadContext; }, - configure(app, { upgradeWebSocket }) { - const agentManager = appLoadContext.agents; - if (agentManager) { - app.get( - `${__PREFIX__}/_dial`, - // We need this since we cannot pass the WSEvents context - // Also important to not pass the callback directly - // since we need to retain `this` context - (upgradeWebSocket as UpgradeWebSocket)((c) => - agentManager.configureSocket(c), - ), - ); - } - }, listeningListener(info) { log.info('server', 'Running on %s:%s', info.address, info.port); }, diff --git a/app/server/web/agent.ts b/app/server/web/agent.ts index 62c1f0c..311442f 100644 --- a/app/server/web/agent.ts +++ b/app/server/web/agent.ts @@ -1,159 +1,278 @@ +import { ChildProcess, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { open, readFile, writeFile } from 'node:fs/promises'; +import { constants, access, open, readFile, writeFile } from 'node:fs/promises'; +import { exit } from 'node:process'; +import { createInterface } from 'node:readline'; import { setTimeout } from 'node:timers/promises'; -import { getConnInfo } from '@hono/node-server/conninfo'; import { type } from 'arktype'; -import type { Context } from 'hono'; -import type { WSContext, WSEvents } from 'hono/ws'; -import { WebSocket } from 'ws'; import { HostInfo } from '~/types'; import log from '~/utils/log'; +import type { HeadplaneConfig } from '../config/schema'; + +interface LogResponse { + Level: 'info' | 'debug' | 'error' | 'fatal'; + Message: string; +} + +interface RegisterMessage { + Type: 'register'; + ID: string; +} + +interface StatusMessage { + Type: 'status'; + Data: Record; +} + +interface MessageResponse { + Level: 'msg'; + Message: RegisterMessage | StatusMessage; +} + +type AgentResponse = LogResponse | MessageResponse; export async function loadAgentSocket( - authkey: string, - path: string, - ttl: number, + config: NonNullable['agent'] | undefined, + headscaleUrl: string, ) { - if (authkey.length === 0) { + if (!config?.enabled) { + return; + } + + if (config.pre_authkey.trim().length === 0) { + log.error('agent', 'Agent `pre_authkey` is not set'); + log.warn('agent', 'The agent will not run until resolved'); return; } try { - const handle = await open(path, 'w'); - log.info('agent', 'Using agent cache file at %s', path); + await access(config.work_dir, constants.R_OK | constants.W_OK); + log.debug('config', 'Using agent work dir at %s', config.work_dir); + } catch (error) { + log.info('config', 'Agent work dir not accessible at %s', config.work_dir); + log.debug('config', 'Error details: %s', error); + return; + } + + try { + const handle = await open(config.cache_path, 'a+'); + log.info('agent', 'Using agent cache file at %s', config.cache_path); await handle.close(); } catch (error) { - log.info('agent', 'Agent cache file not accessible at %s', path); + log.info( + 'agent', + 'Agent cache file not accessible at %s', + config.cache_path, + ); log.debug('agent', 'Error details: %s', error); return; } - const cache = new TimedCache(ttl, path); - return new AgentManager(cache, authkey); + const cache = new TimedCache(config.cache_ttl, config.cache_path); + return new AgentManager(cache, config, headscaleUrl); } class AgentManager { + private static readonly MAX_RESTARTS = 5; + private restartCounter = 0; + private cache: TimedCache; - private agents: Map; - private timers: Map; - private authkey: string; + private headscaleUrl: string; + private config: NonNullable< + NonNullable['agent'] + >; - constructor(cache: TimedCache, authkey: string) { + private spawnProcess: ChildProcess | null; + private agentId: string | null; + + constructor( + cache: TimedCache, + config: NonNullable['agent']>, + headscaleUrl: string, + ) { this.cache = cache; - this.authkey = authkey; - this.agents = new Map(); - this.timers = new Map(); + this.config = config; + this.headscaleUrl = headscaleUrl; + this.spawnProcess = null; + this.agentId = null; + this.startAgent(); + + process.on('SIGINT', () => { + this.spawnProcess?.kill(); + exit(0); + }); + + process.on('SIGTERM', () => { + this.spawnProcess?.kill(); + exit(0); + }); } - tailnetIDs() { - return Array.from(this.agents.keys()); + /** + * Used by the UI to indicate why the agent is not running. + * Exhaustion requires a manual restart of the agent. + * (Which can be invoked via the UI) + * @returns true if the agent is exhausted + */ + exhausted() { + return this.restartCounter >= AgentManager.MAX_RESTARTS; } - lookup(nodeIds: string[]) { + /* + * Called by the UI to manually force a restart of the agent. + */ + deExhaust() { + this.restartCounter = 0; + this.startAgent(); + } + + /* + * Stored agent ID for the current process. This is caught by the agent + * when parsing the stdout on agent startup. + */ + agentID() { + return this.agentId; + } + + private startAgent() { + if (this.spawnProcess) { + log.debug('agent', 'Agent already running'); + return; + } + + if (this.exhausted()) { + log.error('agent', 'Agent is exhausted, cannot start'); + return; + } + + // Cannot be detached since we want to follow our process lifecycle + // We also need to be able to send data to the process by using stdin + log.info( + 'agent', + 'Starting agent process (attempt %d)', + this.restartCounter, + ); + this.spawnProcess = spawn(this.config.executable_path, [], { + detached: false, + stdio: ['pipe', 'pipe', 'pipe'], + env: { + HOME: process.env.HOME, + HEADPLANE_EMBEDDED: 'true', + HEADPLANE_AGENT_WORK_DIR: this.config.work_dir, + HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false', + HEADPLANE_AGENT_HOSTNAME: this.config.host_name, + HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl, + HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey, + }, + }); + + if (!this.spawnProcess?.pid) { + log.error('agent', 'Failed to start agent process'); + this.restartCounter++; + global.setTimeout(() => this.startAgent(), 1000); + return; + } + + if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) { + log.error('agent', 'Failed to connect to agent process'); + this.restartCounter++; + global.setTimeout(() => this.startAgent(), 1000); + return; + } + + const rlStdout = createInterface({ + input: this.spawnProcess.stdout, + crlfDelay: Number.POSITIVE_INFINITY, + }); + + rlStdout.on('line', (line) => { + try { + const parsed = JSON.parse(line) as AgentResponse; + if (parsed.Level === 'msg') { + switch (parsed.Message.Type) { + case 'register': + this.agentId = parsed.Message.ID; + break; + case 'status': + for (const [key, value] of Object.entries(parsed.Message.Data)) { + // Mark the agent as the one that is running + // We store it in the cache so that it shows + // itself later + if (key === this.agentId) { + value.HeadplaneAgent = true; + } + + this.cache.set(key, value); + } + + break; + } + + return; + } + + switch (parsed.Level) { + case 'info': + case 'debug': + case 'error': + log[parsed.Level]('agent', parsed.Message); + break; + case 'fatal': + log.error('agent', parsed.Message); + break; + default: + log.debug('agent', 'Unknown agent response: %s', line); + break; + } + } catch (error) { + log.debug('agent', 'Failed to parse agent response: %s', error); + log.debug('agent', 'Raw data: %s', line); + } + }); + + this.spawnProcess.on('error', (error) => { + log.error('agent', 'Failed to start agent process: %s', error); + this.restartCounter++; + this.spawnProcess = null; + global.setTimeout(() => this.startAgent(), 1000); + }); + + this.spawnProcess.on('exit', (code) => { + log.error('agent', 'Agent process exited with code %d', code ?? -1); + this.restartCounter++; + this.spawnProcess = null; + global.setTimeout(() => this.startAgent(), 1000); + }); + } + + async lookup(nodeIds: string[]) { const entries = this.cache.toJSON(); const missing = nodeIds.filter((nodeId) => !entries[nodeId]); if (missing.length > 0) { - this.requestData(missing); + await this.requestData(missing); } return entries; } - // Request data from all connected agents - // This does not return anything, but caches the data which then needs to be - // queried by the caller separately. - private requestData(nodeList: string[]) { - const NodeIDs = [...new Set(nodeList)]; - NodeIDs.map((node) => { - log.debug('agent', 'Requesting agent data for %s', node); - }); - - for (const agent of this.agents.values()) { - agent.send(JSON.stringify({ NodeIDs })); + // Request data from the internal agent by sending a message to the process + // via stdin. This is a blocking call, so it will wait for the agent to + // respond before returning. + private async requestData(nodeList: string[]) { + if (this.exhausted()) { + return; } - } - // Since we are using Node, Hono is built on 'ws' WebSocket types. - configureSocket(c: Context): WSEvents { - return { - onOpen: (_, ws) => { - const id = c.req.header('x-headplane-tailnet-id'); - if (!id) { - log.warn( - 'agent', - 'Rejecting an agent WebSocket connection without a tailnet ID', - ); - ws.close(1008, 'ERR_INVALID_TAILNET_ID'); - return; - } + // Wait for the process to be spawned, busy waiting is gross + while (this.spawnProcess === null) { + await setTimeout(100); + } - const auth = c.req.header('authorization'); - if (auth !== `Bearer ${this.authkey}`) { - log.warn('agent', 'Rejecting an unauthorized WebSocket connection'); - - const info = getConnInfo(c); - if (info.remote.address) { - log.warn('agent', 'Agent source IP: %s', info.remote.address); - } - - ws.close(1008, 'ERR_UNAUTHORIZED'); - return; - } - - const pinger = setInterval(() => { - if (ws.readyState !== 1) { - clearInterval(pinger); - return; - } - - ws.raw?.ping(); - }, 30000); - - this.agents.set(id, ws); - this.timers.set(id, pinger); - }, - - onClose: () => { - const id = c.req.header('x-headplane-tailnet-id'); - if (!id) { - return; - } - - clearInterval(this.timers.get(id)); - this.agents.delete(id); - }, - - onError: (event, ws) => { - const id = c.req.header('x-headplane-tailnet-id'); - if (!id) { - return; - } - - clearInterval(this.timers.get(id)); - if (event instanceof ErrorEvent) { - log.error('agent', 'WebSocket error: %s', event.message); - } - - log.debug('agent', 'Closing agent WebSocket connection'); - ws.close(1011, 'ERR_INTERNAL_ERROR'); - }, - - // This is where we receive the data from the agent - // Requests are made in the AgentManager.requestData function - onMessage: (event, ws) => { - const id = c.req.header('x-headplane-tailnet-id'); - if (!id) { - return; - } - - const data = JSON.parse(event.data.toString()); - log.debug('agent', 'Received agent data from %s', id); - for (const [node, info] of Object.entries(data)) { - this.cache.set(node, info); - log.debug('agent', 'Cached HostInfo for %s', node); - } - }, - }; + // Send the request to the agent, without waiting for a response. + // The live data invalidator will re-request the data if it is not + // available in the cache anyways. + const data = JSON.stringify({ NodeIDs: nodeList }); + this.spawnProcess.stdin?.write(`${data}\n`); } } diff --git a/app/types/HostInfo.ts b/app/types/HostInfo.ts index 4cd10b7..3ebb404 100644 --- a/app/types/HostInfo.ts +++ b/app/types/HostInfo.ts @@ -3,6 +3,11 @@ // https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816 export interface HostInfo { + /** + * Custom identifier we use to determine if its an agent or not + */ + HeadplaneAgent?: boolean; + /** Version of this code (in version.Long format) */ IPNVersion?: string; diff --git a/config.example.yaml b/config.example.yaml index b8ffd29..e1adb4e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -43,8 +43,34 @@ headscale: config_strict: true # Integration configurations for Headplane to interact with Headscale -# Only one of these should be enabled at a time or you will get errors integration: + # The Headplane agent allows retrieving information about nodes + # This allows the UI to display version, OS, and connectivity data + # You will see the Headplane agent in your Tailnet as a node when + # it connects. + enabled: false + # To connect to your Tailnet, you need to generate a pre-auth key + # This can be done via the web UI or through the `headscale` CLI. + pre_authkey: "" + # Optionally change the name of the agent in the Tailnet. + # host_name: "headplane-agent" + + # Configure different caching settings. By default, the agent will store + # caches in the path below for a maximum of 1 minute. If you want data + # to update faster, reduce the TTL, but this will increase the frequency + # of requests to Headscale. + # cache_ttl: 60 + # cache_path: /var/lib/headplane/agent_cache.json + + # Do not change this unless you are running a custom deployment. + # The work_dir represents where the agent will store its data to be able + # to automatically reauthenticate with your Tailnet. It needs to be + # writable by the user running the Headplane process. + # work_dir: "/var/lib/headplane/agent" + + # Only one of these should be enabled at a time or you will get errors + # This does not include the agent integration (above), which can be enabled + # at the same time as any of these and is recommended for the best experience. docker: enabled: false # Preferred method: use container_label to dynamically discover the Headscale container. diff --git a/package.json b/package.json index 9eb9865..b510f07 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "@dnd-kit/sortable": "^8.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/inter": "^5.1.1", - "@hono/node-server": "^1.14.0", "@kubernetes/client-node": "^0.22.3", "@primer/octicons-react": "^19.14.0", "@react-aria/toast": "3.0.0-beta.18", @@ -43,13 +42,12 @@ "react-error-boundary": "^5.0.0", "react-icons": "^5.5.0", "react-router": "^7.4.0", - "react-router-hono-server": "^2.11.0", + "react-router-hono-server": "^2.13.0", "react-stately": "^3.35.0", "remix-utils": "^8.0.0", "tailwind-merge": "^2.6.0", "undici": "^7.2.0", "usehooks-ts": "^3.1.0", - "ws": "^8.18.1", "yaml": "^2.7.0", "zod": "^3.24.1" }, @@ -58,7 +56,6 @@ "@biomejs/biome": "^1.9.4", "@react-router/dev": "^7.4.0", "@types/websocket": "^1.0.10", - "@types/ws": "^8.5.13", "autoprefixer": "^10.4.21", "babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229", "lefthook": "^1.10.9", diff --git a/patches/react-router-hono-server.patch b/patches/react-router-hono-server.patch index 6b09840..96a847a 100644 --- a/patches/react-router-hono-server.patch +++ b/patches/react-router-hono-server.patch @@ -1,8 +1,8 @@ diff --git a/dist/adapters/node.d.ts b/dist/adapters/node.d.ts -index 68742808892c1282ccff1e3321167862196d1229..f9a9249e1d1e573018d7ff3d3b967c4a1667d6ca 100644 +index 0108a13ac57e67858f2f91d05f22a4f81fc57508..dcbf308e17d8df6900e8b764d3e4d2e3e99deccc 100644 --- a/dist/adapters/node.d.ts +++ b/dist/adapters/node.d.ts -@@ -50,6 +50,10 @@ interface HonoNodeServerOptions extends HonoServerOpti +@@ -51,6 +51,10 @@ interface HonoNodeServerOptions extends HonoServerOpti /** * Callback executed just after `serve` from `@hono/node-server` */ @@ -14,41 +14,35 @@ index 68742808892c1282ccff1e3321167862196d1229..f9a9249e1d1e573018d7ff3d3b967c4a /** * The Node.js Adapter rewrites the global Request/Response and uses a lightweight Request/Response to improve performance. diff --git a/dist/adapters/node.js b/dist/adapters/node.js -index 481dec801537f6ccf7f7a8a8e2294f4b0f20bb7d..980fecf219dd0c501ed415e36985ec56d997f14f 100644 +index 966604f94ca8528b684ef95fe7891c2e6352561b..56eb6650d00b047163377b9e9017b9c5f31b1fa9 100644 --- a/dist/adapters/node.js +++ b/dist/adapters/node.js -@@ -46,16 +46,22 @@ async function createHonoServer(options) { +@@ -46,16 +46,24 @@ async function createHonoServer(options) { } await mergedOptions.beforeAll?.(app); app.use( - `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, -+ `${__PREFIX__}/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, ++ `/${__PREFIX__}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`, cache(60 * 60 * 24 * 365), // 1 year -- serveStatic({ root: clientBuildPath }) +- serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets }) + serveStatic({ -+ root: clientBuildPath, -+ rewriteRequestPath: path => path.replace(__PREFIX__, "/") -+ }) ++ root: clientBuildPath, ++ ...mergedOptions.serveStaticOptions?.clientAssets, ++ rewriteRequestPath: path => path.replace(__PREFIX__, "/") ++ }) ); app.use( - "*", -+ `${__PREFIX__}/assets/*`, ++ `${__PREFIX__}/*`, cache(60 * 60), // 1 hour -- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public" }) +- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets }) + serveStatic({ -+ root: PRODUCTION ? clientBuildPath : "./public", -+ rewriteRequestPath: path => path.replace(__PREFIX__, "/") -+ }) ++ root: PRODUCTION ? clientBuildPath : "./public", ++ ...mergedOptions.serveStaticOptions?.publicAssets, ++ rewriteRequestPath: path => path.replace(__PREFIX__, "/") ++ }) ); if (mergedOptions.defaultLogger) { app.use("*", logger()); -@@ -86,6 +92,7 @@ async function createHonoServer(options) { - ...app, - ...mergedOptions.customNodeServer, - port: mergedOptions.port, -+ hostname: mergedOptions.hostname, - overrideGlobalObjects: mergedOptions.overrideGlobalObjects - }, - mergedOptions.listeningListener diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f4ca1fe..053c936 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ patchedDependencies: hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211 path: patches/@shopify__lang-jsonc@1.0.0.patch react-router-hono-server: - hash: c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e + hash: 99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3 path: patches/react-router-hono-server.patch importers: @@ -31,9 +31,6 @@ importers: '@fontsource-variable/inter': specifier: ^5.1.1 version: 5.1.1 - '@hono/node-server': - specifier: ^1.14.0 - version: 1.14.0(hono@4.7.5) '@kubernetes/client-node': specifier: ^0.22.3 version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10) @@ -110,8 +107,8 @@ importers: specifier: ^7.4.0 version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) react-router-hono-server: - specifier: ^2.11.0 - version: 2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) + specifier: ^2.13.0 + version: 2.13.0(patch_hash=99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)) react-stately: specifier: ^3.35.0 version: 3.35.0(react@19.0.0) @@ -127,9 +124,6 @@ importers: usehooks-ts: specifier: ^3.1.0 version: 3.1.0(react@19.0.0) - ws: - specifier: ^8.18.1 - version: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) yaml: specifier: ^2.7.0 version: 2.7.0 @@ -149,9 +143,6 @@ importers: '@types/websocket': specifier: ^1.0.10 version: 1.0.10 - '@types/ws': - specifier: ^8.5.13 - version: 8.5.13 autoprefixer: specifier: ^10.4.21 version: 10.4.21(postcss@8.5.3) @@ -457,8 +448,8 @@ packages: '@codemirror/commands@6.7.1': resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==} - '@codemirror/commands@6.8.0': - resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==} + '@codemirror/commands@6.8.1': + resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==} '@codemirror/language@6.10.8': resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==} @@ -980,15 +971,15 @@ packages: peerDependencies: hono: ^4 - '@hono/node-ws@1.1.0': - resolution: {integrity: sha512-uHaz1EPguJqsUmA+Jmhdi/DTRAMs2Fvcy7qno9E48rlK3WBtyGQw4u4DKlc+o18Nh1DGz2oA1n9hCzEyhVBeLw==} + '@hono/node-ws@1.1.1': + resolution: {integrity: sha512-iFJrAw5GuBTstehBzLY2FyW5rRlXmO3Uwpijpm4Liv75owNP/UjZe3KExsLuEK4w+u+xhvHqOoQUyEKWUvyghw==} engines: {node: '>=18.14.1'} peerDependencies: '@hono/node-server': ^1.11.1 hono: ^4.6.0 - '@hono/vite-dev-server@0.17.0': - resolution: {integrity: sha512-EvGOIj1MoY9uV94onXXz88yWaTxzUK+Mv8LiIEsR/9eSFoVUnHVR0B7l7iNIsxfHYRN7tbPDMWBSnD2RQun3yw==} + '@hono/vite-dev-server@0.19.0': + resolution: {integrity: sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw==} engines: {node: '>=18.14.1'} peerDependencies: hono: '*' @@ -1826,9 +1817,6 @@ packages: '@types/node@20.17.16': resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==} - '@types/node@22.10.1': - resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==} - '@types/node@22.10.7': resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==} @@ -1843,9 +1831,6 @@ packages: '@types/websocket@1.0.10': resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==} - '@types/ws@8.5.13': - resolution: {integrity: sha512-osM/gWBTPKgHV8XkTunnegTRIsvF6owmf5w+JtAfOw472dptdm0dlGv4xCt6GwQRcC2XVOvvRE/0bAoQcL2QkA==} - '@uiw/codemirror-extensions-basic-setup@4.23.7': resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==} peerDependencies: @@ -2270,8 +2255,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.7.5: - resolution: {integrity: sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ==} + hono@4.7.6: + resolution: {integrity: sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==} engines: {node: '>=16.9.0'} hosted-git-info@6.1.3: @@ -2755,18 +2740,18 @@ packages: react: '>=18' react-dom: '>=18' - react-router-hono-server@2.11.0: - resolution: {integrity: sha512-zn0kJUUamgxYS7mMDLv0kHCJE1UTX0bYNdfJeBLjw0xr/gnre0ttEZ2LTsFM8re1P2iMQ64mftpnSyeXIPijOA==} + react-router-hono-server@2.13.0: + resolution: {integrity: sha512-YcxmFpphZL9Jc4CnOgsKw35wcurmiOpTg3vBzjOROIUhEo6rKvUHOg7AM2QJi2Fa8BRJK6MvHaN4haedYo1iiA==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: - '@cloudflare/workers-types': ^4.20241112.0 + '@cloudflare/workers-types': ^4.20250317.0 '@react-router/dev': ^7.2.0 '@types/react': ^18.3.10 || ^19.0.0 miniflare: ^3.20241205.0 react-router: ^7.2.0 - vite: ^5.1.0 || ^6.0.0 - wrangler: ^3.91.0 + vite: ^6.0.0 + wrangler: ^4.2.0 peerDependenciesMeta: '@cloudflare/workers-types': optional: true @@ -3679,7 +3664,7 @@ snapshots: '@codemirror/view': 6.36.1 '@lezer/common': 1.2.3 - '@codemirror/commands@6.8.0': + '@codemirror/commands@6.8.1': dependencies: '@codemirror/language': 6.10.8 '@codemirror/state': 6.5.0 @@ -4015,23 +4000,23 @@ snapshots: dependencies: tslib: 2.8.1 - '@hono/node-server@1.14.0(hono@4.7.5)': + '@hono/node-server@1.14.0(hono@4.7.6)': dependencies: - hono: 4.7.5 + hono: 4.7.6 - '@hono/node-ws@1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10)': + '@hono/node-ws@1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10)': dependencies: - '@hono/node-server': 1.14.0(hono@4.7.5) - hono: 4.7.5 + '@hono/node-server': 1.14.0(hono@4.7.6) + hono: 4.7.6 ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - utf-8-validate - '@hono/vite-dev-server@0.17.0(hono@4.7.5)': + '@hono/vite-dev-server@0.19.0(hono@4.7.6)': dependencies: - '@hono/node-server': 1.14.0(hono@4.7.5) - hono: 4.7.5 + '@hono/node-server': 1.14.0(hono@4.7.6) + hono: 4.7.6 minimatch: 9.0.5 '@internationalized/date@3.6.0': @@ -5325,10 +5310,6 @@ snapshots: dependencies: undici-types: 6.19.8 - '@types/node@22.10.1': - dependencies: - undici-types: 6.20.0 - '@types/node@22.10.7': dependencies: undici-types: 6.20.0 @@ -5345,10 +5326,6 @@ snapshots: dependencies: '@types/node': 22.10.7 - '@types/ws@8.5.13': - dependencies: - '@types/node': 22.10.1 - '@uiw/codemirror-extensions-basic-setup@4.23.7(@codemirror/autocomplete@6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3))(@codemirror/commands@6.7.1)(@codemirror/language@6.10.8)(@codemirror/lint@6.8.2)(@codemirror/search@6.5.7)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)': dependencies: '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3) @@ -5552,7 +5529,7 @@ snapshots: codemirror@6.0.1(@lezer/common@1.2.3): dependencies: '@codemirror/autocomplete': 6.18.2(@codemirror/language@6.10.8)(@codemirror/state@6.5.0)(@codemirror/view@6.36.1)(@lezer/common@1.2.3) - '@codemirror/commands': 6.8.0 + '@codemirror/commands': 6.8.1 '@codemirror/language': 6.10.8 '@codemirror/lint': 6.8.2 '@codemirror/search': 6.5.7 @@ -5821,7 +5798,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.7.5: {} + hono@4.7.6: {} hosted-git-info@6.1.3: dependencies: @@ -6255,15 +6232,15 @@ snapshots: react-dom: 19.0.0(react@19.0.0) react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - react-router-hono-server@2.11.0(patch_hash=c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): + react-router-hono-server@2.13.0(patch_hash=99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3)(@react-router/dev@7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0))(@types/react@19.0.2)(bufferutil@4.0.9)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(utf-8-validate@5.0.10)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0)): dependencies: '@drizzle-team/brocli': 0.11.0 - '@hono/node-server': 1.14.0(hono@4.7.5) - '@hono/node-ws': 1.1.0(@hono/node-server@1.14.0(hono@4.7.5))(bufferutil@4.0.9)(hono@4.7.5)(utf-8-validate@5.0.10) - '@hono/vite-dev-server': 0.17.0(hono@4.7.5) + '@hono/node-server': 1.14.0(hono@4.7.6) + '@hono/node-ws': 1.1.1(@hono/node-server@1.14.0(hono@4.7.6))(bufferutil@4.0.9)(hono@4.7.6)(utf-8-validate@5.0.10) + '@hono/vite-dev-server': 0.19.0(hono@4.7.6) '@react-router/dev': 7.4.0(@types/node@22.10.7)(jiti@1.21.7)(react-router@7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(terser@5.39.0)(tsx@4.19.2)(typescript@5.8.2)(vite@6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0))(yaml@2.7.0) '@types/react': 19.0.2 - hono: 4.7.5 + hono: 4.7.6 react-router: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0) vite: 6.2.2(@types/node@22.10.7)(jiti@1.21.7)(terser@5.39.0)(tsx@4.19.2)(yaml@2.7.0) transitivePeerDependencies: