feat: switch away from websocket to stdout messaging for agent
This commit is contained in:
parent
b090354d50
commit
9a8546ef09
1
.npmrc
1
.npmrc
@ -1,2 +1 @@
|
|||||||
side-effects-cache = false
|
side-effects-cache = false
|
||||||
public-hoist-pattern[]=*hono*
|
|
||||||
|
|||||||
16
Dockerfile
16
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
|
FROM node:22-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@ -11,8 +23,12 @@ COPY . .
|
|||||||
RUN pnpm run build
|
RUN pnpm run build
|
||||||
|
|
||||||
FROM node:22-alpine
|
FROM node:22-alpine
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
RUN mkdir -p /var/lib/headplane
|
RUN mkdir -p /var/lib/headplane
|
||||||
|
RUN mkdir -p /usr/libexec/headplane
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build /app/build
|
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" ]
|
CMD [ "node", "./build/server/index.js" ]
|
||||||
|
|||||||
@ -8,6 +8,11 @@ import (
|
|||||||
"github.com/tale/headplane/agent/internal/util"
|
"github.com/tale/headplane/agent/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Register struct {
|
||||||
|
Type string
|
||||||
|
ID string
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log := util.GetLogger()
|
log := util.GetLogger()
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
@ -21,11 +26,10 @@ func main() {
|
|||||||
agent.Connect()
|
agent.Connect()
|
||||||
defer agent.Shutdown()
|
defer agent.Shutdown()
|
||||||
|
|
||||||
ws, err := hpagent.NewSocket(agent, cfg)
|
log.Msg(&Register{
|
||||||
if err != nil {
|
Type: "register",
|
||||||
log.Fatal("Failed to create websocket: %s", err)
|
ID: agent.ID,
|
||||||
}
|
})
|
||||||
|
|
||||||
defer ws.StopListening()
|
hpagent.FollowMaster(agent)
|
||||||
ws.FollowMaster()
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,8 +8,7 @@ type Config struct {
|
|||||||
Hostname string
|
Hostname string
|
||||||
TSControlURL string
|
TSControlURL string
|
||||||
TSAuthKey string
|
TSAuthKey string
|
||||||
HPControlURL string
|
WorkDir string
|
||||||
HPAuthKey string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -17,8 +16,7 @@ const (
|
|||||||
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
||||||
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
||||||
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
||||||
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
|
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
||||||
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Load reads the agent configuration from environment variables.
|
// Load reads the agent configuration from environment variables.
|
||||||
@ -28,8 +26,7 @@ func Load() (*Config, error) {
|
|||||||
Hostname: os.Getenv(HostnameEnv),
|
Hostname: os.Getenv(HostnameEnv),
|
||||||
TSControlURL: os.Getenv(TSControlURLEnv),
|
TSControlURL: os.Getenv(TSControlURLEnv),
|
||||||
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
||||||
HPControlURL: os.Getenv(HPControlURLEnv),
|
WorkDir: os.Getenv(WorkDirEnv),
|
||||||
HPAuthKey: os.Getenv(HPAuthKeyEnv),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if os.Getenv(DebugEnv) == "true" {
|
if os.Getenv(DebugEnv) == "true" {
|
||||||
@ -44,9 +41,5 @@ func Load() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateHPReady(c); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,16 +16,12 @@ func validateRequired(config *Config) error {
|
|||||||
return fmt.Errorf("%s is required", TSControlURLEnv)
|
return fmt.Errorf("%s is required", TSControlURLEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.HPControlURL == "" {
|
|
||||||
return fmt.Errorf("%s is required", HPControlURLEnv)
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.TSAuthKey == "" {
|
if config.TSAuthKey == "" {
|
||||||
return fmt.Errorf("%s is required", TSAuthKeyEnv)
|
return fmt.Errorf("%s is required", TSAuthKeyEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.HPAuthKey == "" {
|
if config.WorkDir == "" {
|
||||||
return fmt.Errorf("%s is required", HPAuthKeyEnv)
|
return fmt.Errorf("%s is required", WorkDirEnv)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@ -50,23 +46,3 @@ func validateTSReady(config *Config) error {
|
|||||||
|
|
||||||
return nil
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,9 +1,12 @@
|
|||||||
package hpagent
|
package hpagent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/tale/headplane/agent/internal/tsnet"
|
||||||
"github.com/tale/headplane/agent/internal/util"
|
"github.com/tale/headplane/agent/internal/util"
|
||||||
"tailscale.com/tailcfg"
|
"tailscale.com/tailcfg"
|
||||||
)
|
)
|
||||||
@ -13,30 +16,32 @@ type RecvMessage struct {
|
|||||||
NodeIDs []string
|
NodeIDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starts listening for messages from the Headplane master
|
type SendMessage struct {
|
||||||
func (s *Socket) FollowMaster() {
|
Type string
|
||||||
log := util.GetLogger()
|
Data any
|
||||||
|
}
|
||||||
|
|
||||||
for {
|
// Starts listening for messages from stdin
|
||||||
_, message, err := s.ReadMessage()
|
func FollowMaster(agent *tsnet.TSAgent) {
|
||||||
if err != nil {
|
log := util.GetLogger()
|
||||||
log.Error("Error reading message: %s", err)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
return
|
|
||||||
}
|
for scanner.Scan() {
|
||||||
|
line := scanner.Bytes()
|
||||||
|
|
||||||
var msg RecvMessage
|
var msg RecvMessage
|
||||||
err = json.Unmarshal(message, &msg)
|
err := json.Unmarshal(line, &msg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to unmarshal message: %s", err)
|
log.Error("Unable to unmarshal message: %s", err)
|
||||||
log.Debug("Full Error: %v", err)
|
log.Debug("Full Error: %v", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Debug("Recieved message from master: %v", message)
|
log.Debug("Recieved message from master: %v", line)
|
||||||
|
|
||||||
if len(msg.NodeIDs) == 0 {
|
if len(msg.NodeIDs) == 0 {
|
||||||
log.Debug("Message recieved had no node IDs")
|
log.Debug("Message recieved had no node IDs")
|
||||||
log.Debug("Full message: %s", message)
|
log.Debug("Full message: %s", line)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,7 +54,7 @@ func (s *Socket) FollowMaster() {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(nodeID string) {
|
go func(nodeID string) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
result, err := s.Agent.GetStatusForPeer(nodeID)
|
result, err := agent.GetStatusForPeer(nodeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Unable to get status for node %s: %s", nodeID, err)
|
log.Error("Unable to get status for node %s: %s", nodeID, err)
|
||||||
return
|
return
|
||||||
@ -70,15 +75,13 @@ func (s *Socket) FollowMaster() {
|
|||||||
|
|
||||||
// Send the results back to the Headplane master
|
// Send the results back to the Headplane master
|
||||||
log.Debug("Sending status back to master: %v", results)
|
log.Debug("Sending status back to master: %v", results)
|
||||||
err = s.SendStatus(results)
|
log.Msg(&SendMessage{
|
||||||
if err != nil {
|
Type: "status",
|
||||||
log.Error("Error sending status: %s", err)
|
Data: results,
|
||||||
return
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -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
|
|
||||||
}
|
|
||||||
@ -2,6 +2,9 @@ package tsnet
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/tale/headplane/agent/internal/config"
|
"github.com/tale/headplane/agent/internal/config"
|
||||||
"github.com/tale/headplane/agent/internal/util"
|
"github.com/tale/headplane/agent/internal/util"
|
||||||
"tailscale.com/client/tailscale"
|
"tailscale.com/client/tailscale"
|
||||||
@ -17,15 +20,27 @@ type TSAgent struct {
|
|||||||
|
|
||||||
// Creates a new tsnet agent and returns an instance of the server.
|
// Creates a new tsnet agent and returns an instance of the server.
|
||||||
func NewAgent(cfg *config.Config) *TSAgent {
|
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{
|
server := &tsnet.Server{
|
||||||
|
Dir: dir,
|
||||||
Hostname: cfg.Hostname,
|
Hostname: cfg.Hostname,
|
||||||
ControlURL: cfg.TSControlURL,
|
ControlURL: cfg.TSControlURL,
|
||||||
AuthKey: cfg.TSAuthKey,
|
AuthKey: cfg.TSAuthKey,
|
||||||
Logf: func(string, ...interface{}) {}, // Disabled by default
|
Logf: func(string, ...any) {}, // Disabled by default
|
||||||
|
UserLogf: log.Info,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Debug {
|
if cfg.Debug {
|
||||||
log := util.GetLogger()
|
|
||||||
server.Logf = log.Debug
|
server.Logf = log.Debug
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -47,8 +62,13 @@ func (s *TSAgent) Connect() {
|
|||||||
log.Fatal("Failed to initialize local Tailscale client: %s", err)
|
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)
|
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.
|
// Shuts down the tsnet agent.
|
||||||
|
|||||||
@ -1,66 +1,117 @@
|
|||||||
package util
|
package util
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Logger struct {
|
type LogLevel string
|
||||||
debug *log.Logger
|
|
||||||
info *log.Logger
|
const (
|
||||||
error *log.Logger
|
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{}
|
type Logger struct {
|
||||||
var logger *Logger
|
debugEnabled bool
|
||||||
|
encoder *json.Encoder
|
||||||
|
pool *sync.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
var logger = NewLogger()
|
||||||
|
|
||||||
func GetLogger() *Logger {
|
func GetLogger() *Logger {
|
||||||
if logger == nil {
|
|
||||||
lock.Lock()
|
|
||||||
defer lock.Unlock()
|
|
||||||
if logger == nil {
|
|
||||||
logger = NewLogger()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewLogger() *Logger {
|
func NewLogger() *Logger {
|
||||||
// Create a new Logger for stdout and stderr
|
enc := json.NewEncoder(os.Stdout)
|
||||||
// Errors still go to both stdout and stderr
|
enc.SetEscapeHTML(false)
|
||||||
|
|
||||||
return &Logger{
|
return &Logger{
|
||||||
debug: nil,
|
encoder: enc,
|
||||||
info: log.New(os.Stdout, "[INFO] ", log.LstdFlags),
|
pool: &sync.Pool{
|
||||||
error: log.New(os.Stderr, "[ERROR] ", log.LstdFlags),
|
New: func() any {
|
||||||
|
return &LogMessage{}
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (logger *Logger) SetDebug(debug bool) {
|
func (l *Logger) SetDebug(enabled bool) {
|
||||||
if debug {
|
if enabled {
|
||||||
logger.Info("Enabling Debug logging for headplane-agent")
|
l.debugEnabled = true
|
||||||
logger.Info("Be careful, this will spam a lot of information")
|
l.Info("Enabling Debug logging for headplane-agent")
|
||||||
logger.debug = log.New(os.Stdout, "[DEBUG] ", log.LstdFlags)
|
l.Info("Be careful, this will spam a lot of information")
|
||||||
} else {
|
|
||||||
logger.debug = nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (logger *Logger) Info(fmt string, v ...any) {
|
func (l *Logger) log(level LogLevel, format string, v ...any) {
|
||||||
logger.info.Printf(fmt, v...)
|
msg := fmt.Sprintf(format, v...)
|
||||||
}
|
timestamp := time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
func (logger *Logger) Debug(fmt string, v ...any) {
|
// Manually construct compact JSON line for performance
|
||||||
if logger.debug != nil {
|
line := `{"Level":"` + string(level) +
|
||||||
logger.debug.Printf(fmt, v...)
|
`","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) {
|
func (l *Logger) Debug(format string, v ...any) {
|
||||||
logger.error.Printf(fmt, v...)
|
if l.debugEnabled {
|
||||||
|
l.log(LevelDebug, format, v...)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (logger *Logger) Fatal(fmt string, v ...any) {
|
func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
|
||||||
logger.error.Fatalf(fmt, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,8 +66,10 @@ export async function loader({
|
|||||||
magic,
|
magic,
|
||||||
server: context.config.headscale.url,
|
server: context.config.headscale.url,
|
||||||
publicServer: context.config.headscale.public_url,
|
publicServer: context.config.headscale.public_url,
|
||||||
agents: context.agents?.tailnetIDs(),
|
agent: context.agents?.agentID(),
|
||||||
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
stats: await context.agents?.lookup(
|
||||||
|
machines.nodes.map((node) => node.nodeKey),
|
||||||
|
),
|
||||||
writable: writablePermission,
|
writable: writablePermission,
|
||||||
preAuth: await context.sessions.check(
|
preAuth: await context.sessions.check(
|
||||||
request,
|
request,
|
||||||
@ -129,7 +131,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
{/* We only want to show the version column if there are agents */}
|
{/* We only want to show the version column if there are agents */}
|
||||||
{data.agents !== undefined ? (
|
{data.agent !== undefined ? (
|
||||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||||
@ -152,7 +154,7 @@ export default function Page() {
|
|||||||
magic={data.magic}
|
magic={data.magic}
|
||||||
// If we pass undefined, the column will not be rendered
|
// If we pass undefined, the column will not be rendered
|
||||||
// This is useful for when there are no agents configured
|
// 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]}
|
stats={data.stats?.[machine.nodeKey]}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
data.writable
|
data.writable
|
||||||
|
|||||||
@ -1,22 +1,26 @@
|
|||||||
import { type } from 'arktype';
|
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({
|
const serverConfig = type({
|
||||||
host: 'string.ip',
|
host: 'string.ip',
|
||||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||||
cookie_secret: '32 <= string <= 32',
|
cookie_secret: '32 <= string <= 32',
|
||||||
cookie_secure: stringToBool,
|
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({
|
const oidcConfig = type({
|
||||||
@ -46,6 +50,16 @@ const containerLabel = type({
|
|||||||
value: 'string',
|
value: 'string',
|
||||||
}).optional();
|
}).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({
|
const dockerConfig = type({
|
||||||
enabled: stringToBool,
|
enabled: stringToBool,
|
||||||
container_name: 'string',
|
container_name: 'string',
|
||||||
@ -67,12 +81,14 @@ const integrationConfig = type({
|
|||||||
'docker?': dockerConfig,
|
'docker?': dockerConfig,
|
||||||
'kubernetes?': kubernetesConfig,
|
'kubernetes?': kubernetesConfig,
|
||||||
'proc?': procConfig,
|
'proc?': procConfig,
|
||||||
|
'agent?': agentConfig,
|
||||||
}).onDeepUndeclaredKey('reject');
|
}).onDeepUndeclaredKey('reject');
|
||||||
|
|
||||||
const partialIntegrationConfig = type({
|
const partialIntegrationConfig = type({
|
||||||
'docker?': dockerConfig.partial(),
|
'docker?': dockerConfig.partial(),
|
||||||
'kubernetes?': kubernetesConfig.partial(),
|
'kubernetes?': kubernetesConfig.partial(),
|
||||||
'proc?': procConfig.partial(),
|
'proc?': procConfig.partial(),
|
||||||
|
'agent?': agentConfig.partial(),
|
||||||
}).partial();
|
}).partial();
|
||||||
|
|
||||||
export const headplaneConfig = type({
|
export const headplaneConfig = type({
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { env, versions } from 'node:process';
|
import { env, versions } from 'node:process';
|
||||||
import type { UpgradeWebSocket } from 'hono/ws';
|
|
||||||
import { createHonoServer } from 'react-router-hono-server/node';
|
import { createHonoServer } from 'react-router-hono-server/node';
|
||||||
import type { WebSocket } from 'ws';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||||
import { loadIntegration } from './config/integration';
|
import { loadIntegration } from './config/integration';
|
||||||
@ -57,11 +56,9 @@ const appLoadContext = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
agents: await loadAgentSocket(
|
agents: await loadAgentSocket(
|
||||||
config.server.agent.authkey,
|
config.integration?.agent,
|
||||||
config.server.agent.cache_path,
|
config.headscale.url,
|
||||||
config.server.agent.ttl,
|
|
||||||
),
|
),
|
||||||
|
|
||||||
integration: await loadIntegration(config.integration),
|
integration: await loadIntegration(config.integration),
|
||||||
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
||||||
};
|
};
|
||||||
@ -71,7 +68,6 @@ declare module 'react-router' {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default createHonoServer({
|
export default createHonoServer({
|
||||||
useWebSocket: true,
|
|
||||||
overrideGlobalObjects: true,
|
overrideGlobalObjects: true,
|
||||||
port: config.server.port,
|
port: config.server.port,
|
||||||
hostname: config.server.host,
|
hostname: config.server.host,
|
||||||
@ -85,20 +81,6 @@ export default createHonoServer({
|
|||||||
return appLoadContext;
|
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<WebSocket>)((c) =>
|
|
||||||
agentManager.configureSocket(c),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
listeningListener(info) {
|
listeningListener(info) {
|
||||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,159 +1,278 @@
|
|||||||
|
import { ChildProcess, spawn } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
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 { setTimeout } from 'node:timers/promises';
|
||||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
|
||||||
import { type } from 'arktype';
|
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 { HostInfo } from '~/types';
|
||||||
import log from '~/utils/log';
|
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<string, HostInfo>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageResponse {
|
||||||
|
Level: 'msg';
|
||||||
|
Message: RegisterMessage | StatusMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentResponse = LogResponse | MessageResponse;
|
||||||
|
|
||||||
export async function loadAgentSocket(
|
export async function loadAgentSocket(
|
||||||
authkey: string,
|
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||||
path: string,
|
headscaleUrl: string,
|
||||||
ttl: number,
|
|
||||||
) {
|
) {
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await open(path, 'w');
|
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||||
log.info('agent', 'Using agent cache file at %s', path);
|
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();
|
await handle.close();
|
||||||
} catch (error) {
|
} 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);
|
log.debug('agent', 'Error details: %s', error);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cache = new TimedCache<HostInfo>(ttl, path);
|
const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
|
||||||
return new AgentManager(cache, authkey);
|
return new AgentManager(cache, config, headscaleUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AgentManager {
|
class AgentManager {
|
||||||
|
private static readonly MAX_RESTARTS = 5;
|
||||||
|
private restartCounter = 0;
|
||||||
|
|
||||||
private cache: TimedCache<HostInfo>;
|
private cache: TimedCache<HostInfo>;
|
||||||
private agents: Map<string, WSContext>;
|
private headscaleUrl: string;
|
||||||
private timers: Map<string, NodeJS.Timeout>;
|
private config: NonNullable<
|
||||||
private authkey: string;
|
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||||
|
>;
|
||||||
|
|
||||||
constructor(cache: TimedCache<HostInfo>, authkey: string) {
|
private spawnProcess: ChildProcess | null;
|
||||||
|
private agentId: string | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
cache: TimedCache<HostInfo>,
|
||||||
|
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
|
||||||
|
headscaleUrl: string,
|
||||||
|
) {
|
||||||
this.cache = cache;
|
this.cache = cache;
|
||||||
this.authkey = authkey;
|
this.config = config;
|
||||||
this.agents = new Map();
|
this.headscaleUrl = headscaleUrl;
|
||||||
this.timers = new Map();
|
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 entries = this.cache.toJSON();
|
||||||
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
this.requestData(missing);
|
await this.requestData(missing);
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request data from all connected agents
|
// Request data from the internal agent by sending a message to the process
|
||||||
// This does not return anything, but caches the data which then needs to be
|
// via stdin. This is a blocking call, so it will wait for the agent to
|
||||||
// queried by the caller separately.
|
// respond before returning.
|
||||||
private requestData(nodeList: string[]) {
|
private async requestData(nodeList: string[]) {
|
||||||
const NodeIDs = [...new Set(nodeList)];
|
if (this.exhausted()) {
|
||||||
NodeIDs.map((node) => {
|
return;
|
||||||
log.debug('agent', 'Requesting agent data for %s', node);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const agent of this.agents.values()) {
|
|
||||||
agent.send(JSON.stringify({ NodeIDs }));
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Since we are using Node, Hono is built on 'ws' WebSocket types.
|
// Wait for the process to be spawned, busy waiting is gross
|
||||||
configureSocket(c: Context): WSEvents<WebSocket> {
|
while (this.spawnProcess === null) {
|
||||||
return {
|
await setTimeout(100);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const auth = c.req.header('authorization');
|
// Send the request to the agent, without waiting for a response.
|
||||||
if (auth !== `Bearer ${this.authkey}`) {
|
// The live data invalidator will re-request the data if it is not
|
||||||
log.warn('agent', 'Rejecting an unauthorized WebSocket connection');
|
// available in the cache anyways.
|
||||||
|
const data = JSON.stringify({ NodeIDs: nodeList });
|
||||||
const info = getConnInfo(c);
|
this.spawnProcess.stdin?.write(`${data}\n`);
|
||||||
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<HostInfo>(data)) {
|
|
||||||
this.cache.set(node, info);
|
|
||||||
log.debug('agent', 'Cached HostInfo for %s', node);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,11 @@
|
|||||||
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
|
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
|
||||||
|
|
||||||
export interface HostInfo {
|
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) */
|
/** Version of this code (in version.Long format) */
|
||||||
IPNVersion?: string;
|
IPNVersion?: string;
|
||||||
|
|
||||||
|
|||||||
@ -43,8 +43,34 @@ headscale:
|
|||||||
config_strict: true
|
config_strict: true
|
||||||
|
|
||||||
# Integration configurations for Headplane to interact with Headscale
|
# Integration configurations for Headplane to interact with Headscale
|
||||||
# Only one of these should be enabled at a time or you will get errors
|
|
||||||
integration:
|
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: "<your-preauth-key>"
|
||||||
|
# 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:
|
docker:
|
||||||
enabled: false
|
enabled: false
|
||||||
# Preferred method: use container_label to dynamically discover the Headscale container.
|
# Preferred method: use container_label to dynamically discover the Headscale container.
|
||||||
|
|||||||
@ -17,7 +17,6 @@
|
|||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@fontsource-variable/inter": "^5.1.1",
|
"@fontsource-variable/inter": "^5.1.1",
|
||||||
"@hono/node-server": "^1.14.0",
|
|
||||||
"@kubernetes/client-node": "^0.22.3",
|
"@kubernetes/client-node": "^0.22.3",
|
||||||
"@primer/octicons-react": "^19.14.0",
|
"@primer/octicons-react": "^19.14.0",
|
||||||
"@react-aria/toast": "3.0.0-beta.18",
|
"@react-aria/toast": "3.0.0-beta.18",
|
||||||
@ -43,13 +42,12 @@
|
|||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router": "^7.4.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",
|
"react-stately": "^3.35.0",
|
||||||
"remix-utils": "^8.0.0",
|
"remix-utils": "^8.0.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"undici": "^7.2.0",
|
"undici": "^7.2.0",
|
||||||
"usehooks-ts": "^3.1.0",
|
"usehooks-ts": "^3.1.0",
|
||||||
"ws": "^8.18.1",
|
|
||||||
"yaml": "^2.7.0",
|
"yaml": "^2.7.0",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@ -58,7 +56,6 @@
|
|||||||
"@biomejs/biome": "^1.9.4",
|
"@biomejs/biome": "^1.9.4",
|
||||||
"@react-router/dev": "^7.4.0",
|
"@react-router/dev": "^7.4.0",
|
||||||
"@types/websocket": "^1.0.10",
|
"@types/websocket": "^1.0.10",
|
||||||
"@types/ws": "^8.5.13",
|
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
|
"babel-plugin-react-compiler": "19.0.0-beta-55955c9-20241229",
|
||||||
"lefthook": "^1.10.9",
|
"lefthook": "^1.10.9",
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
diff --git a/dist/adapters/node.d.ts b/dist/adapters/node.d.ts
|
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
|
--- a/dist/adapters/node.d.ts
|
||||||
+++ b/dist/adapters/node.d.ts
|
+++ b/dist/adapters/node.d.ts
|
||||||
@@ -50,6 +50,10 @@ interface HonoNodeServerOptions<E extends Env = BlankEnv> extends HonoServerOpti
|
@@ -51,6 +51,10 @@ interface HonoNodeServerOptions<E extends Env = BlankEnv> extends HonoServerOpti
|
||||||
/**
|
/**
|
||||||
* Callback executed just after `serve` from `@hono/node-server`
|
* 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.
|
* 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
|
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
|
--- a/dist/adapters/node.js
|
||||||
+++ b/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);
|
await mergedOptions.beforeAll?.(app);
|
||||||
app.use(
|
app.use(
|
||||||
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
|
- `/${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),
|
cache(60 * 60 * 24 * 365),
|
||||||
// 1 year
|
// 1 year
|
||||||
- serveStatic({ root: clientBuildPath })
|
- serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
|
||||||
+ serveStatic({
|
+ serveStatic({
|
||||||
+ root: clientBuildPath,
|
+ root: clientBuildPath,
|
||||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
+ ...mergedOptions.serveStaticOptions?.clientAssets,
|
||||||
+ })
|
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||||
|
+ })
|
||||||
);
|
);
|
||||||
app.use(
|
app.use(
|
||||||
- "*",
|
- "*",
|
||||||
+ `${__PREFIX__}/assets/*`,
|
+ `${__PREFIX__}/*`,
|
||||||
cache(60 * 60),
|
cache(60 * 60),
|
||||||
// 1 hour
|
// 1 hour
|
||||||
- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public" })
|
- serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })
|
||||||
+ serveStatic({
|
+ serveStatic({
|
||||||
+ root: PRODUCTION ? clientBuildPath : "./public",
|
+ root: PRODUCTION ? clientBuildPath : "./public",
|
||||||
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
+ ...mergedOptions.serveStaticOptions?.publicAssets,
|
||||||
+ })
|
+ rewriteRequestPath: path => path.replace(__PREFIX__, "/")
|
||||||
|
+ })
|
||||||
);
|
);
|
||||||
if (mergedOptions.defaultLogger) {
|
if (mergedOptions.defaultLogger) {
|
||||||
app.use("*", logger());
|
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
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ patchedDependencies:
|
|||||||
hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211
|
hash: 915164bae9a5d47bb0e7edf0cbbc4c7f0fedb1a2f9a5f6ef5c53d8fef6856211
|
||||||
path: patches/@shopify__lang-jsonc@1.0.0.patch
|
path: patches/@shopify__lang-jsonc@1.0.0.patch
|
||||||
react-router-hono-server:
|
react-router-hono-server:
|
||||||
hash: c547fd5480e282b40f1c4e668ab066a78abdde8fe04871a65bda306896b0a39e
|
hash: 99b0b196753142b8bd30e3d98d42462fbea5d06cebfd17d820b073e7ec51e6c3
|
||||||
path: patches/react-router-hono-server.patch
|
path: patches/react-router-hono-server.patch
|
||||||
|
|
||||||
importers:
|
importers:
|
||||||
@ -31,9 +31,6 @@ importers:
|
|||||||
'@fontsource-variable/inter':
|
'@fontsource-variable/inter':
|
||||||
specifier: ^5.1.1
|
specifier: ^5.1.1
|
||||||
version: 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':
|
'@kubernetes/client-node':
|
||||||
specifier: ^0.22.3
|
specifier: ^0.22.3
|
||||||
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
version: 0.22.3(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||||
@ -110,8 +107,8 @@ importers:
|
|||||||
specifier: ^7.4.0
|
specifier: ^7.4.0
|
||||||
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 7.4.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
react-router-hono-server:
|
react-router-hono-server:
|
||||||
specifier: ^2.11.0
|
specifier: ^2.13.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))
|
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:
|
react-stately:
|
||||||
specifier: ^3.35.0
|
specifier: ^3.35.0
|
||||||
version: 3.35.0(react@19.0.0)
|
version: 3.35.0(react@19.0.0)
|
||||||
@ -127,9 +124,6 @@ importers:
|
|||||||
usehooks-ts:
|
usehooks-ts:
|
||||||
specifier: ^3.1.0
|
specifier: ^3.1.0
|
||||||
version: 3.1.0(react@19.0.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:
|
yaml:
|
||||||
specifier: ^2.7.0
|
specifier: ^2.7.0
|
||||||
version: 2.7.0
|
version: 2.7.0
|
||||||
@ -149,9 +143,6 @@ importers:
|
|||||||
'@types/websocket':
|
'@types/websocket':
|
||||||
specifier: ^1.0.10
|
specifier: ^1.0.10
|
||||||
version: 1.0.10
|
version: 1.0.10
|
||||||
'@types/ws':
|
|
||||||
specifier: ^8.5.13
|
|
||||||
version: 8.5.13
|
|
||||||
autoprefixer:
|
autoprefixer:
|
||||||
specifier: ^10.4.21
|
specifier: ^10.4.21
|
||||||
version: 10.4.21(postcss@8.5.3)
|
version: 10.4.21(postcss@8.5.3)
|
||||||
@ -457,8 +448,8 @@ packages:
|
|||||||
'@codemirror/commands@6.7.1':
|
'@codemirror/commands@6.7.1':
|
||||||
resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==}
|
resolution: {integrity: sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==}
|
||||||
|
|
||||||
'@codemirror/commands@6.8.0':
|
'@codemirror/commands@6.8.1':
|
||||||
resolution: {integrity: sha512-q8VPEFaEP4ikSlt6ZxjB3zW72+7osfAYW9i8Zu943uqbKuz6utc1+F170hyLUCUltXORjQXRyYQNfkckzA/bPQ==}
|
resolution: {integrity: sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==}
|
||||||
|
|
||||||
'@codemirror/language@6.10.8':
|
'@codemirror/language@6.10.8':
|
||||||
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
|
resolution: {integrity: sha512-wcP8XPPhDH2vTqf181U8MbZnW+tDyPYy0UzVOa+oHORjyT+mhhom9vBd7dApJwoDz9Nb/a8kHjJIsuA/t8vNFw==}
|
||||||
@ -980,15 +971,15 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: ^4
|
hono: ^4
|
||||||
|
|
||||||
'@hono/node-ws@1.1.0':
|
'@hono/node-ws@1.1.1':
|
||||||
resolution: {integrity: sha512-uHaz1EPguJqsUmA+Jmhdi/DTRAMs2Fvcy7qno9E48rlK3WBtyGQw4u4DKlc+o18Nh1DGz2oA1n9hCzEyhVBeLw==}
|
resolution: {integrity: sha512-iFJrAw5GuBTstehBzLY2FyW5rRlXmO3Uwpijpm4Liv75owNP/UjZe3KExsLuEK4w+u+xhvHqOoQUyEKWUvyghw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@hono/node-server': ^1.11.1
|
'@hono/node-server': ^1.11.1
|
||||||
hono: ^4.6.0
|
hono: ^4.6.0
|
||||||
|
|
||||||
'@hono/vite-dev-server@0.17.0':
|
'@hono/vite-dev-server@0.19.0':
|
||||||
resolution: {integrity: sha512-EvGOIj1MoY9uV94onXXz88yWaTxzUK+Mv8LiIEsR/9eSFoVUnHVR0B7l7iNIsxfHYRN7tbPDMWBSnD2RQun3yw==}
|
resolution: {integrity: sha512-myMc4Nm0nFQSPaeE6I/a1ODyDR5KpQ4EHodX4Tu/7qlB31GfUemhUH/WsO91HJjDEpRRpsT4Zbg+PleMlpTljw==}
|
||||||
engines: {node: '>=18.14.1'}
|
engines: {node: '>=18.14.1'}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
hono: '*'
|
hono: '*'
|
||||||
@ -1826,9 +1817,6 @@ packages:
|
|||||||
'@types/node@20.17.16':
|
'@types/node@20.17.16':
|
||||||
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
|
resolution: {integrity: sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw==}
|
||||||
|
|
||||||
'@types/node@22.10.1':
|
|
||||||
resolution: {integrity: sha512-qKgsUwfHZV2WCWLAnVP1JqnpE6Im6h3Y0+fYgMTasNQ7V++CBX5OT1as0g0f+OyubbFqhf6XVNIsmN4IIhEgGQ==}
|
|
||||||
|
|
||||||
'@types/node@22.10.7':
|
'@types/node@22.10.7':
|
||||||
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
|
resolution: {integrity: sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==}
|
||||||
|
|
||||||
@ -1843,9 +1831,6 @@ packages:
|
|||||||
'@types/websocket@1.0.10':
|
'@types/websocket@1.0.10':
|
||||||
resolution: {integrity: sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==}
|
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':
|
'@uiw/codemirror-extensions-basic-setup@4.23.7':
|
||||||
resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==}
|
resolution: {integrity: sha512-9/2EUa1Lck4kFKkR2BkxlZPpgD/EWuKHnOlysf1yHKZGraaZmZEaUw+utDK4QcuJc8Iz097vsLz4f4th5EU27g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -2270,8 +2255,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
hono@4.7.5:
|
hono@4.7.6:
|
||||||
resolution: {integrity: sha512-fDOK5W2C1vZACsgLONigdZTRZxuBqFtcKh7bUQ5cVSbwI2RWjloJDcgFOVzbQrlI6pCmhlTsVYZ7zpLj4m4qMQ==}
|
resolution: {integrity: sha512-564rVzELU+9BRqqx5k8sT2NFwGD3I3Vifdb6P7CmM6FiarOSY+fDC+6B+k9wcCb86ReoayteZP2ki0cRLN1jbw==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
hosted-git-info@6.1.3:
|
hosted-git-info@6.1.3:
|
||||||
@ -2755,18 +2740,18 @@ packages:
|
|||||||
react: '>=18'
|
react: '>=18'
|
||||||
react-dom: '>=18'
|
react-dom: '>=18'
|
||||||
|
|
||||||
react-router-hono-server@2.11.0:
|
react-router-hono-server@2.13.0:
|
||||||
resolution: {integrity: sha512-zn0kJUUamgxYS7mMDLv0kHCJE1UTX0bYNdfJeBLjw0xr/gnre0ttEZ2LTsFM8re1P2iMQ64mftpnSyeXIPijOA==}
|
resolution: {integrity: sha512-YcxmFpphZL9Jc4CnOgsKw35wcurmiOpTg3vBzjOROIUhEo6rKvUHOg7AM2QJi2Fa8BRJK6MvHaN4haedYo1iiA==}
|
||||||
engines: {node: '>=22.12.0'}
|
engines: {node: '>=22.12.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@cloudflare/workers-types': ^4.20241112.0
|
'@cloudflare/workers-types': ^4.20250317.0
|
||||||
'@react-router/dev': ^7.2.0
|
'@react-router/dev': ^7.2.0
|
||||||
'@types/react': ^18.3.10 || ^19.0.0
|
'@types/react': ^18.3.10 || ^19.0.0
|
||||||
miniflare: ^3.20241205.0
|
miniflare: ^3.20241205.0
|
||||||
react-router: ^7.2.0
|
react-router: ^7.2.0
|
||||||
vite: ^5.1.0 || ^6.0.0
|
vite: ^6.0.0
|
||||||
wrangler: ^3.91.0
|
wrangler: ^4.2.0
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
'@cloudflare/workers-types':
|
'@cloudflare/workers-types':
|
||||||
optional: true
|
optional: true
|
||||||
@ -3679,7 +3664,7 @@ snapshots:
|
|||||||
'@codemirror/view': 6.36.1
|
'@codemirror/view': 6.36.1
|
||||||
'@lezer/common': 1.2.3
|
'@lezer/common': 1.2.3
|
||||||
|
|
||||||
'@codemirror/commands@6.8.0':
|
'@codemirror/commands@6.8.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@codemirror/language': 6.10.8
|
'@codemirror/language': 6.10.8
|
||||||
'@codemirror/state': 6.5.0
|
'@codemirror/state': 6.5.0
|
||||||
@ -4015,23 +4000,23 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
tslib: 2.8.1
|
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:
|
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:
|
dependencies:
|
||||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
'@hono/node-server': 1.14.0(hono@4.7.6)
|
||||||
hono: 4.7.5
|
hono: 4.7.6
|
||||||
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
ws: 8.18.1(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- utf-8-validate
|
- 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:
|
dependencies:
|
||||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
'@hono/node-server': 1.14.0(hono@4.7.6)
|
||||||
hono: 4.7.5
|
hono: 4.7.6
|
||||||
minimatch: 9.0.5
|
minimatch: 9.0.5
|
||||||
|
|
||||||
'@internationalized/date@3.6.0':
|
'@internationalized/date@3.6.0':
|
||||||
@ -5325,10 +5310,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.19.8
|
undici-types: 6.19.8
|
||||||
|
|
||||||
'@types/node@22.10.1':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.20.0
|
|
||||||
|
|
||||||
'@types/node@22.10.7':
|
'@types/node@22.10.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.20.0
|
undici-types: 6.20.0
|
||||||
@ -5345,10 +5326,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 22.10.7
|
'@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)':
|
'@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:
|
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/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):
|
codemirror@6.0.1(@lezer/common@1.2.3):
|
||||||
dependencies:
|
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/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/language': 6.10.8
|
||||||
'@codemirror/lint': 6.8.2
|
'@codemirror/lint': 6.8.2
|
||||||
'@codemirror/search': 6.5.7
|
'@codemirror/search': 6.5.7
|
||||||
@ -5821,7 +5798,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
function-bind: 1.1.2
|
function-bind: 1.1.2
|
||||||
|
|
||||||
hono@4.7.5: {}
|
hono@4.7.6: {}
|
||||||
|
|
||||||
hosted-git-info@6.1.3:
|
hosted-git-info@6.1.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
@ -6255,15 +6232,15 @@ snapshots:
|
|||||||
react-dom: 19.0.0(react@19.0.0)
|
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: 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:
|
dependencies:
|
||||||
'@drizzle-team/brocli': 0.11.0
|
'@drizzle-team/brocli': 0.11.0
|
||||||
'@hono/node-server': 1.14.0(hono@4.7.5)
|
'@hono/node-server': 1.14.0(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)
|
||||||
'@hono/vite-dev-server': 0.17.0(hono@4.7.5)
|
'@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)
|
'@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
|
'@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)
|
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)
|
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:
|
transitivePeerDependencies:
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user