diff --git a/web/runtime/remote.go b/web/runtime/remote.go index c71714e7..9cc83f32 100644 --- a/web/runtime/remote.go +++ b/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 +} diff --git a/web/runtime/remote_test.go b/web/runtime/remote_test.go new file mode 100644 index 00000000..dd966792 --- /dev/null +++ b/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) + } + }) + } +}