fix: resolve 404 on gateway root and unify webhook routing on single port

The health server on port 18790 only handled /health and /ready, returning
404 for all other paths including /. This also meant webhook-based channels
(LINE) ran on separate ports not exposed in docker-compose or firewall.

Changes:
- Add root `/` handler returning gateway info JSON instead of 404
- Expose health server mux so channels can register webhook routes on it
- LINE channel now registers webhook on shared gateway mux (port 18790)
- Channels still fall back to standalone servers if not using shared mux

https://claude.ai/code/session_019vXaqxGmkdCjM8m3jp6rYj
This commit is contained in:
Claude 2026-02-17 14:09:58 +00:00
parent 1ea879745b
commit 7ca93d2596
No known key found for this signature in database
4 changed files with 75 additions and 2 deletions

View file

@ -659,17 +659,20 @@ func gatewayCmd() {
fmt.Println("✓ Device event service started") fmt.Println("✓ Device event service started")
} }
// Create the gateway HTTP server first so channels can register webhooks on it
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
channelManager.RegisterWebhooks(healthServer.Mux())
if err := channelManager.StartAll(ctx); err != nil { if err := channelManager.StartAll(ctx); err != nil {
fmt.Printf("Error starting channels: %v\n", err) fmt.Printf("Error starting channels: %v\n", err)
} }
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() { go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed { if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()}) logger.ErrorCF("health", "Health server error", map[string]interface{}{"error": err.Error()})
} }
}() }()
fmt.Printf("✓ Health endpoints available at http://%s:%d/health and /ready\n", cfg.Gateway.Host, cfg.Gateway.Port) fmt.Printf("✓ Gateway listening on http://%s:%d (health, webhooks)\n", cfg.Gateway.Host, cfg.Gateway.Port)
go agentLoop.Run(ctx) go agentLoop.Run(ctx)

View file

@ -44,6 +44,7 @@ type LINEChannel struct {
*BaseChannel *BaseChannel
config config.LINEConfig config config.LINEConfig
httpServer *http.Server httpServer *http.Server
useSharedMux bool // true when webhook is registered on the gateway mux
botUserID string // Bot's user ID botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...) botBasicID string // Bot's basic ID (e.g. @216ru...)
botDisplayName string // Bot's display name for text-based mention detection botDisplayName string // Bot's display name for text-based mention detection
@ -67,6 +68,20 @@ func NewLINEChannel(cfg config.LINEConfig, messageBus *bus.MessageBus) (*LINECha
}, nil }, nil
} }
// RegisterWebhook registers the LINE webhook handler on a shared HTTP mux
// (typically the gateway mux) so no separate server is needed.
func (c *LINEChannel) RegisterWebhook(mux *http.ServeMux) {
path := c.config.WebhookPath
if path == "" {
path = "/webhook/line"
}
mux.HandleFunc(path, c.webhookHandler)
c.useSharedMux = true
logger.InfoCF("line", "LINE webhook registered on gateway", map[string]interface{}{
"path": path,
})
}
// Start launches the HTTP webhook server. // Start launches the HTTP webhook server.
func (c *LINEChannel) Start(ctx context.Context) error { func (c *LINEChannel) Start(ctx context.Context) error {
logger.InfoC("line", "Starting LINE channel (Webhook Mode)") logger.InfoC("line", "Starting LINE channel (Webhook Mode)")
@ -86,6 +101,13 @@ func (c *LINEChannel) Start(ctx context.Context) error {
}) })
} }
// If webhook was already registered on the shared gateway mux, skip creating a standalone server
if c.useSharedMux {
c.setRunning(true)
logger.InfoC("line", "LINE channel started (shared gateway webhook)")
return nil
}
mux := http.NewServeMux() mux := http.NewServeMux()
path := c.config.WebhookPath path := c.config.WebhookPath
if path == "" { if path == "" {

View file

@ -9,6 +9,7 @@ package channels
import ( import (
"context" "context"
"fmt" "fmt"
"net/http"
"sync" "sync"
"github.com/sipeed/picoclaw/pkg/bus" "github.com/sipeed/picoclaw/pkg/bus"
@ -314,6 +315,19 @@ func (m *Manager) GetEnabledChannels() []string {
return names return names
} }
// RegisterWebhooks registers webhook routes for channels that support HTTP webhooks
// on the shared gateway mux, so they share the same port as the health server.
func (m *Manager) RegisterWebhooks(mux *http.ServeMux) {
m.mu.RLock()
defer m.mu.RUnlock()
if line, ok := m.channels["line"]; ok {
if lc, ok := line.(*LINEChannel); ok {
lc.RegisterWebhook(mux)
}
}
}
func (m *Manager) RegisterChannel(name string, channel Channel) { func (m *Manager) RegisterChannel(name string, channel Channel) {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()

View file

@ -11,6 +11,7 @@ import (
type Server struct { type Server struct {
server *http.Server server *http.Server
mux *http.ServeMux
mu sync.RWMutex mu sync.RWMutex
ready bool ready bool
checks map[string]Check checks map[string]Check
@ -30,14 +31,23 @@ type StatusResponse struct {
Checks map[string]Check `json:"checks,omitempty"` Checks map[string]Check `json:"checks,omitempty"`
} }
type GatewayInfo struct {
Name string `json:"name"`
Status string `json:"status"`
Uptime string `json:"uptime"`
Version string `json:"version"`
}
func NewServer(host string, port int) *Server { func NewServer(host string, port int) *Server {
mux := http.NewServeMux() mux := http.NewServeMux()
s := &Server{ s := &Server{
mux: mux,
ready: false, ready: false,
checks: make(map[string]Check), checks: make(map[string]Check),
startTime: time.Now(), startTime: time.Now(),
} }
mux.HandleFunc("/", s.rootHandler)
mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/ready", s.readyHandler)
@ -52,6 +62,11 @@ func NewServer(host string, port int) *Server {
return s return s
} }
// Mux returns the HTTP mux so channels can register webhook routes on the gateway port.
func (s *Server) Mux() *http.ServeMux {
return s.mux
}
func (s *Server) Start() error { func (s *Server) Start() error {
s.mu.Lock() s.mu.Lock()
s.ready = true s.ready = true
@ -103,6 +118,25 @@ func (s *Server) RegisterCheck(name string, checkFn func() (bool, string)) {
} }
} }
func (s *Server) rootHandler(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
uptime := time.Since(s.startTime)
resp := GatewayInfo{
Name: "PicoClaw Gateway",
Status: "running",
Uptime: uptime.String(),
Version: "1.0.0",
}
json.NewEncoder(w).Encode(resp)
}
func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)