From 6864a9c7f13bea6e9b9f3fed5ac245db96e3982a Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:01:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 9 +- cmd/server/protocol_mux.go | 129 +++++++++++++++++++++++ cmd/server/protocol_mux_test.go | 140 +++++++++++++++++++++++++ cmd/server/server_manager.go | 141 ++++++++++++++++++++++---- cmd/server/server_manager_test.go | 139 +++++++++++++++++++++++++ docker-compose.metatube.yml | 6 +- docker-compose.search.yml | 4 +- docker-compose.simple.yml | 4 +- docker-compose.standard.yml | 6 +- docker-compose.yml | 4 +- docker-healthcheck.sh | 39 +++++++ internal/config/types.go | 3 +- web/src/pages/settingsGroupGeneral.ts | 2 +- 13 files changed, 596 insertions(+), 30 deletions(-) create mode 100644 cmd/server/protocol_mux.go create mode 100644 cmd/server/protocol_mux_test.go create mode 100644 docker-healthcheck.sh diff --git a/Dockerfile b/Dockerfile index 1adce9b..56f9f74 100644 --- a/Dockerfile +++ b/Dockerfile @@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \ EXPOSE 8080 -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1 +# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的 +# .http_scheme 选择协议并在失败时回退,无需用户改配置。 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD ["/usr/local/bin/mebox-healthcheck"] # Tiny entrypoint that lets us run as a NAS host UID/GID via PUID/PGID without # rewriting /etc/passwd or /etc/group on every container start. COPY docker-entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +COPY docker-healthcheck.sh /usr/local/bin/mebox-healthcheck +RUN chmod +x /entrypoint.sh /usr/local/bin/mebox-healthcheck CMD ["/entrypoint.sh"] diff --git a/cmd/server/protocol_mux.go b/cmd/server/protocol_mux.go new file mode 100644 index 0000000..a9181fc --- /dev/null +++ b/cmd/server/protocol_mux.go @@ -0,0 +1,129 @@ +package main + +import ( + "bufio" + "net" + "sync" + "time" +) + +// 开启 HTTPS 后,同一个端口会同时面对两类连接: +// +// - TLS 客户端(浏览器、播放器)——正常提供 HTTPS 服务; +// - 明确访问 http:// 的客户端——以前只会撞出一句 +// "Client sent an HTTP request to an HTTPS server.",用户看不懂。 +// +// 一个端口上只有 TLS 监听时无法区分两者,所以在 Accept 之后先偷看第一个字节: +// TLS 握手的记录类型固定是 0x16(handshake),而 HTTP 请求行必然是可见字符。 +// 据此把连接分流成两路,明文那一路交给只做跳转的 http.Server,于是 http:// 会被 +// 自动重定向到 https://(见 server_manager.go 的 httpsRedirectHandler)。 +const tlsRecordHandshake = 0x16 + +// defaultPeekTimeout 限制"偷看首字节"阶段的等待,避免只连不发的半开连接长期占住 +// goroutine。分流器按实例持有该值,便于测试用更短的超时。 +const defaultPeekTimeout = 10 * time.Second + +// splitListener 是监听分流器:Accept 到底层连接后按首字节投递给对应的一路。 +type splitListener struct { + ln net.Listener + tlsCh chan net.Conn + plainCh chan net.Conn + quit chan struct{} + once sync.Once + peek time.Duration +} + +func newSplitListener(ln net.Listener) *splitListener { + return newSplitListenerWithTimeout(ln, defaultPeekTimeout) +} + +func newSplitListenerWithTimeout(ln net.Listener, peek time.Duration) *splitListener { + s := &splitListener{ + ln: ln, + tlsCh: make(chan net.Conn), + plainCh: make(chan net.Conn), + quit: make(chan struct{}), + peek: peek, + } + go s.acceptLoop() + return s +} + +// tlsListener 返回只收到 TLS 连接的 listener。 +func (s *splitListener) tlsListener() net.Listener { + return &channelListener{addr: s.ln.Addr(), conns: s.tlsCh, quit: s.quit} +} + +// plainListener 返回只收到明文 HTTP 连接的 listener。 +func (s *splitListener) plainListener() net.Listener { + return &channelListener{addr: s.ln.Addr(), conns: s.plainCh, quit: s.quit} +} + +func (s *splitListener) acceptLoop() { + for { + conn, err := s.ln.Accept() + if err != nil { + // 底层监听关闭(换证、关停)后两路 Accept 一起返回 net.ErrClosed, + // 由持有它们的 http.Server 自行收尾。 + s.stop() + return + } + go s.dispatch(conn) + } +} + +func (s *splitListener) dispatch(conn net.Conn) { + br := bufio.NewReader(conn) + _ = conn.SetReadDeadline(time.Now().Add(s.peek)) + first, err := br.Peek(1) + _ = conn.SetReadDeadline(time.Time{}) + if err != nil { + _ = conn.Close() + return + } + + peeked := &peekedConn{Conn: conn, r: br} + ch := s.plainCh + if first[0] == tlsRecordHandshake { + ch = s.tlsCh + } + select { + case ch <- peeked: // 交给对应的 http.Server,由它负责读写与关闭 + case <-s.quit: + _ = conn.Close() + } +} + +func (s *splitListener) stop() { + s.once.Do(func() { close(s.quit) }) +} + +// channelListener 把一路 channel 包装成 net.Listener,供 http.Server.Serve 使用。 +type channelListener struct { + addr net.Addr + conns <-chan net.Conn + quit <-chan struct{} +} + +func (l *channelListener) Accept() (net.Conn, error) { + select { + case conn := <-l.conns: + return conn, nil + case <-l.quit: + return nil, net.ErrClosed + } +} + +// Close 是空实现:真正要释放的端口在 splitListener 的底层监听上,它一旦关闭, +// 这里的 Accept 就会返回 net.ErrClosed,http.Server 随之退出。 +func (l *channelListener) Close() error { return nil } + +func (l *channelListener) Addr() net.Addr { return l.addr } + +// peekedConn 把已经读进 bufio 缓冲的首字节还给上层读取者。 +type peekedConn struct { + net.Conn + r *bufio.Reader +} + +func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) } diff --git a/cmd/server/protocol_mux_test.go b/cmd/server/protocol_mux_test.go new file mode 100644 index 0000000..d078cc9 --- /dev/null +++ b/cmd/server/protocol_mux_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "io" + "net" + "net/http" + "testing" + "time" +) + +func TestSplitListenerRoutesByFirstByte(t *testing.T) { + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + split := newSplitListener(raw) + addr := raw.Addr().String() + + // TLS:首字节 0x16。分流器偷看过的字节必须还能被上层读到,否则握手会失败。 + handshake := []byte{0x16, 0x03, 0x01, 0x00, 0x2c} + tlsClient, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer func() { _ = tlsClient.Close() }() + if _, err := tlsClient.Write(handshake); err != nil { + t.Fatal(err) + } + tlsConn := <-split.tlsCh + defer func() { _ = tlsConn.Close() }() + got := make([]byte, len(handshake)) + if _, err := io.ReadFull(tlsConn, got); err != nil { + t.Fatalf("read peeked tls bytes: %v", err) + } + if string(got) != string(handshake) { + t.Fatalf("tls bytes = %v, want %v", got, handshake) + } + + // 明文 HTTP:请求行走另一路,同样要读到偷看过的首字节。 + request := "GET /api/health HTTP/1.1\r\nHost: example.com\r\n\r\n" + plainClient, err := net.Dial("tcp", addr) + if err != nil { + t.Fatal(err) + } + defer func() { _ = plainClient.Close() }() + if _, err := plainClient.Write([]byte(request)); err != nil { + t.Fatal(err) + } + plainConn := <-split.plainCh + defer func() { _ = plainConn.Close() }() + head := make([]byte, len("GET /")) + if _, err := io.ReadFull(plainConn, head); err != nil { + t.Fatalf("read peeked plain bytes: %v", err) + } + if string(head) != request[:len(head)] { + t.Fatalf("plain bytes = %q, want prefix of %q", head, request) + } + + // 底层监听关闭后两路 Accept 都要返回 net.ErrClosed,http.Server 才能收尾。 + _ = raw.Close() + for _, ln := range []net.Listener{split.tlsListener(), split.plainListener()} { + deadline := time.Now().Add(3 * time.Second) + for { + _, err := ln.Accept() + if err == net.ErrClosed { + break + } + if time.Now().After(deadline) { + t.Fatalf("Accept after close = %v, want net.ErrClosed", err) + } + time.Sleep(10 * time.Millisecond) + } + } +} + +// 只连不发的半开连接不应被分给任何一路,而应在偷看超时后被丢弃。 +func TestSplitListenerDropsIdleConnection(t *testing.T) { + raw, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer func() { _ = raw.Close() }() + split := newSplitListenerWithTimeout(raw, 200*time.Millisecond) + + conn, err := net.Dial("tcp", raw.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer func() { _ = conn.Close() }() + + select { + case c := <-split.plainCh: + _ = c.Close() + t.Fatal("idle connection should not be dispatched") + case c := <-split.tlsCh: + _ = c.Close() + t.Fatal("idle connection should not be dispatched") + case <-time.After(time.Second): + } +} + +func TestHTTPSRedirectTarget(t *testing.T) { + cases := []struct { + name string + host string + rawURL string + want string + wantErr bool + }{ + {name: "keeps host port path and query", host: "10.0.0.2:8080", rawURL: "/library?tab=1&q=a%20b", want: "https://10.0.0.2:8080/library?tab=1&q=a%20b"}, + {name: "root path", host: "nas.local:18080", rawURL: "/", want: "https://nas.local:18080/"}, + {name: "keeps escaping of user input", host: "10.0.0.2", rawURL: "/a%2Fb", want: "https://10.0.0.2/a%2Fb"}, + {name: "absolute form request line", host: "10.0.0.2:8080", rawURL: "http://10.0.0.2:8080/api/x?y=1", want: "https://10.0.0.2:8080/api/x?y=1"}, + {name: "rejects empty host", host: "", rawURL: "/", wantErr: true}, + {name: "rejects host with slash", host: "evil.com/path", rawURL: "/", wantErr: true}, + {name: "rejects userinfo host", host: "user@evil.com", rawURL: "/", wantErr: true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, tc.rawURL, nil) + if err != nil { + t.Fatal(err) + } + req.Host = tc.host + + if host := redirectHostHeader(req); host == "" { + if !tc.wantErr { + t.Fatalf("redirectHostHeader(%q) rejected, want accepted", tc.host) + } + return + } + if tc.wantErr { + t.Fatalf("redirectHostHeader(%q) accepted, want rejected", tc.host) + } + if got := httpsRedirectURL(tc.host, req); got != tc.want { + t.Fatalf("redirect target = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/cmd/server/server_manager.go b/cmd/server/server_manager.go index abe2526..2568521 100644 --- a/cmd/server/server_manager.go +++ b/cmd/server/server_manager.go @@ -7,6 +7,8 @@ import ( "fmt" "net" "net/http" + "os" + "path/filepath" "strings" "sync" "time" @@ -17,6 +19,14 @@ import ( "github.com/truewhile/MeBox/internal/service" ) +// httpSchemeFileName 是数据目录下记录当前监听协议的小文件(内容为 http 或 https)。 +// +// 容器健康检查无法读取后台保存的 HTTPS 设置(它存在数据库里),而同一端口在开启 +// HTTPS 后只接受 TLS。若健康检查固定用 http:// 探测,每轮都会撞出 +// "client sent an HTTP request to an HTTPS server",既刷日志又让容器永远 unhealthy。 +// 因此由服务进程在每次成功绑定监听后写下真实协议,供 docker-healthcheck.sh 选择。 +const httpSchemeFileName = ".http_scheme" + // tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。 type tlsPair struct { cert tls.Certificate @@ -35,6 +45,9 @@ type tlsPair struct { // - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。 // // 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。 +// +// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走 +// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。 type serverManager struct { cfg *config.Config log *zap.Logger @@ -43,6 +56,7 @@ type serverManager struct { mu sync.Mutex srv *http.Server + redirectSrv *http.Server ln net.Listener pair *tlsPair stopCh chan struct{} @@ -92,11 +106,11 @@ func (m *serverManager) Reload() error { return nil } - oldSrv, oldLn, oldPair := m.srv, m.ln, m.pair + oldSrv, oldRedirect, oldLn, oldPair := m.srv, m.redirectSrv, m.ln, m.pair if oldLn != nil { _ = oldLn.Close() // 释放端口后再绑定新监听 } - m.srv, m.ln, m.pair = nil, nil, nil + m.srv, m.redirectSrv, m.ln, m.pair = nil, nil, nil, nil firstErr := m.bind(pair) if firstErr != nil { @@ -107,6 +121,7 @@ func (m *serverManager) Reload() error { } // 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。 m.drain(oldSrv) + m.drain(oldRedirect) m.logServerReady() m.maybeStartAutoReloadLocked() return firstErr @@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error { close(m.stopCh) } m.mu.Lock() - defer m.mu.Unlock() - if m.srv == nil { - return nil + targets := []*http.Server{m.srv, m.redirectSrv} + m.mu.Unlock() + + var firstErr error + for _, srv := range targets { + if srv == nil { + continue + } + if err := srv.Shutdown(ctx); err != nil && firstErr == nil { + firstErr = err + } } - return m.srv.Shutdown(ctx) + return firstErr } // desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。 @@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool { return pair.version == m.pair.version } -// bind 创建并按需启用 TLS 的监听,异步开始服务。 +// bind 创建监听,异步开始服务。 +// +// 未开启 HTTPS 时端口只服务明文 HTTP;开启后同一个端口两种流量都要接:TLS 连接 +// 交给主服务器,明文 HTTP 连接交给只做跳转的 redirect 服务器,让 http:// 自动 +// 跳到 https://,而不是甩给用户一句 "Client sent an HTTP request to an HTTPS +// server."。 func (m *serverManager) bind(pair *tlsPair) error { ln, err := net.Listen("tcp", m.addr) if err != nil { return fmt.Errorf("listen %s: %w", m.addr, err) } - srv := &http.Server{ - Handler: m.handler, + + if pair == nil { + srv := m.newHTTPServer(m.handler) + m.srv, m.redirectSrv, m.ln, m.pair = srv, nil, ln, nil + m.writeSchemeState(nil) + go m.serve(srv, ln) + return nil + } + + split := newSplitListener(ln) + srv := m.newHTTPServer(m.handler) + tlsLn := tls.NewListener(split.tlsListener(), &tls.Config{ + Certificates: []tls.Certificate{pair.cert}, + MinVersion: tls.VersionTLS12, + }) + redirect := m.newHTTPServer(m.httpsRedirectHandler()) + + m.srv, m.redirectSrv, m.ln, m.pair = srv, redirect, ln, pair + m.writeSchemeState(pair) + go m.serve(srv, tlsLn) + go m.serve(redirect, split.plainListener()) + return nil +} + +func (m *serverManager) newHTTPServer(handler http.Handler) *http.Server { + return &http.Server{ + Handler: handler, ReadHeaderTimeout: 15 * time.Second, } - if pair != nil { - ln = tls.NewListener(ln, &tls.Config{ - Certificates: []tls.Certificate{pair.cert}, - MinVersion: tls.VersionTLS12, - }) +} + +// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、 +// 同路径的 https://。 +// +// 用 302/307 而不是 301/308:HTTPS 开关可以在后台随时改,永久跳转会被浏览器长期 +// 缓存,等用户关掉 HTTPS 之后 http 访问会被带到已经不提供 TLS 的端口上打不开。 +func (m *serverManager) httpsRedirectHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host := redirectHostHeader(r) + if host == "" { + // 没有可用的 Host(HTTP/1.0 或畸形请求头)时宁可明说,也不跳到错的地方。 + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintf(w, "HTTPS is enabled; open https://:%d instead.\n", m.cfg.App.Port) + return + } + status := http.StatusFound + if r.Method != http.MethodGet && r.Method != http.MethodHead { + status = http.StatusTemporaryRedirect // 保住方法与请求体 + } + w.Header().Set("Location", httpsRedirectURL(host, r)) + w.WriteHeader(status) + }) +} + +// httpsRedirectURL 拼出同端口同路径的 HTTPS 地址。 +func httpsRedirectURL(host string, r *http.Request) string { + target := "https://" + host + r.URL.EscapedPath() + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + return target +} + +// redirectHostHeader 取重定向目标主机。HTTP/1.1 里 Host 是唯一的主机来源(端口映射 +// 到容器 8080 时它同时也是 HTTPS 端口),这里只做字符过滤,避免把请求头里的怪字符 +// 带进 Location 造成任意跳转。 +func redirectHostHeader(r *http.Request) string { + host := strings.TrimSpace(r.Host) + if host == "" || strings.ContainsAny(host, " \t\r\n/\\@?#") { + return "" + } + return host +} + +// writeSchemeState 把当前实际监听的协议写入数据目录,供容器健康检查按协议探测。 +// 写失败只降级为告警:健康检查脚本在状态文件缺失时会回退试另一种协议。 +func (m *serverManager) writeSchemeState(pair *tlsPair) { + dataDir := strings.TrimSpace(m.cfg.App.DataDir) + if dataDir == "" { + return + } + scheme := "http" + if pair != nil { + scheme = "https" + } + path := filepath.Join(dataDir, httpSchemeFileName) + if err := os.WriteFile(path, []byte(scheme+"\n"), 0o644); err != nil { // #nosec G304 -- path is DataDir + fixed filename. + m.log.Warn("write http scheme state failed", zap.String("path", path), zap.Error(err)) } - m.srv, m.ln, m.pair = srv, ln, pair - go m.serve(srv, ln) - return nil } func (m *serverManager) serve(s *http.Server, ln net.Listener) { @@ -271,7 +376,7 @@ func (m *serverManager) logServerReady() { zap.String("listen", m.addr), ) if m.pair != nil { - m.log.Info("HTTPS is enabled; plain HTTP is no longer served on this port", + m.log.Info("HTTPS is enabled; plain HTTP requests on this port are redirected to HTTPS", zap.String("addr", m.addr), ) } diff --git a/cmd/server/server_manager_test.go b/cmd/server/server_manager_test.go index 24c0412..09392d2 100644 --- a/cmd/server/server_manager_test.go +++ b/cmd/server/server_manager_test.go @@ -1,13 +1,17 @@ package main import ( + "context" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" + "io" "math/big" + "net" "net/http" "os" "path/filepath" @@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) { NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(24 * time.Hour), DNSNames: []string{"localhost"}, + // 带上回环 IP,HTTPS 客户端就能在校验证书的前提下连 127.0.0.1:port。 + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, } der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv) if err != nil { @@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) { t.Fatal("version should change after files replaced") } } + +// 健康检查依赖该文件判断该用 http 还是 https 探测,内容与当前监听协议必须一致, +// 否则容器会一直显示 unhealthy 并刷 TLS handshake error 日志。 +func TestWriteSchemeState(t *testing.T) { + m := newTestServerManager(t) + m.cfg.App.DataDir = t.TempDir() + path := filepath.Join(m.cfg.App.DataDir, httpSchemeFileName) + + m.writeSchemeState(nil) + if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "http" { + t.Fatalf("plain HTTP state: data=%q err=%v", data, err) + } + + m.writeSchemeState(&tlsPair{}) + if data, err := os.ReadFile(path); err != nil || strings.TrimSpace(string(data)) != "https" { + t.Fatalf("HTTPS state: data=%q err=%v", data, err) + } + + // DataDir 为空时不应写到工作目录(测试/裸跑二进制时会污染仓库)。 + m.cfg.App.DataDir = "" + _ = os.Remove(path) + m.writeSchemeState(nil) + if _, err := os.Stat(httpSchemeFileName); !os.IsNotExist(err) { + t.Fatalf("empty DataDir should not create %s in cwd: %v", httpSchemeFileName, err) + } +} + +// 开启 HTTPS 后同一个端口要同时接 TLS 与明文 HTTP:明文请求跳到 HTTPS,而不是 +// 给用户看 "Client sent an HTTP request to an HTTPS server."。 +func TestServerManagerRedirectsPlainHTTPToHTTPS(t *testing.T) { + certPEM, keyPEM := makeTestPairPEM(t) + cfg := &config.Config{} + cfg.App.Port = 8080 + cfg.App.DataDir = t.TempDir() + cfg.App.HTTPSEnabled = true + cfg.App.SSLCert, cfg.App.SSLKey = certPEM, keyPEM + + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "secure:"+r.URL.Path) + }) + m := newServerManager(cfg, zap.NewNop(), handler) + m.addr = "127.0.0.1:0" // 端口 0 交给内核分配,测试之间不会撞端口 + if err := m.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = m.Shutdown(context.Background()) }) + + addr := m.ln.Addr().String() + // 端口 0 只在第一次分配时用;热切换要绑回同一个端口(生产里端口本来就是固定的)。 + m.addr = addr + schemeFile := filepath.Join(cfg.App.DataDir, httpSchemeFileName) + if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "https" { + t.Fatalf("scheme state = %q err=%v, want https", data, err) + } + + plain := &http.Client{ + Timeout: 10 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + + resp, err := plain.Get("http://" + addr + "/library?tab=1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusFound { + t.Fatalf("plain GET status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got, want := resp.Header.Get("Location"), "https://"+addr+"/library?tab=1"; got != want { + t.Fatalf("Location = %q, want %q", got, want) + } + + postResp, err := plain.Post("http://"+addr+"/api/me/login", "application/json", strings.NewReader("{}")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = postResp.Body.Close() }() + if postResp.StatusCode != http.StatusTemporaryRedirect { + t.Fatalf("plain POST status = %d, want %d", postResp.StatusCode, http.StatusTemporaryRedirect) + } + if got, want := postResp.Header.Get("Location"), "https://"+addr+"/api/me/login"; got != want { + t.Fatalf("POST Location = %q, want %q", got, want) + } + + // 同一端口上的 TLS 连接照常拿到正常响应(校验证书,不做跳过校验)。 + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM([]byte(certPEM)) { + t.Fatal("append test certificate to pool failed") + } + secure := &http.Client{ + Timeout: 10 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }, + } + secureResp, err := secure.Get("https://" + addr + "/library") + if err != nil { + t.Fatal(err) + } + defer func() { _ = secureResp.Body.Close() }() + body, err := io.ReadAll(secureResp.Body) + if err != nil { + t.Fatal(err) + } + if secureResp.StatusCode != http.StatusOK || string(body) != "secure:/library" { + t.Fatalf("https response = %d %q, want 200 %q", secureResp.StatusCode, body, "secure:/library") + } + + // 关掉 HTTPS 后同一端口回到纯明文服务,不再有多余的跳转服务器。 + cfg.App.HTTPSEnabled = false + if err := m.Reload(); err != nil { + t.Fatal(err) + } + if m.redirectSrv != nil { + t.Fatal("redirect server should be released when HTTPS is off") + } + if data, err := os.ReadFile(schemeFile); err != nil || strings.TrimSpace(string(data)) != "http" { + t.Fatalf("scheme state = %q err=%v, want http", data, err) + } + plainResp, err := plain.Get("http://" + addr + "/library") + if err != nil { + t.Fatal(err) + } + defer func() { _ = plainResp.Body.Close() }() + plainBody, err := io.ReadAll(plainResp.Body) + if err != nil { + t.Fatal(err) + } + if plainResp.StatusCode != http.StatusOK || string(plainBody) != "secure:/library" { + t.Fatalf("plain response after disabling https = %d %q", plainResp.StatusCode, plainBody) + } +} diff --git a/docker-compose.metatube.yml b/docker-compose.metatube.yml index 62e37c8..b8b014e 100644 --- a/docker-compose.metatube.yml +++ b/docker-compose.metatube.yml @@ -62,7 +62,7 @@ services: # NAS 示例:source: /vol1/1000/Media # Windows Docker Desktop 示例:source: D:/Media # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - ./media: /media + - ./media:/media environment: TZ: Asia/Shanghai @@ -105,7 +105,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.search.yml b/docker-compose.search.yml index 5614ac2..85d9e75 100644 --- a/docker-compose.search.yml +++ b/docker-compose.search.yml @@ -115,7 +115,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.simple.yml b/docker-compose.simple.yml index 1be9d65..c6d8729 100644 --- a/docker-compose.simple.yml +++ b/docker-compose.simple.yml @@ -73,7 +73,9 @@ services: - "host.docker.internal:host-gateway" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.standard.yml b/docker-compose.standard.yml index e6c13a0..a656261 100644 --- a/docker-compose.standard.yml +++ b/docker-compose.standard.yml @@ -44,7 +44,7 @@ services: # NAS 示例:source: /vol1/1000/Media # Windows Docker Desktop 示例:source: D:/Media # create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。 - - ./media: /media + - ./media:/media environment: TZ: Asia/Shanghai @@ -87,7 +87,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-compose.yml b/docker-compose.yml index 97bef74..272e779 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,9 @@ services: MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120" healthcheck: - test: ["CMD-SHELL", "busybox wget -qO- http://127.0.0.1:8080/api/health || exit 1"] + # 不能写死 http://:开启 HTTPS 后 8080 只接受 TLS,固定明文探测会 + # 每轮刷一条 TLS handshake error 并让容器一直 unhealthy。 + test: ["CMD", "/usr/local/bin/mebox-healthcheck"] interval: 30s timeout: 10s retries: 5 diff --git a/docker-healthcheck.sh b/docker-healthcheck.sh new file mode 100644 index 0000000..65d430c --- /dev/null +++ b/docker-healthcheck.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# MeBox 容器健康检查。 +# +# 背景:MeBox 只监听一个端口,且开启 HTTPS 后该端口只接受 TLS(明文 HTTP 不再 +# 服务)。健康检查若固定用 http:// 探测,每轮都会被 TLS 监听拒绝并在服务日志里 +# 留下一条 "client sent an HTTP request to an HTTPS server",同时容器状态永远 +# 是 unhealthy。反过来固定用 https:// 探测,则在未开启 HTTPS 的部署上产生 +# "first record does not look like a TLS handshake"。 +# +# 当前协议由服务进程写入 $MEBOX_APP_DATA_DIR/.http_scheme(见 +# cmd/server/server_manager.go 的 writeSchemeState)。脚本优先按该文件选择协议, +# 文件缺失或该协议探测失败时再试另一种,避免状态文件过期导致误报不健康。 +set -u + +port="${MEBOX_APP_PORT:-8080}" +data_dir="${MEBOX_APP_DATA_DIR:-/data}" + +scheme_file="$data_dir/.http_scheme" +scheme="" +if [ -r "$scheme_file" ]; then + scheme="$(tr -d '[:space:]' <"$scheme_file")" +fi +if [ "$scheme" = "https" ]; then + first="https" + second="http" +else + first="http" + second="https" +fi + +for proto in "$first" "$second"; do + # --no-check-certificate:用户常用自签证书或 IP 访问,校验域名必然会失败。 + if busybox wget -q --spider -T 3 --no-check-certificate \ + "$proto://127.0.0.1:$port/api/health" >/dev/null 2>&1; then + exit 0 + fi +done + +exit 1 diff --git a/internal/config/types.go b/internal/config/types.go index 1560711..6106dd2 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -50,7 +50,8 @@ type AppConfig struct { DataDir string `mapstructure:"data_dir"` WebDir string `mapstructure:"web_dir"` // HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置 - // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。 + // SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS; + // 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。 HTTPSEnabled bool `mapstructure:"https_enabled"` // SSLCert 是 PEM 编码的 SSL 证书内容。 SSLCert string `mapstructure:"ssl_cert"` diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index f8c5a48..055b647 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -142,7 +142,7 @@ export const generalSettingsGroup: SettingGroup = { key: 'https.enabled', label: '启用 HTTPS', type: 'toggle', - hint: '开启后服务将仅通过 HTTPS 访问,保存后会自动切换到 https 地址,原 http 地址不再提供服务。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。', + hint: '开启后服务仅通过 HTTPS 提供访问,用 http:// 打开同一地址会被自动跳转到 https://(临时跳转,随时关掉 HTTPS 都不会被浏览器长期记住);保存后当前页面会自动切换到 https 地址。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。', defaultValue: 'false', }, {