mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-14 09:06:37 +08:00
优化
This commit is contained in:
+6
-3
@@ -84,12 +84,15 @@ ENV MEBOX_APP_PORT=8080 \
|
|||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
# 探测协议不能写死:开启 HTTPS 后 8080 只接受 TLS。脚本按数据目录里的
|
||||||
CMD busybox wget -q --spider http://127.0.0.1:8080/api/health || exit 1
|
# .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
|
# 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.
|
# rewriting /etc/passwd or /etc/group on every container start.
|
||||||
COPY docker-entrypoint.sh /entrypoint.sh
|
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"]
|
CMD ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -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) }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
-18
@@ -7,6 +7,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -17,6 +19,14 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/service"
|
"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 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
// tlsPair 记录当前正在服务的证书,用于判断是否需要重新绑定监听。
|
||||||
type tlsPair struct {
|
type tlsPair struct {
|
||||||
cert tls.Certificate
|
cert tls.Certificate
|
||||||
@@ -35,6 +45,9 @@ type tlsPair struct {
|
|||||||
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
|
// - 旧服务器随后优雅退出,正在进行的播放/请求不会被立刻掐断。
|
||||||
//
|
//
|
||||||
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
|
// 任何校验失败都会中止切换并保留旧监听,保证用户不会被锁在服务外面。
|
||||||
|
//
|
||||||
|
// 开启 HTTPS 时端口上会同时接两类连接:TLS 走主服务器,明文 HTTP 走
|
||||||
|
// redirectSrv 并跳转到 HTTPS(见 protocol_mux.go)。
|
||||||
type serverManager struct {
|
type serverManager struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
@@ -43,6 +56,7 @@ type serverManager struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
|
redirectSrv *http.Server
|
||||||
ln net.Listener
|
ln net.Listener
|
||||||
pair *tlsPair
|
pair *tlsPair
|
||||||
stopCh chan struct{}
|
stopCh chan struct{}
|
||||||
@@ -92,11 +106,11 @@ func (m *serverManager) Reload() error {
|
|||||||
return nil
|
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 {
|
if oldLn != nil {
|
||||||
_ = oldLn.Close() // 释放端口后再绑定新监听
|
_ = 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)
|
firstErr := m.bind(pair)
|
||||||
if firstErr != nil {
|
if firstErr != nil {
|
||||||
@@ -107,6 +121,7 @@ func (m *serverManager) Reload() error {
|
|||||||
}
|
}
|
||||||
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
|
// 新监听已就绪,让旧服务器在新连接切换到新监听后优雅退出。
|
||||||
m.drain(oldSrv)
|
m.drain(oldSrv)
|
||||||
|
m.drain(oldRedirect)
|
||||||
m.logServerReady()
|
m.logServerReady()
|
||||||
m.maybeStartAutoReloadLocked()
|
m.maybeStartAutoReloadLocked()
|
||||||
return firstErr
|
return firstErr
|
||||||
@@ -120,11 +135,19 @@ func (m *serverManager) Shutdown(ctx context.Context) error {
|
|||||||
close(m.stopCh)
|
close(m.stopCh)
|
||||||
}
|
}
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
targets := []*http.Server{m.srv, m.redirectSrv}
|
||||||
if m.srv == nil {
|
m.mu.Unlock()
|
||||||
return nil
|
|
||||||
|
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。
|
// desiredPair 根据当前配置计算目标监听形态:nil 表示明文 HTTP,非 nil 表示 TLS。
|
||||||
@@ -217,25 +240,107 @@ func (m *serverManager) pairEquals(pair *tlsPair) bool {
|
|||||||
return pair.version == m.pair.version
|
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 {
|
func (m *serverManager) bind(pair *tlsPair) error {
|
||||||
ln, err := net.Listen("tcp", m.addr)
|
ln, err := net.Listen("tcp", m.addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("listen %s: %w", m.addr, err)
|
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,
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
}
|
}
|
||||||
if pair != nil {
|
}
|
||||||
ln = tls.NewListener(ln, &tls.Config{
|
|
||||||
Certificates: []tls.Certificate{pair.cert},
|
// httpsRedirectHandler 处理"开着 HTTPS 却用 http:// 访问"的请求:跳转到同端口、
|
||||||
MinVersion: tls.VersionTLS12,
|
// 同路径的 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://<host>:%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) {
|
func (m *serverManager) serve(s *http.Server, ln net.Listener) {
|
||||||
@@ -271,7 +376,7 @@ func (m *serverManager) logServerReady() {
|
|||||||
zap.String("listen", m.addr),
|
zap.String("listen", m.addr),
|
||||||
)
|
)
|
||||||
if m.pair != nil {
|
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),
|
zap.String("addr", m.addr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
"crypto/elliptic"
|
"crypto/elliptic"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -32,6 +36,8 @@ func makeTestPairPEM(t *testing.T) (certPEM, keyPEM string) {
|
|||||||
NotBefore: time.Now().Add(-time.Hour),
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
NotAfter: time.Now().Add(24 * time.Hour),
|
NotAfter: time.Now().Add(24 * time.Hour),
|
||||||
DNSNames: []string{"localhost"},
|
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)
|
der, err := x509.CreateCertificate(rand.Reader, tpl, tpl, &priv.PublicKey, priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -105,3 +111,136 @@ func TestDesiredPairModes(t *testing.T) {
|
|||||||
t.Fatal("version should change after files replaced")
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ services:
|
|||||||
# NAS 示例:source: /vol1/1000/Media
|
# NAS 示例:source: /vol1/1000/Media
|
||||||
# Windows Docker Desktop 示例:source: D:/Media
|
# Windows Docker Desktop 示例:source: D:/Media
|
||||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||||
- ./media: /media
|
- ./media:/media
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
@@ -105,7 +105,9 @@ services:
|
|||||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ services:
|
|||||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ services:
|
|||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ services:
|
|||||||
# NAS 示例:source: /vol1/1000/Media
|
# NAS 示例:source: /vol1/1000/Media
|
||||||
# Windows Docker Desktop 示例:source: D:/Media
|
# Windows Docker Desktop 示例:source: D:/Media
|
||||||
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
# create_host_path=false 可以避免路径写错时 Docker 自动创建空文件夹。
|
||||||
- ./media: /media
|
- ./media:/media
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
TZ: Asia/Shanghai
|
TZ: Asia/Shanghai
|
||||||
@@ -87,7 +87,9 @@ services:
|
|||||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
+3
-1
@@ -131,7 +131,9 @@ services:
|
|||||||
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
MEBOX_TRANSCODER_IDLE_TIMEOUT_SECONDS: "120"
|
||||||
|
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -50,7 +50,8 @@ type AppConfig struct {
|
|||||||
DataDir string `mapstructure:"data_dir"`
|
DataDir string `mapstructure:"data_dir"`
|
||||||
WebDir string `mapstructure:"web_dir"`
|
WebDir string `mapstructure:"web_dir"`
|
||||||
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
// HTTPSEnabled 是否仅通过 HTTPS 提供访问。启用时必须同时配置
|
||||||
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS。
|
// SSLCert / SSLKey(或 SSLCertPath / SSLKeyPath),保存后服务会热切换到 HTTPS;
|
||||||
|
// 同一端口上的明文 HTTP 请求会被跳转到 HTTPS,而不是直接拒绝。
|
||||||
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
HTTPSEnabled bool `mapstructure:"https_enabled"`
|
||||||
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
// SSLCert 是 PEM 编码的 SSL 证书内容。
|
||||||
SSLCert string `mapstructure:"ssl_cert"`
|
SSLCert string `mapstructure:"ssl_cert"`
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const generalSettingsGroup: SettingGroup = {
|
|||||||
key: 'https.enabled',
|
key: 'https.enabled',
|
||||||
label: '启用 HTTPS',
|
label: '启用 HTTPS',
|
||||||
type: 'toggle',
|
type: 'toggle',
|
||||||
hint: '开启后服务将仅通过 HTTPS 访问,保存后会自动切换到 https 地址,原 http 地址不再提供服务。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
|
hint: '开启后服务仅通过 HTTPS 提供访问,用 http:// 打开同一地址会被自动跳转到 https://(临时跳转,随时关掉 HTTPS 都不会被浏览器长期记住);保存后当前页面会自动切换到 https 地址。必须先在下文配置好 SSL 证书与私钥(内容或路径任选其一,两者需匹配)才能启用;首次切换因浏览器以 origin(协议+域名+端口)隔离登录态,需要重新登录一次。',
|
||||||
defaultValue: 'false',
|
defaultValue: 'false',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user