335 lines
9.2 KiB
Go
335 lines
9.2 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.asio.asia/nixevol/NixMsg/internal/admin"
|
|
"git.asio.asia/nixevol/NixMsg/internal/app/group"
|
|
"git.asio.asia/nixevol/NixMsg/internal/app/identity"
|
|
"git.asio.asia/nixevol/NixMsg/internal/app/message"
|
|
"git.asio.asia/nixevol/NixMsg/internal/app/port"
|
|
"git.asio.asia/nixevol/NixMsg/internal/app/presence"
|
|
"git.asio.asia/nixevol/NixMsg/internal/auth"
|
|
"git.asio.asia/nixevol/NixMsg/internal/broker"
|
|
"git.asio.asia/nixevol/NixMsg/internal/config"
|
|
"git.asio.asia/nixevol/NixMsg/internal/httpx"
|
|
"git.asio.asia/nixevol/NixMsg/internal/listener"
|
|
"git.asio.asia/nixevol/NixMsg/internal/metrics"
|
|
"git.asio.asia/nixevol/NixMsg/internal/store"
|
|
"git.asio.asia/nixevol/NixMsg/web"
|
|
)
|
|
|
|
func cmdServe(_ []string) error {
|
|
cfgPath := config.PathFromEnv()
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return err
|
|
}
|
|
setupJSONLogger(cfg.Log)
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
return runServe(ctx, cfg)
|
|
}
|
|
|
|
func runServe(ctx context.Context, cfg config.Config) error {
|
|
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
|
return fmt.Errorf("mkdir data_dir: %w", err)
|
|
}
|
|
|
|
db, err := store.Open(cfg.DataDir, cfg.SQLiteSynchronous)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = db.Close() }()
|
|
|
|
ok, err := store.HasAdminPassword(ctx, db.Write)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !ok {
|
|
return errors.New("admin password not initialized; run: nixmsg admin init")
|
|
}
|
|
|
|
hashPool := auth.NewPool()
|
|
sessionTokens := auth.NewSessionTokens()
|
|
apiTokens := auth.NewAPITokens()
|
|
loginLocks := auth.NewLoginLocks()
|
|
memConns := message.NewMemoryConns()
|
|
msgLim := message.LimitsFromFullConfig(cfg)
|
|
|
|
login := broker.NewLogin(broker.LoginOptions{
|
|
DB: db,
|
|
Pool: hashPool,
|
|
Tokens: sessionTokens,
|
|
Locks: loginLocks,
|
|
IdleDays: cfg.SessionIdleDays,
|
|
})
|
|
|
|
// 先用空下行建 message,拿到 App 指针;broker 建好后再 WithDownlink 重建并回填 uplink.msg。
|
|
msgApp := message.New(db, msgLim, hashPool,
|
|
message.WithLocks(loginLocks),
|
|
message.WithConnRegistry(memConns),
|
|
)
|
|
uplink := &appUplink{
|
|
msg: msgApp,
|
|
conns: memConns,
|
|
log: slog.Default(),
|
|
}
|
|
sess := broker.NewSession(broker.SessionOptions{
|
|
Login: login,
|
|
Inner: uplink,
|
|
Limits: broker.HelloLimits{
|
|
MaxBodyBytes: cfg.Limits.MaxBodyBytes,
|
|
MaxMetaBytes: cfg.Limits.MaxMetaBytes,
|
|
MaxFrameBytes: cfg.Limits.MaxFrameBytes,
|
|
MaxTTLSeconds: int64(cfg.Limits.MaxTTLSeconds),
|
|
MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds),
|
|
AckTimeoutSeconds: int64(cfg.Limits.AckTimeoutSeconds),
|
|
ServerVersion: Version,
|
|
},
|
|
Logger: slog.Default(),
|
|
})
|
|
|
|
brk, err := broker.New(broker.Options{
|
|
Authenticator: login,
|
|
Uplink: sess,
|
|
Logger: slog.Default(),
|
|
OnPublishDropped: func(dropCtx context.Context, endpointID string, connID port.ConnID, payload []byte) {
|
|
if dropErr := msgApp.OnPublishDropped(dropCtx, endpointID, connID, payload); dropErr != nil {
|
|
slog.Error("on publish dropped", "endpoint", endpointID, "err", dropErr)
|
|
}
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("broker: %w", err)
|
|
}
|
|
defer func() { _ = brk.Close() }()
|
|
sess.Attach(brk)
|
|
|
|
msgApp = message.New(db, msgLim, hashPool,
|
|
message.WithLocks(loginLocks),
|
|
message.WithConnRegistry(memConns),
|
|
message.WithDownlink(brk),
|
|
)
|
|
uplink.msg = msgApp
|
|
uplink.down = brk
|
|
|
|
presApp := presence.New(presence.Config{
|
|
DB: db,
|
|
Downlink: brk,
|
|
Conns: &presenceConnTable{conns: memConns},
|
|
})
|
|
sess.SetPresence(presApp)
|
|
uplink.presence = presApp
|
|
|
|
idApp := identity.New(identity.Config{
|
|
DB: db,
|
|
Hash: hashPool,
|
|
Locks: loginLocks,
|
|
Sessions: sessionTokens,
|
|
MaxScheduleSeconds: int64(cfg.Limits.MaxScheduleSeconds),
|
|
Logger: slog.Default(),
|
|
ConnControl: brk,
|
|
})
|
|
uplink.identity = idApp
|
|
|
|
groupApp := group.New(group.Config{
|
|
DB: db,
|
|
Talk: idApp,
|
|
Online: presApp,
|
|
Downlink: brk,
|
|
MaxGroupMembers: cfg.Limits.MaxGroupMembers,
|
|
})
|
|
uplink.groups = groupApp
|
|
|
|
if recoverErr := msgApp.RecoverOnStart(ctx); recoverErr != nil {
|
|
return fmt.Errorf("message recover: %w", recoverErr)
|
|
}
|
|
|
|
trustedNets := httpx.ParseCIDRs(cfg.TrustedProxies)
|
|
adminHandler := admin.New(admin.Deps{
|
|
DB: db,
|
|
Hash: hashPool,
|
|
Tokens: apiTokens,
|
|
Locks: loginLocks,
|
|
Logger: slog.Default(),
|
|
TrustedProxies: trustedNets,
|
|
KickEndpoint: func(kickCtx context.Context, endpointID string) (bool, error) {
|
|
if _, found := brk.ConnInfoOf(endpointID); !found {
|
|
return false, nil
|
|
}
|
|
if kickErr := sess.Kick(kickCtx, endpointID); kickErr != nil {
|
|
return false, kickErr
|
|
}
|
|
return true, nil
|
|
},
|
|
})
|
|
|
|
metricsReg := metrics.New()
|
|
buildHandlers := func(proxies *listener.ProxySet) listener.Handlers {
|
|
return listener.Handlers{
|
|
MQTT: brk.WSHandler(proxies),
|
|
ClientAPI: idApp.Handler(),
|
|
AdminAPI: adminHandler,
|
|
Metrics: metricsReg.Handler(),
|
|
MetricsToken: cfg.Metrics.Token,
|
|
Static: staticFileHandler(),
|
|
Healthz: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}),
|
|
Readyz: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if readyErr := db.Ready(r.Context()); readyErr != nil {
|
|
slog.Error("readyz failed", "err", readyErr)
|
|
http.Error(w, "not ready", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write([]byte("ok"))
|
|
}),
|
|
}
|
|
}
|
|
|
|
handlers := buildHandlers(nil)
|
|
shared := cfg.AdminListen == ""
|
|
var clientHandler, adminHTTP http.Handler
|
|
if shared {
|
|
clientHandler = listener.NewMux(listener.RoleShared, handlers)
|
|
} else {
|
|
clientHandler = listener.NewMux(listener.RoleClient, handlers)
|
|
adminHTTP = listener.NewMux(listener.RoleAdmin, handlers)
|
|
}
|
|
|
|
lnOpts := listener.Options{
|
|
Listen: cfg.Listen,
|
|
AdminListen: cfg.AdminListen,
|
|
DataDir: cfg.DataDir,
|
|
CertFile: cfg.TLS.CertFile,
|
|
KeyFile: cfg.TLS.KeyFile,
|
|
AllowPlaintext: cfg.TLS.AllowPlaintext,
|
|
TrustedProxies: cfg.TrustedProxies,
|
|
ClientHandler: clientHandler,
|
|
AdminHandler: adminHTTP,
|
|
OnMQTT: func(conn net.Conn) {
|
|
_ = brk.AttachTCP(conn)
|
|
},
|
|
Logger: slog.Default(),
|
|
}
|
|
lnSrv, err := listener.New(lnOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("listener: %w", err)
|
|
}
|
|
handlers = buildHandlers(lnSrv.Proxies())
|
|
if shared {
|
|
lnOpts.ClientHandler = listener.NewMux(listener.RoleShared, handlers)
|
|
} else {
|
|
lnOpts.ClientHandler = listener.NewMux(listener.RoleClient, handlers)
|
|
lnOpts.AdminHandler = listener.NewMux(listener.RoleAdmin, handlers)
|
|
}
|
|
lnSrv, err = listener.New(lnOpts)
|
|
if err != nil {
|
|
return fmt.Errorf("listener: %w", err)
|
|
}
|
|
|
|
if err := lnSrv.Start(ctx); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = lnSrv.Close() }()
|
|
|
|
if err := writeListenAddr(cfg.DataDir, lnSrv.ListenAddr()); err != nil {
|
|
return err
|
|
}
|
|
if cfg.AdminListen != "" && lnSrv.AdminAddr() != "" {
|
|
if err := os.WriteFile(filepath.Join(cfg.DataDir, "admin.addr"), []byte(lnSrv.AdminAddr()+"\n"), 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
loopCtx, loopCancel := context.WithCancel(ctx)
|
|
defer loopCancel()
|
|
go messageLoops(loopCtx, msgApp, memConns)
|
|
|
|
<-ctx.Done()
|
|
loopCancel()
|
|
_ = lnSrv.Close()
|
|
drainCtx, drainCancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer drainCancel()
|
|
if drainErr := db.Queue.Drain(drainCtx); drainErr != nil && !errors.Is(drainErr, context.DeadlineExceeded) {
|
|
slog.Error("write queue drain", "err", drainErr)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func messageLoops(ctx context.Context, msgApp *message.App, conns *message.MemoryConns) {
|
|
t := time.NewTicker(time.Second)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-t.C:
|
|
nowMs := time.Now().UnixMilli()
|
|
if _, err := msgApp.DispatchDue(ctx, nowMs, 100); err != nil {
|
|
slog.Error("dispatch due", "err", err)
|
|
}
|
|
for ep, live := range conns.Snapshot() {
|
|
if err := msgApp.PushPending(ctx, ep, live.ConnID); err != nil {
|
|
slog.Debug("push pending", "endpoint", ep, "err", err)
|
|
}
|
|
}
|
|
if err := msgApp.CleanupOnce(ctx, nowMs); err != nil {
|
|
slog.Error("cleanup once", "err", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func staticFileHandler() http.Handler {
|
|
root := web.Dist()
|
|
for _, prefix := range []string{"dist", "stub"} {
|
|
sub, err := fs.Sub(root, prefix)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if f, err := sub.Open("index.html"); err == nil {
|
|
_ = f.Close()
|
|
return http.FileServer(http.FS(sub))
|
|
}
|
|
}
|
|
return http.FileServer(http.FS(root))
|
|
}
|
|
|
|
func writeListenAddr(dataDir, addr string) error {
|
|
path := filepath.Join(dataDir, "listen.addr")
|
|
return os.WriteFile(path, []byte(addr+"\n"), 0o644)
|
|
}
|
|
|
|
func setupJSONLogger(cfg config.LogConfig) {
|
|
level := slog.LevelInfo
|
|
switch strings.ToLower(strings.TrimSpace(cfg.Level)) {
|
|
case "debug":
|
|
level = slog.LevelDebug
|
|
case "warn", "warning":
|
|
level = slog.LevelWarn
|
|
case "error":
|
|
level = slog.LevelError
|
|
}
|
|
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level})
|
|
slog.SetDefault(slog.New(h))
|
|
}
|