Browse Source

fix: preserve TLS cert file paths when deploying inbound to remote node

When creating a Hysteria (or any TLS-required) inbound from the central
panel and deploying it to a remote node, sanitizeStreamSettingsForRemote
was unconditionally stripping certificateFile / keyFile from the TLS
settings. This left Xray on the remote node with a TLS block containing
no certificate, causing Xray to crash and the inbounds page to hang.

The fix: only strip cert file paths when inline certificate content
(certificate / key arrays) is also present in the same entry — those
file paths are then truly redundant. When only file paths are present
the user explicitly entered paths that live on the remote node's
filesystem; they are now passed through untouched.

Fixes #4370
MHSanaei 20 hours ago
parent
commit
1f052c0e8f
2 changed files with 129 additions and 6 deletions
  1. 33 6
      web/runtime/remote.go
  2. 96 0
      web/runtime/remote_test.go

+ 33 - 6
web/runtime/remote.go

@@ -344,10 +344,15 @@ func wireInbound(ib *model.Inbound) url.Values {
 }
 
 // sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
-// from the StreamSettings before sending to a remote node. File paths
-// (certificateFile / keyFile) are local to the main panel's filesystem
-// and will cause Xray on the remote node to crash if they don't exist there.
-// Inline certificate content (certificate / key) is kept intact.
+// from the StreamSettings before sending to a remote node, but ONLY when
+// inline certificate content (certificate / key) is also present in the same
+// entry.  In that case the file paths are redundant and stripping them avoids
+// confusion when the central panel's local paths don't exist on the remote.
+//
+// When a certificate entry contains ONLY file paths (no inline content) the
+// paths are left untouched: the user explicitly entered paths that exist on
+// the remote node's filesystem, and removing them would leave Xray with TLS
+// configured but no certificate, causing Xray to crash on the remote node.
 func sanitizeStreamSettingsForRemote(streamSettings string) string {
 	if streamSettings == "" {
 		return streamSettings
@@ -368,18 +373,40 @@ func sanitizeStreamSettingsForRemote(streamSettings string) string {
 		return streamSettings
 	}
 
+	changed := false
 	for _, cert := range certificates {
 		c, ok := cert.(map[string]any)
 		if !ok {
 			continue
 		}
-		delete(c, "certificateFile")
-		delete(c, "keyFile")
+		// Only strip file paths when inline content is present so that the
+		// remote Xray still has a valid certificate to use.
+		hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
+		hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
+		hasCertInline := isNonEmptySlice(c["certificate"])
+		hasKeyInline := isNonEmptySlice(c["key"])
+		if hasCertFile && hasCertInline {
+			delete(c, "certificateFile")
+			changed = true
+		}
+		if hasKeyFile && hasKeyInline {
+			delete(c, "keyFile")
+			changed = true
+		}
 	}
 
+	if !changed {
+		return streamSettings
+	}
 	out, err := json.Marshal(stream)
 	if err != nil {
 		return streamSettings
 	}
 	return string(out)
 }
+
+// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
+func isNonEmptySlice(v any) bool {
+	s, ok := v.([]any)
+	return ok && len(s) > 0
+}

+ 96 - 0
web/runtime/remote_test.go

@@ -0,0 +1,96 @@
+package runtime
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestSanitizeStreamSettingsForRemote(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		// wantCertFile / wantKeyFile: expected presence after sanitize
+		wantCertFile bool
+		wantKeyFile  bool
+	}{
+		{
+			name: "file paths only — kept intact (remote node paths)",
+			input: `{
+				"tlsSettings": {
+					"certificates": [{
+						"certificateFile": "/etc/ssl/cert.crt",
+						"keyFile": "/etc/ssl/key.key"
+					}]
+				}
+			}`,
+			wantCertFile: true,
+			wantKeyFile:  true,
+		},
+		{
+			name: "inline content only — unchanged",
+			input: `{
+				"tlsSettings": {
+					"certificates": [{
+						"certificate": ["-----BEGIN CERTIFICATE-----"],
+						"key": ["-----BEGIN PRIVATE KEY-----"]
+					}]
+				}
+			}`,
+			wantCertFile: false,
+			wantKeyFile:  false,
+		},
+		{
+			name: "both file paths and inline content — file paths stripped (redundant)",
+			input: `{
+				"tlsSettings": {
+					"certificates": [{
+						"certificateFile": "/etc/ssl/cert.crt",
+						"keyFile": "/etc/ssl/key.key",
+						"certificate": ["-----BEGIN CERTIFICATE-----"],
+						"key": ["-----BEGIN PRIVATE KEY-----"]
+					}]
+				}
+			}`,
+			wantCertFile: false,
+			wantKeyFile:  false,
+		},
+		{
+			name: "empty stream settings",
+			input: "",
+			// empty input returns empty, nothing to check
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if tc.input == "" {
+				if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
+					t.Errorf("expected empty string, got %q", got)
+				}
+				return
+			}
+			got := sanitizeStreamSettingsForRemote(tc.input)
+			var out map[string]any
+			if err := json.Unmarshal([]byte(got), &out); err != nil {
+				t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
+			}
+
+			tls, _ := out["tlsSettings"].(map[string]any)
+			certs, _ := tls["certificates"].([]any)
+			if len(certs) == 0 {
+				t.Fatal("certificates array missing in output")
+			}
+			cert, _ := certs[0].(map[string]any)
+
+			_, hasCertFile := cert["certificateFile"]
+			_, hasKeyFile := cert["keyFile"]
+
+			if hasCertFile != tc.wantCertFile {
+				t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
+			}
+			if hasKeyFile != tc.wantKeyFile {
+				t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
+			}
+		})
+	}
+}