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")
}
// 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 {
fmt.Printf("Error starting channels: %v\n", err)
}
healthServer := health.NewServer(cfg.Gateway.Host, cfg.Gateway.Port)
go func() {
if err := healthServer.Start(); err != nil && err != http.ErrServerClosed {
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)

View file

@ -44,6 +44,7 @@ type LINEChannel struct {
*BaseChannel
config config.LINEConfig
httpServer *http.Server
useSharedMux bool // true when webhook is registered on the gateway mux
botUserID string // Bot's user ID
botBasicID string // Bot's basic ID (e.g. @216ru...)
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
}
// 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.
func (c *LINEChannel) Start(ctx context.Context) error {
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()
path := c.config.WebhookPath
if path == "" {

View file

@ -9,6 +9,7 @@ package channels
import (
"context"
"fmt"
"net/http"
"sync"
"github.com/sipeed/picoclaw/pkg/bus"
@ -314,6 +315,19 @@ func (m *Manager) GetEnabledChannels() []string {
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) {
m.mu.Lock()
defer m.mu.Unlock()

View file

@ -11,6 +11,7 @@ import (
type Server struct {
server *http.Server
mux *http.ServeMux
mu sync.RWMutex
ready bool
checks map[string]Check
@ -30,14 +31,23 @@ type StatusResponse struct {
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 {
mux := http.NewServeMux()
s := &Server{
mux: mux,
ready: false,
checks: make(map[string]Check),
startTime: time.Now(),
}
mux.HandleFunc("/", s.rootHandler)
mux.HandleFunc("/health", s.healthHandler)
mux.HandleFunc("/ready", s.readyHandler)
@ -52,6 +62,11 @@ func NewServer(host string, port int) *Server {
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 {
s.mu.Lock()
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) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)