🎨 Implement HTTPS network serving (#16912)

* Add use TLS for network serving configuration option

* kernel: Implement TLS certificate generation

* kernel: server: Use https for fixed port proxy when needed

* Allow exporting the CA Certificate file

* Implement import and export of CA Certs
This commit is contained in:
Davide Garberi 2026-01-27 05:59:11 +01:00 committed by GitHub
parent e7621b7a5f
commit 43ea6757d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 759 additions and 10 deletions

View file

@ -42,6 +42,10 @@ func ServeAPI(ginServer *gin.Engine) {
ginServer.Handle("POST", "/api/system/setAccessAuthCode", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setAccessAuthCode)
ginServer.Handle("POST", "/api/system/setFollowSystemLockScreen", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setFollowSystemLockScreen)
ginServer.Handle("POST", "/api/system/setNetworkServe", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setNetworkServe)
ginServer.Handle("POST", "/api/system/setNetworkServeTLS", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setNetworkServeTLS)
ginServer.Handle("POST", "/api/system/exportTLSCACert", model.CheckAuth, model.CheckAdminRole, exportTLSCACert)
ginServer.Handle("POST", "/api/system/exportTLSCABundle", model.CheckAuth, model.CheckAdminRole, exportTLSCABundle)
ginServer.Handle("POST", "/api/system/importTLSCABundle", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, importTLSCABundle)
ginServer.Handle("POST", "/api/system/setAutoLaunch", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setAutoLaunch)
ginServer.Handle("POST", "/api/system/setDownloadInstallPkg", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setDownloadInstallPkg)
ginServer.Handle("POST", "/api/system/setNetworkProxy", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setNetworkProxy)

View file

@ -720,6 +720,173 @@ func setNetworkServe(c *gin.Context) {
time.Sleep(time.Second * 3)
}
func setNetworkServeTLS(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
arg, ok := util.JsonArg(c, ret)
if !ok {
return
}
networkServeTLS := arg["networkServeTLS"].(bool)
model.Conf.System.NetworkServeTLS = networkServeTLS
model.Conf.Save()
util.PushMsg(model.Conf.Language(42), 1000*15)
time.Sleep(time.Second * 3)
}
func exportTLSCACert(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
caCertPath := filepath.Join(util.ConfDir, util.TLSCACertFilename)
if !gulu.File.IsExist(caCertPath) {
ret.Code = -1
ret.Msg = "CA certificate not found"
return
}
tmpDir := filepath.Join(util.TempDir, "export")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
exportPath := filepath.Join(tmpDir, util.TLSCACertFilename)
if err := gulu.File.CopyFile(caCertPath, exportPath); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
ret.Data = map[string]interface{}{
"path": "/export/" + util.TLSCACertFilename,
}
}
func exportTLSCABundle(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
caCertPath := filepath.Join(util.ConfDir, util.TLSCACertFilename)
caKeyPath := filepath.Join(util.ConfDir, util.TLSCAKeyFilename)
if !gulu.File.IsExist(caCertPath) || !gulu.File.IsExist(caKeyPath) {
ret.Code = -1
ret.Msg = "CA certificate not found, please enable TLS first"
return
}
tmpDir := filepath.Join(util.TempDir, "export", "ca-bundle")
os.RemoveAll(tmpDir)
if err := os.MkdirAll(tmpDir, 0755); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
defer os.RemoveAll(tmpDir)
if err := gulu.File.CopyFile(caCertPath, filepath.Join(tmpDir, util.TLSCACertFilename)); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
if err := gulu.File.CopyFile(caKeyPath, filepath.Join(tmpDir, util.TLSCAKeyFilename)); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
zipPath := filepath.Join(util.TempDir, "export", "ca-bundle.zip")
zipFile, err := gulu.Zip.Create(zipPath)
if err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
if err := zipFile.AddDirectory("", tmpDir); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
if err := zipFile.Close(); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
ret.Data = map[string]interface{}{
"path": "/export/ca-bundle.zip",
}
}
func importTLSCABundle(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)
file, err := c.FormFile("file")
if err != nil {
ret.Code = -1
ret.Msg = "file is required: " + err.Error()
return
}
tmpDir := filepath.Join(util.TempDir, "import")
if err := os.MkdirAll(tmpDir, 0755); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
tmpZipPath := filepath.Join(tmpDir, "ca-bundle.zip")
if err := c.SaveUploadedFile(file, tmpZipPath); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
defer os.Remove(tmpZipPath)
extractDir := filepath.Join(tmpDir, "ca-bundle")
os.RemoveAll(extractDir)
if err := gulu.Zip.Unzip(tmpZipPath, extractDir); err != nil {
ret.Code = -1
ret.Msg = "failed to extract zip file: " + err.Error()
return
}
defer os.RemoveAll(extractDir)
caCertPath := filepath.Join(extractDir, util.TLSCACertFilename)
caCertPEM, err := os.ReadFile(caCertPath)
if err != nil {
ret.Code = -1
ret.Msg = "ca.crt not found in zip file"
return
}
caKeyPath := filepath.Join(extractDir, util.TLSCAKeyFilename)
caKeyPEM, err := os.ReadFile(caKeyPath)
if err != nil {
ret.Code = -1
ret.Msg = "ca.key not found in zip file"
return
}
if err := util.ImportCABundle(string(caCertPEM), string(caKeyPEM)); err != nil {
ret.Code = -1
ret.Msg = err.Error()
return
}
ret.Data = map[string]interface{}{
"msg": "CA bundle imported successfully. Please restart to apply changes.",
}
}
func setAutoLaunch(c *gin.Context) {
ret := gulu.Ret.NewResult()
defer c.JSON(http.StatusOK, ret)

View file

@ -36,8 +36,9 @@ type System struct {
ConfDir string `json:"confDir"`
DataDir string `json:"dataDir"`
NetworkServe bool `json:"networkServe"` // 是否开启网络伺服
NetworkProxy *NetworkProxy `json:"networkProxy"`
NetworkServe bool `json:"networkServe"` // 是否开启网络伺服
NetworkServeTLS bool `json:"networkServeTLS"` // 是否开启 HTTPS 网络伺服
NetworkProxy *NetworkProxy `json:"networkProxy"`
DownloadInstallPkg bool `json:"downloadInstallPkg"`
AutoLaunch2 int `json:"autoLaunch2"` // 0不自动启动1自动启动2自动启动+隐藏主窗口

View file

@ -17,6 +17,7 @@
package proxy
import (
"crypto/tls"
"net/http"
"net/http/httputil"
@ -24,7 +25,7 @@ import (
"github.com/siyuan-note/siyuan/kernel/util"
)
func InitFixedPortService(host string) {
func InitFixedPortService(host string, useTLS bool, certPath, keyPath string) {
if util.FixedPort != util.ServerPort {
if util.IsPortOpen(util.FixedPort) {
return
@ -32,9 +33,23 @@ func InitFixedPortService(host string) {
// 启动一个固定 6806 端口的反向代理服务器,这样浏览器扩展才能直接使用 127.0.0.1:6806不用配置端口
proxy := httputil.NewSingleHostReverseProxy(util.ServerURL)
logging.LogInfof("fixed port service [%s:%s] is running", host, util.FixedPort)
if proxyErr := http.ListenAndServe(host+":"+util.FixedPort, proxy); nil != proxyErr {
logging.LogWarnf("boot fixed port service [%s] failed: %s", util.ServerURL, proxyErr)
if useTLS {
proxy.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}
if useTLS {
logging.LogInfof("fixed port service [%s:%s] is running with TLS", host, util.FixedPort)
if proxyErr := http.ListenAndServeTLS(host+":"+util.FixedPort, certPath, keyPath, proxy); nil != proxyErr {
logging.LogWarnf("boot fixed port service [%s] failed: %s", util.ServerURL, proxyErr)
}
} else {
logging.LogInfof("fixed port service [%s:%s] is running", host, util.FixedPort)
if proxyErr := http.ListenAndServe(host+":"+util.FixedPort, proxy); nil != proxyErr {
logging.LogWarnf("boot fixed port service [%s] failed: %s", util.ServerURL, proxyErr)
}
}
logging.LogInfof("fixed port service [%s:%s] is stopped", host, util.FixedPort)
}

View file

@ -210,14 +210,32 @@ func Serve(fastMode bool, cookieKey string) {
if !fastMode {
rewritePortJSON(pid, port)
}
logging.LogInfof("kernel [pid=%s] http server [%s] is booting", pid, host+":"+port)
// Prepare TLS if enabled
var certPath, keyPath string
useTLS := model.Conf.System.NetworkServeTLS && model.Conf.System.NetworkServe
if useTLS {
// Ensure TLS certificates exist (proxy will use them directly)
var tlsErr error
certPath, keyPath, tlsErr = util.GetOrCreateTLSCert()
if tlsErr != nil {
logging.LogErrorf("failed to get TLS certificates: %s", tlsErr)
if !fastMode {
os.Exit(logging.ExitCodeUnavailablePort)
}
return
}
logging.LogInfof("kernel [pid=%s] http server [%s] is booting (TLS will be enabled on fixed port proxy)", pid, host+":"+port)
} else {
logging.LogInfof("kernel [pid=%s] http server [%s] is booting", pid, host+":"+port)
}
util.HttpServing = true
go util.HookUILoaded()
go func() {
time.Sleep(1 * time.Second)
go proxy.InitFixedPortService(host)
go proxy.InitFixedPortService(host, useTLS, certPath, keyPath)
go proxy.InitPublishService()
// 反代服务器启动失败不影响核心服务器启动
}()

337
kernel/util/cert.go Normal file
View file

@ -0,0 +1,337 @@
// SiYuan - Refactor your thinking
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package util
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"path/filepath"
"time"
"github.com/88250/gulu"
"github.com/siyuan-note/logging"
)
const (
TLSCACertFilename = "ca.crt"
TLSCAKeyFilename = "ca.key"
TLSCertFilename = "cert.pem"
TLSKeyFilename = "key.pem"
)
// Returns paths to existing TLS certificates or generates new ones signed by a local CA.
// Certificates are stored in the conf directory of the workspace.
func GetOrCreateTLSCert() (certPath, keyPath string, err error) {
certPath = filepath.Join(ConfDir, TLSCertFilename)
keyPath = filepath.Join(ConfDir, TLSKeyFilename)
caCertPath := filepath.Join(ConfDir, TLSCACertFilename)
caKeyPath := filepath.Join(ConfDir, TLSCAKeyFilename)
if !gulu.File.IsExist(caCertPath) || !gulu.File.IsExist(caKeyPath) {
logging.LogInfof("generating local CA for TLS...")
if err = generateCACert(caCertPath, caKeyPath); err != nil {
logging.LogErrorf("failed to generate CA certificates: %s", err)
return "", "", err
}
}
if gulu.File.IsExist(certPath) && gulu.File.IsExist(keyPath) {
if validateCert(certPath) {
logging.LogInfof("using existing TLS certificates from [%s]", ConfDir)
return certPath, keyPath, nil
}
logging.LogInfof("existing TLS certificates are invalid or expired, regenerating...")
}
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
if err != nil {
logging.LogErrorf("failed to load CA certificates: %s", err)
return "", "", err
}
logging.LogInfof("generating TLS server certificates signed by local CA...")
if err = generateServerCert(certPath, keyPath, caCert, caKey); err != nil {
logging.LogErrorf("failed to generate TLS certificates: %s", err)
return "", "", err
}
logging.LogInfof("generated TLS certificates at [%s]", ConfDir)
return certPath, keyPath, nil
}
// Checks if the certificate file exists, is not expired, and contains all current IP addresses
func validateCert(certPath string) bool {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return false
}
block, _ := pem.Decode(certPEM)
if block == nil {
return false
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return false
}
// Check if certificate is still valid, with 7 day buffer
if !time.Now().Add(7 * 24 * time.Hour).Before(cert.NotAfter) {
return false
}
// Check if certificate contains all current IP addresses
currentIPs := GetServerAddrs()
certIPMap := make(map[string]bool)
for _, ip := range cert.IPAddresses {
certIPMap[ip.String()] = true
}
for _, ipStr := range currentIPs {
ipStr = trimIPv6Brackets(ipStr)
ip := net.ParseIP(ipStr)
if ip == nil {
continue
}
if !certIPMap[ip.String()] {
logging.LogInfof("certificate missing current IP address [%s], will regenerate", ip.String())
return false
}
}
return true
}
// Creates a new self-signed CA certificate
func generateCACert(certPath, keyPath string) error {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
notBefore := time.Now()
notAfter := notBefore.Add(10 * 365 * 24 * time.Hour)
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SiYuan"},
CommonName: "SiYuan Local CA",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return err
}
return writeCertAndKey(certPath, keyPath, certDER, privateKey)
}
// Creates a new server certificate signed by the CA
func generateServerCert(certPath, keyPath string, caCert *x509.Certificate, caKey any) error {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return err
}
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return err
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
ipAddresses := []net.IP{
net.ParseIP("127.0.0.1"),
net.IPv6loopback,
}
localIPs := GetServerAddrs()
for _, ipStr := range localIPs {
ipStr = trimIPv6Brackets(ipStr)
if ip := net.ParseIP(ipStr); ip != nil {
ipAddresses = append(ipAddresses, ip)
}
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"SiYuan"},
CommonName: "SiYuan Local Server",
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"localhost"},
IPAddresses: ipAddresses,
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, caCert, &privateKey.PublicKey, caKey)
if err != nil {
return err
}
return writeCertAndKey(certPath, keyPath, certDER, privateKey)
}
// Loads the CA certificate and private key from files
func loadCA(certPath, keyPath string) (*x509.Certificate, any, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, err
}
block, _ := pem.Decode(certPEM)
if block == nil {
return nil, nil, fmt.Errorf("failed to decode CA certificate PEM")
}
caCert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, nil, err
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, err
}
block, _ = pem.Decode(keyPEM)
if block == nil {
return nil, nil, fmt.Errorf("failed to decode CA key PEM")
}
caKey, err := x509.ParseECPrivateKey(block.Bytes)
if err != nil {
return nil, nil, err
}
return caCert, caKey, nil
}
func writeCertAndKey(certPath, keyPath string, certDER []byte, privateKey *ecdsa.PrivateKey) error {
certFile, err := os.Create(certPath)
if err != nil {
return err
}
defer certFile.Close()
if err = pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return err
}
keyFile, err := os.Create(keyPath)
if err != nil {
return err
}
defer keyFile.Close()
keyDER, err := x509.MarshalECPrivateKey(privateKey)
if err != nil {
return err
}
if err = pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return err
}
return nil
}
// Imports a CA certificate and private key from PEM-encoded strings.
func ImportCABundle(caCertPEM, caKeyPEM string) error {
certBlock, _ := pem.Decode([]byte(caCertPEM))
if certBlock == nil {
return fmt.Errorf("failed to decode CA certificate PEM")
}
caCert, err := x509.ParseCertificate(certBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA certificate: %w", err)
}
if !caCert.IsCA {
return fmt.Errorf("the provided certificate is not a CA certificate")
}
keyBlock, _ := pem.Decode([]byte(caKeyPEM))
if keyBlock == nil {
return fmt.Errorf("failed to decode CA private key PEM")
}
_, err = x509.ParseECPrivateKey(keyBlock.Bytes)
if err != nil {
return fmt.Errorf("failed to parse CA private key: %w", err)
}
caCertPath := filepath.Join(ConfDir, TLSCACertFilename)
caKeyPath := filepath.Join(ConfDir, TLSCAKeyFilename)
if err := os.WriteFile(caCertPath, []byte(caCertPEM), 0644); err != nil {
return fmt.Errorf("failed to write CA certificate: %w", err)
}
if err := os.WriteFile(caKeyPath, []byte(caKeyPEM), 0600); err != nil {
return fmt.Errorf("failed to write CA private key: %w", err)
}
certPath := filepath.Join(ConfDir, TLSCertFilename)
keyPath := filepath.Join(ConfDir, TLSKeyFilename)
if gulu.File.IsExist(certPath) {
os.Remove(certPath)
}
if gulu.File.IsExist(keyPath) {
os.Remove(keyPath)
}
logging.LogInfof("imported CA bundle, server certificate will be regenerated on next TLS initialization")
return nil
}
// trimIPv6Brackets removes brackets from IPv6 address strings like "[::1]"
func trimIPv6Brackets(ip string) string {
if len(ip) > 2 && ip[0] == '[' && ip[len(ip)-1] == ']' {
return ip[1 : len(ip)-1]
}
return ip
}