13 Commits 21058eb63c ... 1284756f8a

Author SHA1 Message Date
  MHSanaei 1284756f8a fix(outbound): restore TLS, QUIC params and TCP masks when importing share links 1 day ago
  MHSanaei 1f052c0e8f fix: preserve TLS cert file paths when deploying inbound to remote node 1 day ago
  MHSanaei ae6f13b533 fix: also hide QR code for ML-KEM-768 links (too long for QR generation) 1 day ago
  MHSanaei 1cf2582e6d fix: hide QR code for mldsa65 links (too long for QR generation) 1 day ago
  Abdalrahman eacb9f63b0 fix: protocol filter placeholder not showing on initial load (#4372) 1 day ago
  MHSanaei e7035b56fe fix: sync advancedJson before tab switch in convertLink 1 day ago
  dependabot[bot] 5f526e5201 build(deps): bump actions/setup-node from 5 to 6 (#4368) 1 day ago
  MHSanaei bd8d33980f fix: ignore duplicate column errors during AutoMigrate on upgraded DBs 1 day ago
  MHSanaei 5dc02a9af3 v3.0.2 1 day ago
  Abdalrahman 033c5993e0 feat: add API token to install output (#4322) 1 day ago
  MHSanaei 2204c8231d Adjust QR panel sizing and collapse JSON subscription by default 1 day ago
  Abdalrahman 01a7dc807b fix(sub): include xhttp mode in extra JSON for karing compatibility (#4365) 1 day ago
  Farhad H. P. Shirvan 6bf4a2c4f0 fix(docker): update port mapping for 3xui service in docker-compose (#4362) 1 day ago

+ 1 - 1
.github/workflows/ci.yml

@@ -72,7 +72,7 @@ jobs:
     runs-on: ubuntu-latest
     steps:
       - uses: actions/checkout@v6
-      - uses: actions/setup-node@v5
+      - uses: actions/setup-node@v6
         with:
           node-version-file: .nvmrc
           cache: npm

+ 1 - 1
config/version

@@ -1 +1 @@
-3.0.1
+3.0.2

+ 28 - 2
database/db.go

@@ -10,6 +10,7 @@ import (
 	"os"
 	"path"
 	"slices"
+	"strings"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v3/config"
@@ -42,8 +43,12 @@ func initModels() error {
 		&model.Node{},
 		&model.ApiToken{},
 	}
-	for _, model := range models {
-		if err := db.AutoMigrate(model); err != nil {
+	for _, mdl := range models {
+		if err := db.AutoMigrate(mdl); err != nil {
+			if isIgnorableDuplicateColumnErr(err, mdl) {
+				log.Printf("Ignoring duplicate column during auto migration for %T: %v", mdl, err)
+				continue
+			}
 			log.Printf("Error auto migrating model: %v", err)
 			return err
 		}
@@ -51,6 +56,27 @@ func initModels() error {
 	return nil
 }
 
+func isIgnorableDuplicateColumnErr(err error, mdl any) bool {
+	if err == nil {
+		return false
+	}
+	errMsg := strings.ToLower(err.Error())
+	const dupPrefix = "duplicate column name:"
+	if !strings.Contains(errMsg, dupPrefix) {
+		return false
+	}
+	idx := strings.Index(errMsg, dupPrefix)
+	if idx < 0 {
+		return false
+	}
+	col := strings.TrimSpace(errMsg[idx+len(dupPrefix):])
+	col = strings.Trim(col, "`\"[]")
+	if col == "" {
+		return false
+	}
+	return db != nil && db.Migrator().HasColumn(mdl, col)
+}
+
 // initUser creates a default admin user if the users table is empty.
 func initUser() error {
 	empty, err := isTableEmpty("users")

+ 2 - 1
docker-compose.yml

@@ -12,5 +12,6 @@ services:
       XRAY_VMESS_AEAD_FORCED: "false"
       XUI_ENABLE_FAIL2BAN: "true"
     tty: true
-    network_mode: host
+    ports:
+      - "2053:2053"
     restart: unless-stopped

+ 2 - 2
frontend/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "3x-ui-frontend",
-  "version": "0.0.2",
+  "version": "0.0.3",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "3x-ui-frontend",
-      "version": "0.0.2",
+      "version": "0.0.3",
       "dependencies": {
         "@ant-design/icons-vue": "^7.0.1",
         "@codemirror/lang-json": "^6.0.2",

+ 2 - 2
frontend/package.json

@@ -1,7 +1,7 @@
 {
   "name": "3x-ui-frontend",
   "private": true,
-  "version": "0.0.2",
+  "version": "0.0.3",
   "type": "module",
   "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).",
   "engines": {
@@ -40,4 +40,4 @@
   "overrides": {
     "moment-jalaali": "^0.10.4"
   }
-}
+}

+ 4 - 0
frontend/src/models/inbound.js

@@ -1595,6 +1595,10 @@ export class Inbound extends XrayCommonClass {
             });
         }
 
+        if (typeof xhttp.mode === 'string' && xhttp.mode.length > 0) {
+            extra.mode = xhttp.mode;
+        }
+
         const stringFields = [
             "sessionPlacement", "sessionKey",
             "seqPlacement", "seqKey",

+ 59 - 2
frontend/src/models/outbound.js

@@ -1397,6 +1397,13 @@ export class Outbound extends CommonClass {
 
         const port = json.port * 1;
 
+        // Parse fm (finalmask) JSON string — TCP/UDP masks + QUIC params from 3x-ui share links
+        if (json.fm) {
+            try {
+                stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(json.fm));
+            } catch (_) { /* ignore malformed fm */ }
+        }
+
         return new Outbound(json.ps, Protocols.VMess, new Outbound.VmessSettings(json.add, port, json.id, json.scy), stream);
     }
 
@@ -1496,6 +1503,14 @@ export class Outbound extends CommonClass {
             default:
                 return null;
         }
+        // Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links
+        const fmRaw = url.searchParams.get('fm');
+        if (fmRaw) {
+            try {
+                stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(fmRaw));
+            } catch (_) { /* ignore malformed fm */ }
+        }
+
         let remark = decodeURIComponent(url.hash);
         // Remove '#' from url.hash
         remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
@@ -1516,7 +1531,17 @@ export class Outbound extends CommonClass {
         let urlParams = new URLSearchParams(params);
 
         // Create stream settings with hysteria network
-        let stream = new StreamSettings('hysteria', 'none');
+        let security = urlParams.get('security') ?? 'none';
+        let stream = new StreamSettings('hysteria', security);
+
+        // Parse TLS settings when security=tls
+        if (security === 'tls') {
+            let fp = urlParams.get('fp') ?? 'none';
+            let alpn = urlParams.get('alpn');
+            let sni = urlParams.get('sni') ?? '';
+            let ech = urlParams.get('ech') ?? '';
+            stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
+        }
 
         // Set hysteria stream settings
         stream.hysteria.auth = password;
@@ -1534,7 +1559,7 @@ export class Outbound extends CommonClass {
             stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
         }
 
-        // Optional QUIC parameters
+        // Optional QUIC parameters for FinalMask support and hysteria2 share links
         if (urlParams.has('initStreamReceiveWindow')) {
             stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow'));
         }
@@ -1557,6 +1582,38 @@ export class Outbound extends CommonClass {
             stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true';
         }
 
+        // Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links, with special handling to mirror QUIC params into both stream.finalmask and stream.hysteria
+        const fmRaw = urlParams.get('fm');
+        if (fmRaw) {
+            try {
+                const fm = JSON.parse(fmRaw);
+                const qp = fm.quicParams;
+                if (qp && typeof qp === 'object') {
+                    // Populate stream.finalmask.quicParams — this enables the "QUIC Params"
+                    // toggle in FinalMaskForm and carries all QUIC tuning settings.
+                    stream.finalmask.quicParams = QuicParams.fromJson(qp);
+
+                    // Also mirror the overlapping fields into stream.hysteria so the
+                    // Hysteria transport section of the form shows consistent values.
+                    if (qp.congestion) stream.hysteria.congestion = qp.congestion;
+                    if (Number.isInteger(qp.initStreamReceiveWindow)) stream.hysteria.initStreamReceiveWindow = qp.initStreamReceiveWindow;
+                    if (Number.isInteger(qp.maxStreamReceiveWindow)) stream.hysteria.maxStreamReceiveWindow = qp.maxStreamReceiveWindow;
+                    if (Number.isInteger(qp.initConnectionReceiveWindow)) stream.hysteria.initConnectionReceiveWindow = qp.initConnectionReceiveWindow;
+                    if (Number.isInteger(qp.maxConnectionReceiveWindow)) stream.hysteria.maxConnectionReceiveWindow = qp.maxConnectionReceiveWindow;
+                    if (Number.isInteger(qp.maxIdleTimeout)) stream.hysteria.maxIdleTimeout = qp.maxIdleTimeout;
+                    if (Number.isInteger(qp.keepAlivePeriod)) stream.hysteria.keepAlivePeriod = qp.keepAlivePeriod;
+                    if (qp.disablePathMTUDiscovery === true) stream.hysteria.disablePathMTUDiscovery = true;
+                    if (qp.udpHop) {
+                        stream.hysteria.udphopPort = qp.udpHop.ports ?? stream.hysteria.udphopPort;
+                        if (qp.udpHop.interval !== undefined) {
+                            stream.hysteria.udphopIntervalMin = qp.udpHop.interval;
+                            stream.hysteria.udphopIntervalMax = qp.udpHop.interval;
+                        }
+                    }
+                }
+            } catch (_) { /* ignore malformed fm */ }
+        }
+
         // Create settings
         let settings = new Outbound.HysteriaSettings(address, port, 2);
 

+ 1 - 1
frontend/src/pages/inbounds/InboundList.vue

@@ -80,7 +80,7 @@ const savedFilterState = (() => {
 const enableFilter = ref(!!savedFilterState.enableFilter);
 const searchKey = ref(savedFilterState.searchKey || '');
 const filterBy = ref(savedFilterState.filterBy || '');
-const protocolFilter = ref(savedFilterState.protocolFilter || '');
+const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
 const nodeFilter = ref(savedFilterState.nodeFilter || '');
 
 watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {

+ 2 - 2
frontend/src/pages/inbounds/QrCodeModal.vue

@@ -98,7 +98,6 @@ watch(() => props.open, (next) => {
   }
   const open = [];
   if (subLink.value) open.push('sub');
-  if (subJsonLink.value) open.push('sub-json');
   activeKeys.value = open;
 });
 
@@ -112,7 +111,8 @@ function close() {
     <template v-if="dbInbound">
       <a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
         <a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
-          <QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
+          <QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''"
+            :show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
         </a-collapse-panel>
       </a-collapse>
     </template>

+ 9 - 2
frontend/src/pages/inbounds/QrPanel.vue

@@ -11,7 +11,7 @@ const props = defineProps({
   value: { type: String, required: true },
   remark: { type: String, default: '' },
   downloadName: { type: String, default: '' },
-  size: { type: Number, default: 240 },
+  size: { type: Number, default: 360 },
   showQr: { type: Boolean, default: true },
 });
 
@@ -82,8 +82,15 @@ function download() {
 
 .qr-panel-canvas .qr-code {
   cursor: pointer;
-  padding: 0 !important;
   background: #fff;
   border-radius: 4px;
+  line-height: 0;
+}
+
+.qr-panel-canvas .qr-code :deep(svg) {
+  display: block;
+  width: 100%;
+  height: auto;
+  max-width: 360px;
 }
 </style>

+ 1 - 0
frontend/src/pages/xray/OutboundFormModal.vue

@@ -195,6 +195,7 @@ function convertLink() {
       return;
     }
     outbound.value = next;
+    primeAdvancedJson();
     linkInput.value = '';
     message.success('Link imported successfully...');
     activeKey.value = '1';

+ 4 - 0
install.sh

@@ -763,6 +763,9 @@ config_after_install() {
 
             prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
 
+            # Retrieve the API token for display
+            local config_apiToken=$(${xui_folder}/x-ui setting -getApiToken true | grep -Eo 'apiToken: .+' | awk '{print $2}')
+
             # Display final credentials and access information
             echo ""
             echo -e "${green}═══════════════════════════════════════════${plain}"
@@ -773,6 +776,7 @@ config_after_install() {
             echo -e "${green}Port:        ${config_port}${plain}"
             echo -e "${green}WebBasePath: ${config_webBasePath}${plain}"
             echo -e "${green}Access URL:  ${SSL_SCHEME}://${SSL_HOST}:${config_port}/${config_webBasePath}${plain}"
+            echo -e "${green}API Token:   ${config_apiToken}${plain}"
             echo -e "${green}═══════════════════════════════════════════${plain}"
             echo -e "${yellow}⚠ IMPORTANT: Save these credentials securely!${plain}"
             if [[ "$SSL_SCHEME" == "https" ]]; then

+ 27 - 0
main.go

@@ -391,6 +391,28 @@ func GetListenIP(getListen bool) {
 	}
 }
 
+func GetApiToken(getApiToken bool) {
+	if !getApiToken {
+		return
+	}
+	apiTokenService := service.ApiTokenService{}
+	tokens, err := apiTokenService.List()
+	if err != nil {
+		fmt.Println("get apiToken failed, error info:", err)
+		return
+	}
+	if len(tokens) > 0 {
+		fmt.Println("apiToken:", tokens[0].Token)
+		return
+	}
+	created, err := apiTokenService.Create("install")
+	if err != nil {
+		fmt.Println("create apiToken failed, error info:", err)
+		return
+	}
+	fmt.Println("apiToken:", created.Token)
+}
+
 // migrateDb performs database migration operations for the 3x-ui panel.
 func migrateDb() {
 	inboundService := service.InboundService{}
@@ -433,6 +455,7 @@ func main() {
 	var reset bool
 	var show bool
 	var getCert bool
+	var getApiToken bool
 	var resetTwoFactor bool
 	settingCmd.BoolVar(&reset, "reset", false, "Reset all settings")
 	settingCmd.BoolVar(&show, "show", false, "Display current settings")
@@ -444,6 +467,7 @@ func main() {
 	settingCmd.BoolVar(&resetTwoFactor, "resetTwoFactor", false, "Reset two-factor authentication settings")
 	settingCmd.BoolVar(&getListen, "getListen", false, "Display current panel listenIP IP")
 	settingCmd.BoolVar(&getCert, "getCert", false, "Display current certificate settings")
+	settingCmd.BoolVar(&getApiToken, "getApiToken", false, "Display current API token")
 	settingCmd.StringVar(&webCertFile, "webCert", "", "Set path to public key file for panel")
 	settingCmd.StringVar(&webKeyFile, "webCertKey", "", "Set path to private key file for panel")
 	settingCmd.StringVar(&tgbottoken, "tgbottoken", "", "Set token for Telegram bot")
@@ -501,6 +525,9 @@ func main() {
 		if getCert {
 			GetCertificate(getCert)
 		}
+		if getApiToken {
+			GetApiToken(getApiToken)
+		}
 		if (tgbottoken != "") || (tgbotchatid != "") || (tgbotRuntime != "") {
 			updateTgbotSetting(tgbottoken, tgbotchatid, tgbotRuntime)
 		}

+ 8 - 0
sub/subJsonService.go

@@ -265,6 +265,14 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
 		streamSettings["wsSettings"] = s.removeAcceptProxy(streamSettings["wsSettings"])
 	case "httpupgrade":
 		streamSettings["httpupgradeSettings"] = s.removeAcceptProxy(streamSettings["httpupgradeSettings"])
+	case "xhttp":
+		streamSettings["xhttpSettings"] = s.removeAcceptProxy(streamSettings["xhttpSettings"])
+		if xhttp, ok := streamSettings["xhttpSettings"].(map[string]any); ok {
+			delete(xhttp, "noSSEHeader")
+			delete(xhttp, "scMaxBufferedPosts")
+			delete(xhttp, "scStreamUpServerSecs")
+			delete(xhttp, "serverMaxHeaderBytes")
+		}
 	}
 	return streamSettings
 }

+ 4 - 0
sub/subService.go

@@ -1025,6 +1025,10 @@ func buildXhttpExtra(xhttp map[string]any) map[string]any {
 		}
 	}
 
+	if mode, ok := xhttp["mode"].(string); ok && len(mode) > 0 {
+		extra["mode"] = mode
+	}
+
 	stringFields := []string{
 		"sessionPlacement", "sessionKey",
 		"seqPlacement", "seqKey",

+ 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)
+			}
+		})
+	}
+}