14 Commits 6951198aae ... 3452267302

Author SHA1 Message Date
  Sanaei 3452267302 Merge branch 'main' into bash 23 hours ago
  MHSanaei 3d1d75d65a Revert "build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487)" 23 hours ago
  Sanaei b5cb069a07 Merge branch 'main' into bash 23 hours ago
  Cheng Ho Ming, Eric 6e2816d035 fix(frontend): override browser default background color on autofilled login inputs (#4478) 23 hours ago
  dependabot[bot] 7fc7c14ac1 build(deps-dev): bump vite from 8.0.13 to 8.0.14 in /frontend (#4487) 1 day ago
  githacs2022 5f318f3b16 Add SockOpt.Mark and SockOpt.Interface parameters for Outbound stream (#4480) 1 day ago
  MHSanaei 9f80cfedab fix(sub): use standard sub://BASE64#REMARK scheme for Shadowrocket 2 days ago
  MHSanaei 1b436bb3e0 fix(clients): honor global pageSize and widen size-changer dropdown 2 days ago
  MHSanaei 5b5ac3f04b fix(migrate): include hysteria, hysteria2, shadowsocks in client sync 2 days ago
  MHSanaei 3827d7d061 fix(clients): seed all clients when settings.clients has string tgId 3 days ago
  MHSanaei d7f47d8b6a fix(xray): allow private-IP destinations via freedom finalRules 3 days ago
  Abdalrahman fd3770c8c9 fix: parse XHTTP extra fields from V2Ray links and v2rayN JSON imports (#4426) 3 days ago
  Константин 758e1ad050 Make HSTS policy configurable if https is enabled (#4462) 3 days ago
  Black 121b6e0bd0 feat(panel): copy connection strings for `mixed` inbound (#4450) 3 days ago

+ 5 - 0
config/config.go

@@ -57,6 +57,11 @@ func IsDebug() bool {
 	return os.Getenv("XUI_DEBUG") == "true"
 }
 
+// IsSkipHSTS returns true if skipping HSTS mode is enabled via the XUI_SKIP_HSTS environment variable.
+func IsSkipHSTS() bool {
+	return os.Getenv("XUI_SKIP_HSTS") == "true"
+}
+
 // GetBinFolderPath returns the path to the binary folder, defaulting to "bin" if not set via XUI_BIN_FOLDER.
 func GetBinFolderPath() string {
 	binFolderPath := os.Getenv("XUI_BIN_FOLDER")

+ 34 - 0
database/db.go

@@ -11,6 +11,7 @@ import (
 	"os"
 	"path"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -198,6 +199,36 @@ func runSeeders(isUsersEmpty bool) error {
 	return nil
 }
 
+// normalizeClientJSONFields coerces loosely-typed numeric fields in a raw
+// settings.clients entry so json.Unmarshal into model.Client doesn't fail
+// when older rows wrote tgId/limitIp/totalGB/etc. as strings. Empty strings
+// drop the key so the field falls back to its zero value.
+func normalizeClientJSONFields(obj map[string]any) {
+	normalizeInt := func(key string) {
+		raw, exists := obj[key]
+		if !exists {
+			return
+		}
+		s, ok := raw.(string)
+		if !ok {
+			return
+		}
+		trimmed := strings.ReplaceAll(strings.TrimSpace(s), " ", "")
+		if trimmed == "" {
+			delete(obj, key)
+			return
+		}
+		if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
+			obj[key] = n
+		} else {
+			delete(obj, key)
+		}
+	}
+	for _, k := range []string{"tgId", "limitIp", "totalGB", "expiryTime", "reset", "created_at", "updated_at"} {
+		normalizeInt(k)
+	}
+}
+
 func seedClientsFromInboundJSON() error {
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
@@ -226,12 +257,15 @@ func seedClientsFromInboundJSON() error {
 				if !ok {
 					continue
 				}
+				normalizeClientJSONFields(obj)
 				blob, err := json.Marshal(obj)
 				if err != nil {
 					continue
 				}
 				var c model.Client
 				if err := json.Unmarshal(blob, &c); err != nil {
+					log.Printf("ClientsTable seed: skip client in inbound %d (unmarshal failed): %v; payload=%s",
+						inbound.Id, err, string(blob))
 					continue
 				}
 				email := strings.TrimSpace(c.Email)

+ 70 - 8
frontend/src/models/outbound.js

@@ -748,6 +748,9 @@ export class SockoptStreamSettings extends CommonClass {
         penetrate = false,
         addressPortStrategy = Address_Port_Strategy.NONE,
         trustedXForwardedFor = [],
+        mark = 0,            
+        interfaceName = "",  
+
     ) {
         super();
         this.dialerProxy = dialerProxy;
@@ -757,6 +760,9 @@ export class SockoptStreamSettings extends CommonClass {
         this.penetrate = penetrate;
         this.addressPortStrategy = addressPortStrategy;
         this.trustedXForwardedFor = trustedXForwardedFor;
+        this.mark = mark;          
+        this.interfaceName = interfaceName; 
+
     }
 
     static fromJson(json = {}) {
@@ -768,7 +774,9 @@ export class SockoptStreamSettings extends CommonClass {
             json.tcpMptcp,
             json.penetrate,
             json.addressPortStrategy,
-            json.trustedXForwardedFor || []
+            json.trustedXForwardedFor || [],
+            json.mark ?? 0,      
+            json.interface ?? "", 
         );
     }
 
@@ -779,7 +787,9 @@ export class SockoptStreamSettings extends CommonClass {
             tcpKeepAliveInterval: this.tcpKeepAliveInterval,
             tcpMptcp: this.tcpMptcp,
             penetrate: this.penetrate,
-            addressPortStrategy: this.addressPortStrategy
+            addressPortStrategy: this.addressPortStrategy,
+            mark: this.mark, 
+            interface: this.interfaceName, 
         };
         if (this.trustedXForwardedFor && this.trustedXForwardedFor.length > 0) {
             result.trustedXForwardedFor = this.trustedXForwardedFor;
@@ -1138,8 +1148,12 @@ export class StreamSettings extends CommonClass {
     }
 
     static fromJson(json = {}) {
+        // Xray-core supports both "xhttpSettings" and "splithttpSettings" (backward-compat alias)
+        const xhttpJson = json.xhttpSettings ?? json.splithttpSettings;
+        // Normalize "splithttp" network name to "xhttp" for internal consistency
+        const network = json.network === 'splithttp' ? 'xhttp' : json.network;
         return new StreamSettings(
-            json.network,
+            network,
             json.security,
             TlsStreamSettings.fromJson(json.tlsSettings),
             RealityStreamSettings.fromJson(json.realitySettings),
@@ -1148,7 +1162,7 @@ export class StreamSettings extends CommonClass {
             WsStreamSettings.fromJson(json.wsSettings),
             GrpcStreamSettings.fromJson(json.grpcSettings),
             HttpUpgradeStreamSettings.fromJson(json.httpupgradeSettings),
-            xHTTPStreamSettings.fromJson(json.xhttpSettings),
+            xHTTPStreamSettings.fromJson(xhttpJson),
             HysteriaStreamSettings.fromJson(json.hysteriaSettings),
             FinalMaskStreamSettings.fromJson(json.finalmask),
             SockoptStreamSettings.fromJson(json.sockopt),
@@ -1379,12 +1393,28 @@ export class Outbound extends CommonClass {
         } else if (network === 'httpupgrade') {
             stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
         } else if (network === 'xhttp') {
-            // xHTTPStreamSettings positional args are (path, host, headers, ..., mode);
-            // passing `json.mode` as the 3rd argument used to land in the `headers`
-            // slot, dropping the mode on the floor. Build the object and set mode
-            // explicitly to avoid that.
             const xh = new xHTTPStreamSettings(json.path, json.host);
             if (json.mode) xh.mode = json.mode;
+            if (json.type && !json.mode) xh.mode = json.type;
+            // Padding / obfuscation — sing-box families use x_padding_bytes,
+            // while the extra block carries xPaddingBytes.
+            if (json.x_padding_bytes && !json.xPaddingBytes) json.xPaddingBytes = json.x_padding_bytes;
+            if (typeof json.xPaddingBytes === 'string' && json.xPaddingBytes) xh.xPaddingBytes = json.xPaddingBytes;
+            if (json.xPaddingObfsMode === true) {
+                xh.xPaddingObfsMode = true;
+                ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
+                    if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
+                });
+            }
+            // Bidirectional string fields carried in the extra block
+            const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
+            xFields.forEach(k => {
+                if (typeof json[k] === 'string' && json[k]) xh[k] = json[k];
+            });
+            // Headers — VMess extra emits them as a {name: value} map
+            if (json.headers && typeof json.headers === 'object' && !Array.isArray(json.headers)) {
+                xh.headers = Object.entries(json.headers).map(([name, value]) => ({ name, value }));
+            }
             stream.xhttp = xh;
         }
 
@@ -1455,6 +1485,16 @@ export class Outbound extends CommonClass {
                     ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
                         if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
                     });
+                    if (!xh.mode && typeof extra.mode === 'string' && extra.mode) xh.mode = extra.mode;
+                    // Bidirectional string fields carried inside the extra block
+                    const xFields = ["sessionPlacement", "sessionKey", "seqPlacement", "seqKey", "uplinkDataPlacement", "uplinkDataKey", "scMaxEachPostBytes"];
+                    xFields.forEach(k => {
+                        if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
+                    });
+                    // Headers — extra emits them as a {name: value} map
+                    if (extra.headers && typeof extra.headers === 'object' && !Array.isArray(extra.headers)) {
+                        xh.headers = Object.entries(extra.headers).map(([name, value]) => ({ name, value }));
+                    }
                 } catch (_) { /* ignore malformed extra */ }
             }
             stream.xhttp = xh;
@@ -1997,6 +2037,28 @@ Outbound.VLESSSettings = class extends CommonClass {
     }
 
     static fromJson(json = {}) {
+        // Handle v2rayN-style nested vnext array (standard Xray JSON format)
+        if (!ObjectUtil.isArrEmpty(json.vnext)) {
+            const v = json.vnext[0] || {};
+            const u = ObjectUtil.isArrEmpty(v.users) ? {} : v.users[0];
+            const saved = json.testseed;
+            const testseed = (Array.isArray(saved)
+                && saved.length === 4
+                && saved.every(v => Number.isInteger(v) && v > 0))
+                ? saved
+                : [];
+            return new Outbound.VLESSSettings(
+                v.address,
+                v.port,
+                u.id,
+                u.flow,
+                u.encryption,
+                json.reverse?.tag || '',
+                ReverseSniffing.fromJson(json.reverse?.sniffing || {}),
+                json.testpre || 0,
+                testseed,
+            );
+        }
         if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings();
         const saved = json.testseed;
         const testseed = (Array.isArray(saved)

+ 42 - 11
frontend/src/pages/clients/ClientsPage.vue

@@ -43,6 +43,7 @@ const {
   tgBotEnable,
   expireDiff,
   trafficDiff,
+  pageSize,
   create,
   update,
   remove,
@@ -442,6 +443,10 @@ function expiryColor(row) {
 const sortState = ref({ column: null, order: null });
 const paginationState = ref({ current: 1, pageSize: 20 });
 
+watch(pageSize, (next) => {
+  if (next > 0) paginationState.value.pageSize = next;
+}, { immediate: true });
+
 function sortableCol(col, key) {
   return {
     ...col,
@@ -670,8 +675,9 @@ const columns = computed(() => [
                     </a-select>
                   </div>
 
-                  <a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading" row-key="email"
-                    :row-selection="rowSelection" :pagination="tablePagination" size="small" @change="onTableChange">
+                  <a-table v-if="!isMobile" :columns="columns" :data-source="sortedClients" :loading="loading"
+                    row-key="email" :row-selection="rowSelection" :pagination="tablePagination" size="small"
+                    @change="onTableChange">
                     <template #bodyCell="{ column, record }">
                       <template v-if="column.key === 'email'">
                         <div class="email-cell">
@@ -842,6 +848,11 @@ const columns = computed(() => [
   background: var(--bg-page);
 }
 
+.clients-page :deep(.ant-pagination-options-size-changer),
+.clients-page :deep(.ant-pagination-options-size-changer .ant-select-selector) {
+  min-width: 100px !important;
+}
+
 .clients-page.is-dark {
   --bg-page: #1e1e1e;
   --bg-card: #252526;
@@ -874,7 +885,7 @@ const columns = computed(() => [
   margin-bottom: 8px;
 }
 
-.filter-bar.mobile > * {
+.filter-bar.mobile>* {
   flex: 0 0 auto;
 }
 
@@ -911,11 +922,25 @@ const columns = computed(() => [
   vertical-align: middle;
 }
 
-.dot-green { background: #52c41a; }
-.dot-blue { background: #1677ff; }
-.dot-red { background: #ff4d4f; }
-.dot-orange { background: #fa8c16; }
-.dot-gray { background: rgba(128, 128, 128, 0.6); }
+.dot-green {
+  background: #52c41a;
+}
+
+.dot-blue {
+  background: #1677ff;
+}
+
+.dot-red {
+  background: #ff4d4f;
+}
+
+.dot-orange {
+  background: #fa8c16;
+}
+
+.dot-gray {
+  background: rgba(128, 128, 128, 0.6);
+}
 
 .status-tag {
   margin: 0 0 0 4px;
@@ -1050,8 +1075,6 @@ const columns = computed(() => [
 </style>
 
 <style>
-/* AD-Vue popovers teleport their content to <body>, so scoped styles
-   don't reach them — this block has to be unscoped. */
 .client-email-list {
   max-height: 280px;
   min-width: 160px;
@@ -1059,9 +1082,17 @@ const columns = computed(() => [
   padding-right: 4px;
 }
 
-.client-email-list > div {
+.client-email-list>div {
   padding: 2px 0;
   font-size: 12px;
   white-space: nowrap;
 }
+
+.ant-select-dropdown:has(.ant-select-item-option[title$="/ page"]) {
+  min-width: 110px !important;
+}
+
+.ant-select-dropdown:has(.ant-select-item-option[title$="/ page"]) .ant-select-item-option-content {
+  white-space: nowrap;
+}
 </style>

+ 3 - 0
frontend/src/pages/clients/useClients.js

@@ -14,6 +14,7 @@ export function useClients() {
   const tgBotEnable = ref(false);
   const expireDiff = ref(0);
   const trafficDiff = ref(0);
+  const pageSize = ref(0);
 
   async function refresh() {
     loading.value = true;
@@ -48,6 +49,7 @@ export function useClients() {
     tgBotEnable.value = !!s.tgBotEnable;
     expireDiff.value = (s.expireDiff ?? 0) * 86400000;
     trafficDiff.value = (s.trafficDiff ?? 0) * 1073741824;
+    pageSize.value = s.pageSize ?? 0;
   }
 
   async function create(payload) {
@@ -199,6 +201,7 @@ export function useClients() {
     tgBotEnable,
     expireDiff,
     trafficDiff,
+    pageSize,
     refresh,
     create,
     update,

+ 119 - 27
frontend/src/pages/inbounds/InboundInfoModal.vue

@@ -19,41 +19,20 @@ import { useDatepicker } from '@/composables/useDatepicker.js';
 const { t } = useI18n();
 const { datepicker } = useDatepicker();
 
-// One modal handles every protocol's info / share view because the
-// legacy template did the same. The big v-if forks at the top decide
-// which sub-block of the body renders:
-//   • multi-user inbound (VMess/VLess/Trojan/SS-multi/Hysteria) → per-
-//     client row + share links
-//   • SS single-user → connection details + share link
-//   • WireGuard → secret/peers + per-peer config download
-//   • Mixed/HTTP/Tunnel → connection details only
-//
-// We display links via QrPanel — each link gets its own QR + copy +
-// (for WireGuard configs) download button.
-
 const props = defineProps({
   open: { type: Boolean, default: false },
-  // Result of inbounds-page checkFallback() so the link-gen sees the
-  // root inbound's listen/port/security when the dbInbound is a
-  // domain-socket fallback (`@<name>`).
   dbInbound: { type: Object, default: null },
-  // Index into inbound.clients to focus on for multi-user inbounds.
   clientIndex: { type: Number, default: 0 },
-  // Sidecar config the legacy panel keyed off `app.*`.
   remarkModel: { type: String, default: '-ieo' },
   expireDiff: { type: Number, default: 0 },
   trafficDiff: { type: Number, default: 0 },
   ipLimitEnable: { type: Boolean, default: false },
   tgBotEnable: { type: Boolean, default: false },
-  // Address of the node hosting this inbound; '' for local. Wired
-  // through to share/QR link generation so node-managed inbounds
-  // produce links that connect to the node, not the central panel.
   nodeAddress: { type: String, default: '' },
   subSettings: {
     type: Object,
     default: () => ({ enable: false, subURI: '', subJsonURI: '', subJsonEnable: false }),
   },
-  // Email -> ts (last-online unix-ms) map fetched at the page level.
   lastOnlineMap: { type: Object, default: () => ({}) },
 });
 
@@ -598,7 +577,8 @@ const showSubscriptionTab = computed(
             <div v-if="inbound.settings.gateway?.length" class="info-row">
               <dt>Gateway</dt>
               <dd><a-tag v-for="(ip, j) in inbound.settings.gateway" :key="`tun-i-gw-${j}`" color="green"
-                  class="value-tag">{{ ip }}</a-tag></dd>
+                  class="value-tag">{{
+                  ip }}</a-tag></dd>
             </div>
             <div v-if="inbound.settings.dns?.length" class="info-row">
               <dt>DNS</dt>
@@ -612,7 +592,8 @@ const showSubscriptionTab = computed(
             <div v-if="inbound.settings.autoSystemRoutingTable?.length" class="info-row">
               <dt>Auto system routes</dt>
               <dd><a-tag v-for="(cidr, j) in inbound.settings.autoSystemRoutingTable" :key="`tun-i-rt-${j}`"
-                  color="green">{{ cidr }}</a-tag></dd>
+                  color="green">{{
+                  cidr }}</a-tag></dd>
             </div>
           </dl>
 
@@ -670,12 +651,101 @@ const showSubscriptionTab = computed(
                   <span class="account-sep">:</span>
                   <a-tag class="value-tag">{{ account.pass }}</a-tag>
                   <a-tooltip :title="t('copy')">
-                    <a-button size="small" @click="copyText(`${account.user}:${account.pass}`)">
-                      <template #icon>
-                        <CopyOutlined />
-                      </template>
+                    <a-button size="small" type="text"
+                      @click="copyText(`${account.user}:${account.pass}`)">
+                      <template #icon><CopyOutlined /></template>
                     </a-button>
                   </a-tooltip>
+                  <a-space :size="4" wrap class="share-buttons share-desktop">
+                    <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
+                      <a-button size="small"
+                        @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
+                        SOCKS5
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`">
+                      <a-button size="small"
+                        @click="copyText(`http://${dbInbound.address}:${dbInbound.port}@${account.user}:${account.pass}`)">
+                        HTTP
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip title="https://t.me/socks?server=...&port=...&user=...&pass=...">
+                      <a-button size="small"
+                        @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`)">
+                        Telegram
+                      </a-button>
+                    </a-tooltip>
+                  </a-space>
+                  <a-dropdown :trigger="['click']" class="share-mobile">
+                    <a-button size="small">
+                      <template #icon><CopyOutlined /></template>
+                      {{ t('copy') }}
+                    </a-button>
+                    <template #overlay>
+                      <a-menu @click="({ key }) => {
+                        const h = dbInbound.address;
+                        const port = dbInbound.port;
+                        if (key === 'telegram') {
+                          copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}&user=${encodeURIComponent(account.user)}&pass=${encodeURIComponent(account.pass)}`);
+                        } else {
+                          copyText(`${key}://${h}:${port}@${account.user}:${account.pass}`);
+                        }
+                      }">
+                        <a-menu-item key="socks5">SOCKS5</a-menu-item>
+                        <a-menu-item key="http">HTTP</a-menu-item>
+                        <a-menu-item key="telegram">Telegram</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
+                </dd>
+              </div>
+            </template>
+
+            <template v-if="inbound.settings.auth === 'noauth'">
+              <div class="info-row">
+                <dt>{{ t('copy') }}</dt>
+                <dd>
+                  <a-space :size="4" wrap class="share-buttons share-desktop">
+                    <a-tooltip :title="`socks5://${dbInbound.address}:${dbInbound.port}`">
+                      <a-button size="small"
+                        @click="copyText(`socks5://${dbInbound.address}:${dbInbound.port}`)">
+                        SOCKS5
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip :title="`http://${dbInbound.address}:${dbInbound.port}`">
+                      <a-button size="small"
+                        @click="copyText(`http://${dbInbound.address}:${dbInbound.port}`)">
+                        HTTP
+                      </a-button>
+                    </a-tooltip>
+                    <a-tooltip title="https://t.me/socks?server=...&port=...">
+                      <a-button size="small"
+                        @click="copyText(`https://t.me/socks?server=${encodeURIComponent(dbInbound.address)}&port=${dbInbound.port}`)">
+                        Telegram
+                      </a-button>
+                    </a-tooltip>
+                  </a-space>
+                  <a-dropdown :trigger="['click']" class="share-mobile">
+                    <a-button size="small">
+                      <template #icon><CopyOutlined /></template>
+                      {{ t('copy') }}
+                    </a-button>
+                    <template #overlay>
+                      <a-menu @click="({ key }) => {
+                        const h = dbInbound.address;
+                        const port = dbInbound.port;
+                        if (key === 'telegram') {
+                          copyText(`https://t.me/socks?server=${encodeURIComponent(h)}&port=${port}`);
+                        } else {
+                          copyText(`${key}://${h}:${port}`);
+                        }
+                      }">
+                        <a-menu-item key="socks5">SOCKS5</a-menu-item>
+                        <a-menu-item key="http">HTTP</a-menu-item>
+                        <a-menu-item key="telegram">Telegram</a-menu-item>
+                      </a-menu>
+                    </template>
+                  </a-dropdown>
                 </dd>
               </div>
             </template>
@@ -897,6 +967,7 @@ const showSubscriptionTab = computed(
   white-space: normal;
   word-break: break-all;
   display: inline-block;
+  margin-right: 0;
 }
 
 .value-block {
@@ -927,6 +998,27 @@ const showSubscriptionTab = computed(
   flex-shrink: 0;
 }
 
+.share-buttons,
+.share-mobile {
+  margin-inline-start: 4px;
+  padding-inline-start: 8px;
+  border-inline-start: 1px solid rgba(128, 128, 128, 0.25);
+}
+
+.share-mobile {
+  display: none;
+}
+
+@media (max-width: 600px) {
+  .share-desktop {
+    display: none !important;
+  }
+  .share-mobile {
+    display: inline-flex;
+    align-items: center;
+  }
+}
+
 .security-line {
   display: flex;
   align-items: center;

+ 7 - 0
frontend/src/pages/login/LoginPage.vue

@@ -469,6 +469,13 @@ function cycleTheme() {
   font-weight: 500;
 }
 
+.login-form :deep(input.ant-input:-webkit-autofill) {
+  -webkit-text-fill-color: var(--color-text) !important;
+  -webkit-box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
+  box-shadow: 0 0 0 1000px var(--bg-card) inset !important;
+  transition: background-color 9999s ease-in-out 0s, color 9999s ease-in-out 0s;
+}
+
 .submit-row {
   margin-bottom: 0;
 }

+ 1 - 1
frontend/src/pages/sub/SubPage.vue

@@ -125,7 +125,7 @@ const shadowrocketUrl = computed(() => {
   if (!subUrl) return '';
   const separator = subUrl.includes('?') ? '&' : '?';
   const rawUrl = subUrl + separator + 'flag=shadowrocket';
-  const base64Url = encodeURIComponent(btoa(rawUrl));
+  const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
   const remark = encodeURIComponent(subTitle || sId || 'Subscription');
   return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
 });

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

@@ -328,6 +328,47 @@ function regenerateWgKeys() {
                 </a-select>
               </a-form-item>
             </template>
+
+            <a-form-item label="Final Rules">
+              <a-button size="small" type="primary" @click="outbound.settings.addFinalRule('allow')">
+                <template #icon>
+                  <PlusOutlined />
+                </template>
+              </a-button>
+              <span class="ml-8" style="opacity: 0.6;">
+                Override Xray's default private-IP block (needed for LAN access through proxy)
+              </span>
+            </a-form-item>
+            <template v-for="(rule, index) in outbound.settings.finalRules || []" :key="`fr-${index}`">
+              <a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }" :colon="false">
+                <div class="item-heading">
+                  <span>Rule {{ index + 1 }}</span>
+                  <DeleteOutlined class="danger-icon" @click="outbound.settings.delFinalRule(index)" />
+                </div>
+              </a-form-item>
+              <a-form-item label="Action">
+                <a-select v-model:value="rule.action">
+                  <a-select-option v-for="x in ['allow', 'block']" :key="x" :value="x">{{ x }}</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Network">
+                <a-select v-model:value="rule.network" allow-clear placeholder="(any)">
+                  <a-select-option value="tcp">tcp</a-select-option>
+                  <a-select-option value="udp">udp</a-select-option>
+                  <a-select-option value="tcp,udp">tcp,udp</a-select-option>
+                </a-select>
+              </a-form-item>
+              <a-form-item label="Port">
+                <a-input v-model:value="rule.port" placeholder="e.g. 80,443 or 1000-2000" />
+              </a-form-item>
+              <a-form-item label="IP / CIDR / geoip">
+                <a-select v-model:value="rule.ip" mode="tags" :token-separators="[',', ' ']"
+                  placeholder="e.g. 10.0.0.0/8, geoip:private, ext:cn.dat:cn" />
+              </a-form-item>
+              <a-form-item v-if="rule.action === 'block'" label="Block delay (ms)">
+                <a-input v-model:value="rule.blockDelay" placeholder="optional: 5000-10000" />
+              </a-form-item>
+            </template>
           </template>
 
           <!-- ============== Blackhole ============== -->
@@ -947,6 +988,12 @@ function regenerateWgKeys() {
               <a-form-item label="Penetrate">
                 <a-switch v-model:checked="outbound.stream.sockopt.penetrate" />
               </a-form-item>
+              <a-form-item label="Mark (fwmark)">
+                <a-input-number v-model:value="outbound.stream.sockopt.mark" :min="0" />
+              </a-form-item>
+              <a-form-item label="Interface">
+                <a-input v-model:value="outbound.stream.sockopt.interfaceName" />
+              </a-form-item>
             </template>
           </template>
 

+ 4 - 1
web/service/config.json

@@ -30,7 +30,10 @@
   "outbounds": [{
       "protocol": "freedom",
       "settings": {
-        "domainStrategy": "AsIs"
+        "domainStrategy": "AsIs",
+        "finalRules": [
+          { "action": "allow", "ip": ["geoip:private"] }
+        ]
       },
       "tag": "direct"
     },

+ 7 - 1
web/service/inbound.go

@@ -2845,7 +2845,7 @@ func (s *InboundService) MigrationRequirements() {
 
 	// Fix inbounds based problems
 	var inbounds []*model.Inbound
-	err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan"}).Find(&inbounds).Error
+	err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria", "hysteria2"}).Find(&inbounds).Error
 	if err != nil && err != gorm.ErrRecordNotFound {
 		return
 	}
@@ -2924,6 +2924,12 @@ func (s *InboundService) MigrationRequirements() {
 				}
 			}
 		}
+
+		// Heal clients table for installs where the one-shot seeder
+		// skipped clients due to a tgId-string unmarshal error.
+		if syncErr := s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); syncErr != nil {
+			logger.Warning("MigrationRequirements sync clients failed:", syncErr)
+		}
 	}
 	tx.Save(inbounds)
 

+ 2 - 1
web/web.go

@@ -154,7 +154,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 
 	engine := gin.Default()
 	directHTTPS := s.isDirectHTTPSConfigured()
-	engine.Use(middleware.SecurityHeadersMiddleware(directHTTPS))
+	sendHSTS := directHTTPS && !config.IsSkipHSTS()
+	engine.Use(middleware.SecurityHeadersMiddleware(sendHSTS))
 
 	webDomain, err := s.settingService.GetWebDomain()
 	if err != nil {