@@ -379,7 +417,12 @@ ${checkUpdateHTML}
});
});
const networkServeElement = about.element.querySelector("#networkServe") as HTMLInputElement;
+ const networkServeTLSElement = about.element.querySelector("#networkServeTLS") as HTMLInputElement;
networkServeElement.addEventListener("change", () => {
+ networkServeTLSElement.disabled = !networkServeElement.checked;
+ if (!networkServeElement.checked) {
+ networkServeTLSElement.checked = false;
+ }
fetchPost("/api/system/setNetworkServe", {networkServe: networkServeElement.checked}, () => {
exportLayout({
errorExit: true,
@@ -387,6 +430,60 @@ ${checkUpdateHTML}
});
});
});
+ networkServeTLSElement.addEventListener("change", () => {
+ const exportCACertSection = about.element.querySelector("#exportCACertSection");
+ const exportCABundleSection = about.element.querySelector("#exportCABundleSection");
+ const importCABundleSection = about.element.querySelector("#importCABundleSection");
+ if (exportCACertSection && exportCABundleSection && importCABundleSection) {
+ if (networkServeTLSElement.checked) {
+ exportCACertSection.classList.remove("fn__none");
+ exportCABundleSection.classList.remove("fn__none");
+ importCABundleSection.classList.remove("fn__none");
+ } else {
+ exportCACertSection.classList.add("fn__none");
+ exportCABundleSection.classList.add("fn__none");
+ importCABundleSection.classList.add("fn__none");
+ }
+ }
+ fetchPost("/api/system/setNetworkServeTLS", {networkServeTLS: networkServeTLSElement.checked}, () => {
+ exportLayout({
+ errorExit: true,
+ cb: exitSiYuan
+ });
+ });
+ });
+ about.element.querySelector("#exportCACert")?.addEventListener("click", () => {
+ fetchPost("/api/system/exportTLSCACert", {}, (response) => {
+ openByMobile(response.data.path);
+ });
+ });
+ about.element.querySelector("#exportCABundle")?.addEventListener("click", () => {
+ fetchPost("/api/system/exportTLSCABundle", {}, (response) => {
+ openByMobile(response.data.path);
+ });
+ });
+ about.element.querySelector("#importCABundle")?.addEventListener("click", () => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".zip";
+ input.onchange = () => {
+ if (input.files && input.files[0]) {
+ const formData = new FormData();
+ formData.append("file", input.files[0]);
+ fetch("/api/system/importTLSCABundle", {
+ method: "POST",
+ body: formData,
+ }).then(res => res.json()).then((response) => {
+ if (response.code === 0) {
+ showMessage(window.siyuan.languages.importCABundleSuccess);
+ } else {
+ showMessage(response.msg, 6000, "error");
+ }
+ });
+ }
+ };
+ input.click();
+ });
const lockScreenModeElement = about.element.querySelector("#lockScreenMode") as HTMLInputElement;
lockScreenModeElement.addEventListener("change", () => {
fetchPost("/api/system/setFollowSystemLockScreen", {lockScreenMode: lockScreenModeElement.checked ? 1 : 0}, () => {
diff --git a/app/src/mobile/settings/about.ts b/app/src/mobile/settings/about.ts
index e5f86e0a3..58827339c 100644
--- a/app/src/mobile/settings/about.ts
+++ b/app/src/mobile/settings/about.ts
@@ -24,10 +24,42 @@ export const initAbout = () => {
${window.siyuan.languages.about2}
-
+
${window.siyuan.languages.about4}
${window.siyuan.languages.about3.replace("${port}", location.port)}
@@ -451,11 +483,67 @@ export const initAbout = () => {
});
});
const networkServeElement = modelMainElement.querySelector("#networkServe") as HTMLInputElement;
+ const networkServeTLSElement = modelMainElement.querySelector("#networkServeTLS") as HTMLInputElement;
networkServeElement.addEventListener("change", () => {
+ networkServeTLSElement.disabled = !networkServeElement.checked;
+ if (!networkServeElement.checked) {
+ networkServeTLSElement.checked = false;
+ }
fetchPost("/api/system/setNetworkServe", {networkServe: networkServeElement.checked}, () => {
exitSiYuan();
});
});
+ networkServeTLSElement.addEventListener("change", () => {
+ const exportCACertSection = modelMainElement.querySelector("#exportCACertSection");
+ const exportCABundleSection = modelMainElement.querySelector("#exportCABundleSection");
+ const importCABundleSection = modelMainElement.querySelector("#importCABundleSection");
+ if (exportCACertSection && exportCABundleSection && importCABundleSection) {
+ if (networkServeTLSElement.checked) {
+ exportCACertSection.classList.remove("fn__none");
+ exportCABundleSection.classList.remove("fn__none");
+ importCABundleSection.classList.remove("fn__none");
+ } else {
+ exportCACertSection.classList.add("fn__none");
+ exportCABundleSection.classList.add("fn__none");
+ importCABundleSection.classList.add("fn__none");
+ }
+ }
+ fetchPost("/api/system/setNetworkServeTLS", {networkServeTLS: networkServeTLSElement.checked}, () => {
+ exitSiYuan();
+ });
+ });
+ modelMainElement.querySelector("#exportCACert")?.addEventListener("click", () => {
+ fetchPost("/api/system/exportTLSCACert", {}, (response) => {
+ openByMobile(response.data.path);
+ });
+ });
+ modelMainElement.querySelector("#exportCABundle")?.addEventListener("click", () => {
+ fetchPost("/api/system/exportTLSCABundle", {}, (response) => {
+ openByMobile(response.data.path);
+ });
+ });
+ modelMainElement.querySelector("#importCABundle")?.addEventListener("click", () => {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".zip";
+ input.onchange = () => {
+ if (input.files && input.files[0]) {
+ const formData = new FormData();
+ formData.append("file", input.files[0]);
+ fetch("/api/system/importTLSCABundle", {
+ method: "POST",
+ body: formData,
+ }).then(res => res.json()).then((response) => {
+ if (response.code === 0) {
+ showMessage(window.siyuan.languages.importCABundleSuccess);
+ } else {
+ showMessage(response.msg, 6000, "error");
+ }
+ });
+ }
+ };
+ input.click();
+ });
const tokenElement = modelMainElement.querySelector("#token") as HTMLInputElement;
tokenElement.addEventListener("change", () => {
fetchPost("/api/system/setAPIToken", {token: tokenElement.value}, () => {
diff --git a/app/src/types/config.d.ts b/app/src/types/config.d.ts
index efd08c075..cd90be0a1 100644
--- a/app/src/types/config.d.ts
+++ b/app/src/types/config.d.ts
@@ -1623,6 +1623,10 @@ declare namespace Config {
* Whether to enable network serve (whether to allow connections from other devices)
*/
networkServe: boolean;
+ /**
+ * Whether to enable HTTPS for network serve (TLS encryption)
+ */
+ networkServeTLS: boolean;
/**
* The operating system name determined at compile time (obtained using the command `go tool
* dist list`)
diff --git a/kernel/api/router.go b/kernel/api/router.go
index 3e5a24755..af0ba3bfb 100644
--- a/kernel/api/router.go
+++ b/kernel/api/router.go
@@ -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)
diff --git a/kernel/api/system.go b/kernel/api/system.go
index a6f097e27..314ed0e82 100644
--- a/kernel/api/system.go
+++ b/kernel/api/system.go
@@ -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)
diff --git a/kernel/conf/system.go b/kernel/conf/system.go
index 58b0475a2..08069ac4e 100644
--- a/kernel/conf/system.go
+++ b/kernel/conf/system.go
@@ -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:自动启动+隐藏主窗口
diff --git a/kernel/go.mod b/kernel/go.mod
index 04eb18315..838293996 100644
--- a/kernel/go.mod
+++ b/kernel/go.mod
@@ -174,6 +174,7 @@ require (
github.com/richardlehane/mscfb v1.0.4 // indirect
github.com/richardlehane/msoleps v1.0.4 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/soheilhy/cmux v0.1.5 // indirect
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf // indirect
github.com/teambition/rrule-go v1.8.2 // indirect
github.com/tetratelabs/wazero v1.9.0 // indirect
diff --git a/kernel/go.sum b/kernel/go.sum
index 2f13039ba..6fa8cf0a8 100644
--- a/kernel/go.sum
+++ b/kernel/go.sum
@@ -395,6 +395,8 @@ github.com/siyuan-note/logging v0.0.0-20260117134552-88b424dfe7f1 h1:C2Y1XhBrrGe
github.com/siyuan-note/logging v0.0.0-20260117134552-88b424dfe7f1/go.mod h1:t3Tmt3DgQx0zqJmrckszJ+JBZ7iJrD1Ktp8FDBQ249E=
github.com/siyuan-note/riff v0.0.0-20251022131846-228528e70754 h1:6QYpy7s5HlRSge09TyM/mT0vz1RDcWYZdkxEh7hmbH4=
github.com/siyuan-note/riff v0.0.0-20251022131846-228528e70754/go.mod h1:/N7+N2CsZ0nleNPpP3b+06Bzqvuhy6GUmLY7Kug/zT0=
+github.com/soheilhy/cmux v0.1.5 h1:jjzc5WVemNEDTLwv9tlmemhC73tI08BNOIGwBOo10Js=
+github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/ssor/bom v0.0.0-20170718123548-6386211fdfcf h1:pvbZ0lM0XWPBqUKqFU8cmavspvIl9nulOYwdy6IFRRo=
@@ -486,6 +488,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -509,6 +512,7 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/kernel/server/proxy/fixedport.go b/kernel/server/proxy/fixedport.go
index dface6bc1..09d840a57 100644
--- a/kernel/server/proxy/fixedport.go
+++ b/kernel/server/proxy/fixedport.go
@@ -17,25 +17,80 @@
package proxy
import (
+ "crypto/tls"
+ "net"
"net/http"
"net/http/httputil"
"github.com/siyuan-note/logging"
"github.com/siyuan-note/siyuan/kernel/util"
+ "github.com/soheilhy/cmux"
)
-func InitFixedPortService(host string) {
+func InitFixedPortService(host string, useTLS bool, certPath, keyPath string) {
if util.FixedPort != util.ServerPort {
if util.IsPortOpen(util.FixedPort) {
return
}
+ addr := host + ":" + util.FixedPort
+
// 启动一个固定 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},
+ }
+
+ logging.LogInfof("fixed port service [%s] is running (HTTP/HTTPS dual mode)", addr)
+
+ ln, err := net.Listen("tcp", addr)
+ if err != nil {
+ logging.LogWarnf("boot fixed port service [%s] failed: %s", addr, err)
+ return
+ }
+
+ m := cmux.New(ln)
+
+ // Match TLS connections (first byte 0x16 indicates TLS handshake)
+ tlsL := m.Match(cmux.TLS())
+ // Match HTTP (anything else)
+ httpL := m.Match(cmux.Any())
+
+ cert, err := tls.LoadX509KeyPair(certPath, keyPath)
+ if err != nil {
+ logging.LogWarnf("failed to load TLS cert for fixed port service: %s", err)
+ ln.Close()
+ return
+ }
+ tlsConfig := &tls.Config{Certificates: []tls.Certificate{cert}}
+
+ tlsListener := tls.NewListener(tlsL, tlsConfig)
+
+ go func() {
+ httpServer := &http.Server{Handler: proxy}
+ if err := httpServer.Serve(httpL); err != nil && err != cmux.ErrListenerClosed {
+ logging.LogWarnf("fixed port HTTP server error: %s", err)
+ }
+ }()
+
+ go func() {
+ httpsServer := &http.Server{Handler: proxy}
+ if err := httpsServer.Serve(tlsListener); err != nil && err != cmux.ErrListenerClosed {
+ logging.LogWarnf("fixed port HTTPS server error: %s", err)
+ }
+ }()
+
+ if err := m.Serve(); err != nil && err != cmux.ErrListenerClosed {
+ logging.LogWarnf("fixed port cmux serve error: %s", err)
+ }
+ } else {
+ logging.LogInfof("fixed port service [%s] is running", addr)
+ if proxyErr := http.ListenAndServe(addr, 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)
+ logging.LogInfof("fixed port service [%s] is stopped", addr)
}
}
diff --git a/kernel/server/serve.go b/kernel/server/serve.go
index 40fb560af..702215dba 100644
--- a/kernel/server/serve.go
+++ b/kernel/server/serve.go
@@ -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()
// 反代服务器启动失败不影响核心服务器启动
}()
diff --git a/kernel/util/cert.go b/kernel/util/cert.go
new file mode 100644
index 000000000..76d991c58
--- /dev/null
+++ b/kernel/util/cert.go
@@ -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
.
+
+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
+}