Files
MyGoNavi/internal/webserver/server.go
Syngnat 8d186fc066 feat(web-auth): 支持六位密码与环境变量托管
- 密码策略统一为至少六个可见字符,覆盖初始化与设置中心改密
- 支持 GONAVI_WEB_PASSWORD 启动同步并保留既有 2FA 与会话配置
- 设置中心识别环境托管状态并禁用临时改密
- Compose、env 示例及双语文档补充密码配置和容器重建说明
- 增加认证持久化、环境覆盖、前端状态与 Docker 登录回归校验
2026-07-10 10:34:03 +08:00

480 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webserver
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io/fs"
"net"
"net/http"
"os"
"path"
"reflect"
"strings"
"sync"
"time"
aiservice "GoNavi-Wails/internal/ai/service"
appcore "GoNavi-Wails/internal/app"
"GoNavi-Wails/internal/logger"
"GoNavi-Wails/internal/uievents"
)
const (
defaultWebServerAddr = "127.0.0.1:34116"
internalRoutePrefix = "/__gonavi"
)
var errorType = reflect.TypeOf((*error)(nil)).Elem()
type Options struct {
Addr string
}
type invokeRequest struct {
Namespace string `json:"namespace"`
Receiver string `json:"receiver"`
Method string `json:"method"`
Args []json.RawMessage `json:"args"`
}
type invokeResponse struct {
Result any `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type eventMessage struct {
Name string `json:"name"`
Args []any `json:"args,omitempty"`
}
type eventHub struct {
mu sync.RWMutex
subscribers map[chan eventMessage]struct{}
}
func newEventHub() *eventHub {
return &eventHub{subscribers: make(map[chan eventMessage]struct{})}
}
func (h *eventHub) Emit(name string, args ...any) {
if h == nil || strings.TrimSpace(name) == "" {
return
}
msg := eventMessage{Name: name, Args: args}
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subscribers {
select {
case ch <- msg:
default:
}
}
}
func (h *eventHub) subscribe() chan eventMessage {
ch := make(chan eventMessage, 128)
h.mu.Lock()
h.subscribers[ch] = struct{}{}
h.mu.Unlock()
return ch
}
func (h *eventHub) unsubscribe(ch chan eventMessage) {
if ch == nil {
return
}
h.mu.Lock()
if _, ok := h.subscribers[ch]; ok {
delete(h.subscribers, ch)
close(ch)
}
h.mu.Unlock()
}
type methodInvoker struct {
targets map[string]reflect.Value
}
func newMethodInvoker(app *appcore.App, ai *aiservice.Service) *methodInvoker {
return &methodInvoker{
targets: map[string]reflect.Value{
"app.app": reflect.ValueOf(app),
"app": reflect.ValueOf(app),
"aiservice.service": reflect.ValueOf(ai),
"aiservice": reflect.ValueOf(ai),
},
}
}
func (i *methodInvoker) Invoke(req invokeRequest) (any, error) {
if i == nil {
return nil, fmt.Errorf("web invoker is not initialized")
}
namespace := strings.ToLower(strings.TrimSpace(req.Namespace))
receiver := strings.ToLower(strings.TrimSpace(req.Receiver))
methodName := strings.TrimSpace(req.Method)
if namespace == "" || methodName == "" {
return nil, fmt.Errorf("invalid invoke request")
}
key := namespace
if receiver != "" {
key = namespace + "." + receiver
}
target, ok := i.targets[key]
if !ok {
return nil, fmt.Errorf("unsupported invoke target: %s.%s", namespace, receiver)
}
method := target.MethodByName(methodName)
if !method.IsValid() {
return nil, fmt.Errorf("unsupported method: %s.%s.%s", namespace, receiver, methodName)
}
methodType := method.Type()
if methodType.IsVariadic() {
return nil, fmt.Errorf("variadic methods are not supported: %s", methodName)
}
if methodType.NumIn() != len(req.Args) {
return nil, fmt.Errorf("invalid argument count for %s: want %d got %d", methodName, methodType.NumIn(), len(req.Args))
}
callArgs := make([]reflect.Value, 0, len(req.Args))
for index, raw := range req.Args {
argValue, err := decodeArgument(raw, methodType.In(index))
if err != nil {
return nil, fmt.Errorf("decode argument %d for %s failed: %w", index, methodName, err)
}
callArgs = append(callArgs, argValue)
}
results := method.Call(callArgs)
return unpackResults(results)
}
func decodeArgument(raw json.RawMessage, targetType reflect.Type) (reflect.Value, error) {
holder := reflect.New(targetType)
if len(raw) == 0 {
return holder.Elem(), nil
}
if err := json.Unmarshal(raw, holder.Interface()); err != nil {
return reflect.Value{}, err
}
return holder.Elem(), nil
}
func unpackResults(results []reflect.Value) (any, error) {
if len(results) == 0 {
return nil, nil
}
last := results[len(results)-1]
if last.IsValid() && last.Type().Implements(errorType) {
if !last.IsNil() {
return nil, last.Interface().(error)
}
results = results[:len(results)-1]
}
switch len(results) {
case 0:
return nil, nil
case 1:
return results[0].Interface(), nil
default:
unpacked := make([]any, len(results))
for index := range results {
unpacked[index] = results[index].Interface()
}
return unpacked, nil
}
}
type Server struct {
options Options
assets fs.FS
app *appcore.App
ai *aiservice.Service
auth *webAuthManager
events *eventHub
invoker *methodInvoker
}
func ParseOptions(args []string) (Options, error) {
options := Options{
Addr: defaultWebServerAddr,
}
if envAddr := strings.TrimSpace(os.Getenv("GONAVI_WEB_ADDR")); envAddr != "" {
options.Addr = envAddr
}
fs := flag.NewFlagSet("gonavi web-server", flag.ContinueOnError)
fs.SetOutput(ioDiscard{})
fs.StringVar(&options.Addr, "addr", options.Addr, "web server listen address, for example 127.0.0.1:34116")
if err := fs.Parse(args); err != nil {
return Options{}, err
}
if fs.NArg() > 0 {
return Options{}, fmt.Errorf("unknown web-server arguments: %s", strings.Join(fs.Args(), " "))
}
return options, nil
}
func Run(ctx context.Context, assetFS fs.FS, args []string) error {
options, err := ParseOptions(args)
if err != nil {
return err
}
server, err := New(ctx, assetFS, options)
if err != nil {
return err
}
return server.Run(ctx)
}
func New(ctx context.Context, assetFS fs.FS, options Options) (*Server, error) {
if assetFS == nil {
return nil, fmt.Errorf("web assets are unavailable")
}
frontendFS, err := fs.Sub(assetFS, "frontend/dist")
if err != nil {
return nil, fmt.Errorf("resolve frontend dist assets failed: %w", err)
}
events := newEventHub()
lifecycleCtx := uievents.WithEmitter(ctx, events)
app := appcore.NewApp()
appcore.InitializeLifecycle(app, lifecycleCtx)
ai := aiservice.NewService()
aiservice.InitializeLifecycle(ai, lifecycleCtx)
auth, err := newWebAuthManagerFromEnvironment("")
if err != nil {
return nil, fmt.Errorf("initialize web auth failed: %w", err)
}
return &Server{
options: options,
assets: frontendFS,
app: app,
ai: ai,
auth: auth,
events: events,
invoker: newMethodInvoker(app, ai),
}, nil
}
func (s *Server) Run(ctx context.Context) error {
if s == nil {
return fmt.Errorf("web server is not initialized")
}
if ctx == nil {
ctx = context.Background()
}
defer s.ai.Shutdown()
defer s.app.Shutdown()
httpServer := &http.Server{
Addr: s.options.Addr,
Handler: s.routes(),
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := net.Listen("tcp", s.options.Addr)
if err != nil {
return err
}
logger.Infof("GoNavi Web Server 启动addr=%s", listener.Addr().String())
errCh := make(chan error, 1)
go func() {
errCh <- httpServer.Serve(listener)
}()
select {
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
shutdownErr := httpServer.Shutdown(shutdownCtx)
serveErr := <-errCh
if shutdownErr != nil && !errors.Is(shutdownErr, http.ErrServerClosed) {
return shutdownErr
}
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
return serveErr
}
return nil
}
}
func (s *Server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(internalRoutePrefix+"/auth/status", s.handleAuthStatus)
mux.HandleFunc(internalRoutePrefix+"/auth/setup/bootstrap", s.handleSetupBootstrap)
mux.HandleFunc(internalRoutePrefix+"/auth/setup/complete", s.handleSetupComplete)
mux.HandleFunc(internalRoutePrefix+"/auth/login", s.handleLogin)
mux.HandleFunc(internalRoutePrefix+"/auth/logout", s.handleLogout)
mux.Handle(internalRoutePrefix+"/auth/settings", s.requireWebAuth(http.HandlerFunc(s.handleAuthSettings)))
mux.Handle(internalRoutePrefix+"/auth/settings/password", s.requireWebAuth(http.HandlerFunc(s.handleAuthPasswordChange)))
mux.Handle(internalRoutePrefix+"/api/invoke", s.requireWebAuth(http.HandlerFunc(s.handleInvoke)))
mux.Handle(internalRoutePrefix+"/events", s.requireWebAuth(http.HandlerFunc(s.handleEvents)))
mux.Handle(internalRoutePrefix+"/web-runtime.js", s.requireWebAuth(http.HandlerFunc(s.handleRuntimeBridge)))
mux.HandleFunc(internalRoutePrefix+"/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/login", s.handleLoginPage)
mux.HandleFunc("/setup", s.handleSetupPage)
fileServer := http.FileServer(http.FS(s.assets))
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, internalRoutePrefix+"/") {
http.NotFound(w, r)
return
}
status := s.auth.Status(func() string {
sessionID, _ := readSessionCookie(r)
return sessionID
}())
if !status.Configured {
http.Redirect(w, r, buildAuthRedirectURL("/setup", r.URL.RequestURI()), http.StatusSeeOther)
return
}
if !status.Authenticated {
clearSessionCookie(w, r)
http.Redirect(w, r, buildAuthRedirectURL("/login", r.URL.RequestURI()), http.StatusSeeOther)
return
}
if s.shouldServeIndex(r.URL.Path) {
s.serveIndex(w, r)
return
}
fileServer.ServeHTTP(w, r)
})
return withSecurityHeaders(mux)
}
func (s *Server) shouldServeIndex(requestPath string) bool {
cleaned := strings.TrimPrefix(path.Clean("/"+requestPath), "/")
if cleaned == "" || cleaned == "." {
return true
}
if strings.Contains(path.Base(cleaned), ".") {
file, err := s.assets.Open(cleaned)
if err != nil {
return false
}
defer file.Close()
info, err := file.Stat()
return err == nil && info.IsDir()
}
return true
}
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
payload, err := fs.ReadFile(s.assets, "index.html")
if err != nil {
http.Error(w, "frontend index is unavailable", http.StatusInternalServerError)
return
}
html := injectRuntimeBridge(string(payload))
w.Header().Set("Content-Type", "text/html; charset=utf-8")
http.ServeContent(w, r, "index.html", time.Time{}, strings.NewReader(html))
}
func injectRuntimeBridge(indexHTML string) string {
if strings.Contains(indexHTML, internalRoutePrefix+"/web-runtime.js") {
return indexHTML
}
scriptTag := fmt.Sprintf(`<script src="%s/web-runtime.js"></script>`, internalRoutePrefix)
if strings.Contains(indexHTML, "</head>") {
return strings.Replace(indexHTML, "</head>", scriptTag+"\n</head>", 1)
}
return scriptTag + "\n" + indexHTML
}
func (s *Server) handleInvoke(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var request invokeRequest
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
return
}
result, err := s.invoker.Invoke(request)
if err != nil {
s.writeInvokeResponse(w, http.StatusBadRequest, invokeResponse{Error: err.Error()})
return
}
s.writeInvokeResponse(w, http.StatusOK, invokeResponse{Result: result})
}
func (s *Server) writeInvokeResponse(w http.ResponseWriter, status int, response invokeResponse) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(response)
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ch := s.events.subscribe()
defer s.events.unsubscribe(ch)
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
_, _ = w.Write([]byte(": connected\n\n"))
flusher.Flush()
for {
select {
case <-r.Context().Done():
return
case <-ticker.C:
_, _ = w.Write([]byte(": ping\n\n"))
flusher.Flush()
case msg, ok := <-ch:
if !ok {
return
}
payload, err := json.Marshal(msg)
if err != nil {
continue
}
_, _ = fmt.Fprintf(w, "event: gonavi\ndata: %s\n\n", payload)
flusher.Flush()
}
}
}
func (s *Server) handleRuntimeBridge(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
_, _ = w.Write([]byte(runtimeBridgeScript()))
}
type ioDiscard struct{}
func (ioDiscard) Write(p []byte) (int, error) {
return len(p), nil
}