mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-08 14:36:13 +00:00
testseed is only meaningful for the exact xtls-rprx-vision flow, but the panel was emitting it for any non-empty flow (including the UDP variant) and keeping it on the inbound after the flow was cleared via the client modal. Tighten the gate end-to-end: - VLESSSettings.toJson (inbound + outbound) now only emits testseed when the flow is exactly xtls-rprx-vision and the array is 4 positive ints; default state is empty so unmodified inbounds omit the field entirely. - canEnableVisionSeed drops the udp443 variant per spec. - Form adds a tooltip + theme-aware help text and an inline error when the user partially fills the four inputs; submit is blocked in that state. Reset clears to empty (= use server defaults). - UpdateInboundClient strips a now-orphaned testseed when the spliced client no longer leaves any XRV flow in the inbound. - MigrationRequirements cleans up legacy rows where testseed lingered after flow changes or was saved for non-XRV flows by older versions. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
16 KiB
HTML
370 lines
16 KiB
HTML
{{define "modals/inboundModal"}}
|
|
<a-modal id="inbound-modal" v-model="inModal.visible" :title="inModal.title" :dialog-style="{ top: '20px' }"
|
|
@ok="inModal.ok" :confirm-loading="inModal.confirmLoading" :closable="true" :mask-closable="false"
|
|
:class="themeSwitcher.currentTheme" :ok-text="inModal.okText" cancel-text='{{ i18n "close" }}'>
|
|
{{template "form/inbound" .}}
|
|
</a-modal>
|
|
<script>
|
|
// Make inModal globally available to ensure it works with any base path
|
|
const inModal = (window.inModal = {
|
|
title: "",
|
|
visible: false,
|
|
confirmLoading: false,
|
|
okText: '{{ i18n "sure" }}',
|
|
isEdit: false,
|
|
confirm: null,
|
|
inbound: new Inbound(),
|
|
dbInbound: new DBInbound(),
|
|
ok() {
|
|
// Block submit when Vision Seed is XRV-gated and partially/invalidly filled.
|
|
const seedErr = inModal.testseedError();
|
|
if (seedErr) {
|
|
if (typeof Vue !== "undefined" && Vue.prototype && Vue.prototype.$message) {
|
|
Vue.prototype.$message.error(seedErr);
|
|
} else {
|
|
alert(seedErr);
|
|
}
|
|
return;
|
|
}
|
|
ObjectUtil.execute(inModal.confirm, inModal.inbound, inModal.dbInbound);
|
|
},
|
|
show({
|
|
title = "",
|
|
okText = '{{ i18n "sure" }}',
|
|
inbound = null,
|
|
dbInbound = null,
|
|
confirm = (inbound, dbInbound) => {},
|
|
isEdit = false,
|
|
}) {
|
|
this.title = title;
|
|
this.okText = okText;
|
|
if (inbound) {
|
|
this.inbound = Inbound.fromJson(inbound.toJson());
|
|
} else {
|
|
this.inbound = new Inbound();
|
|
}
|
|
// Ensure VLESS settings has a testseed array reference for Vue reactivity,
|
|
// but leave it empty so we don't auto-emit defaults — user must explicitly
|
|
// fill all four fields, or leave blank to fall back to backend defaults.
|
|
if (this.inbound.protocol === Protocols.VLESS && this.inbound.settings) {
|
|
if (!Array.isArray(this.inbound.settings.testseed)) {
|
|
this.inbound.settings.testseed = [];
|
|
}
|
|
}
|
|
if (dbInbound) {
|
|
this.dbInbound = new DBInbound(dbInbound);
|
|
} else {
|
|
this.dbInbound = new DBInbound();
|
|
}
|
|
this.confirm = confirm;
|
|
this.visible = true;
|
|
this.isEdit = isEdit;
|
|
},
|
|
close() {
|
|
inModal.visible = false;
|
|
inModal.loading(false);
|
|
},
|
|
loading(loading = true) {
|
|
inModal.confirmLoading = loading;
|
|
},
|
|
// Returns an error string when the current testseed state would be rejected,
|
|
// or "" when it's valid (empty == use defaults; full 4 positive ints == custom).
|
|
testseedError() {
|
|
if (!inModal.inbound || inModal.inbound.protocol !== Protocols.VLESS) return "";
|
|
if (typeof inModal.inbound.canEnableVisionSeed === "function"
|
|
&& !inModal.inbound.canEnableVisionSeed()) return "";
|
|
const seed = inModal.inbound.settings && inModal.inbound.settings.testseed;
|
|
if (!Array.isArray(seed) || seed.length === 0) return "";
|
|
const filled = seed.filter(v => v !== null && v !== undefined && v !== "");
|
|
if (filled.length === 0) return "";
|
|
if (seed.length !== 4 || filled.length !== 4 ||
|
|
!seed.every(v => Number.isInteger(v) && v > 0)) {
|
|
return "Provide exactly 4 positive integers or leave empty to use defaults.";
|
|
}
|
|
return "";
|
|
},
|
|
// Vision Seed helpers — always available regardless of Vue context
|
|
updateTestseed(index, value) {
|
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
|
if (!Array.isArray(inModal.inbound.settings.testseed)) {
|
|
inModal.inbound.settings.testseed = [];
|
|
}
|
|
const seed = inModal.inbound.settings.testseed;
|
|
while (seed.length <= index) seed.push(null);
|
|
seed[index] = value;
|
|
// If user cleared every slot, collapse back to empty so we omit testseed entirely.
|
|
if (seed.every(v => v === null || v === undefined || v === "")) {
|
|
inModal.inbound.settings.testseed = [];
|
|
}
|
|
},
|
|
setRandomTestseed() {
|
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
|
// Positive integers only (>=1) so the array passes validation and gets emitted.
|
|
inModal.inbound.settings.testseed = [
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
];
|
|
},
|
|
resetTestseed() {
|
|
if (!inModal.inbound || !inModal.inbound.settings) return;
|
|
// Empty == "use server defaults [900, 500, 900, 256]"; placeholders show in the form.
|
|
inModal.inbound.settings.testseed = [];
|
|
},
|
|
});
|
|
|
|
// Store Vue instance globally to ensure methods are always accessible
|
|
let inboundModalVueInstance = null;
|
|
|
|
inboundModalVueInstance = new Vue({
|
|
delimiters: ["[[", "]]"],
|
|
el: "#inbound-modal",
|
|
data: {
|
|
inModal: inModal,
|
|
delayedStart: false,
|
|
get inbound() {
|
|
return inModal.inbound;
|
|
},
|
|
get dbInbound() {
|
|
return inModal.dbInbound;
|
|
},
|
|
get isEdit() {
|
|
return inModal.isEdit;
|
|
},
|
|
get client() {
|
|
return inModal.inbound &&
|
|
inModal.inbound.clients &&
|
|
inModal.inbound.clients.length > 0 ?
|
|
inModal.inbound.clients[0] :
|
|
null;
|
|
},
|
|
get datepicker() {
|
|
return app.datepicker;
|
|
},
|
|
get delayedExpireDays() {
|
|
return this.client && this.client.expiryTime < 0 ?
|
|
this.client.expiryTime / -86400000 :
|
|
0;
|
|
},
|
|
set delayedExpireDays(days) {
|
|
this.client.expiryTime = -86400000 * days;
|
|
},
|
|
get externalProxy() {
|
|
return this.inbound.stream.externalProxy.length > 0;
|
|
},
|
|
set externalProxy(value) {
|
|
if (value) {
|
|
inModal.inbound.stream.externalProxy = [{
|
|
forceTls: "same",
|
|
dest: window.location.hostname,
|
|
port: inModal.inbound.port,
|
|
remark: "",
|
|
}, ];
|
|
} else {
|
|
inModal.inbound.stream.externalProxy = [];
|
|
}
|
|
},
|
|
},
|
|
watch: {
|
|
"inModal.inbound.stream.security"(newVal, oldVal) {
|
|
// Clear flow when security changes from reality/tls to none
|
|
if (
|
|
inModal.inbound.protocol == Protocols.VLESS &&
|
|
!inModal.inbound.canEnableTlsFlow()
|
|
) {
|
|
inModal.inbound.settings.vlesses.forEach((client) => {
|
|
client.flow = "";
|
|
});
|
|
}
|
|
},
|
|
// Keep testseed as a valid array reference for Vue reactivity while the user
|
|
// toggles flows — but do NOT auto-fill defaults. Empty means "use server defaults"
|
|
// and is the only way the form omits testseed from the outbound JSON.
|
|
"inModal.inbound.settings.vlesses": {
|
|
handler() {
|
|
if (
|
|
inModal.inbound.protocol === Protocols.VLESS &&
|
|
inModal.inbound.settings &&
|
|
!Array.isArray(inModal.inbound.settings.testseed)
|
|
) {
|
|
inModal.inbound.settings.testseed = [];
|
|
}
|
|
},
|
|
deep: true,
|
|
},
|
|
},
|
|
methods: {
|
|
streamNetworkChange() {
|
|
if (!inModal.inbound.canEnableTls()) {
|
|
this.inModal.inbound.stream.security = "none";
|
|
}
|
|
if (!inModal.inbound.canEnableReality()) {
|
|
this.inModal.inbound.reality = false;
|
|
}
|
|
if (
|
|
this.inModal.inbound.protocol == Protocols.VLESS &&
|
|
!inModal.inbound.canEnableTlsFlow()
|
|
) {
|
|
this.inModal.inbound.settings.vlesses.forEach((client) => {
|
|
client.flow = "";
|
|
});
|
|
}
|
|
if (inModal.inbound.stream.network != "kcp") {
|
|
inModal.inbound.stream.finalmask.udp = [];
|
|
}
|
|
},
|
|
SSMethodChange() {
|
|
this.inModal.inbound.settings.password =
|
|
RandomUtil.randomShadowsocksPassword(
|
|
this.inModal.inbound.settings.method,
|
|
);
|
|
|
|
if (this.inModal.inbound.isSSMultiUser) {
|
|
if (this.inModal.inbound.settings.shadowsockses.length == 0) {
|
|
this.inModal.inbound.settings.shadowsockses = [
|
|
new Inbound.ShadowsocksSettings.Shadowsocks(),
|
|
];
|
|
}
|
|
if (!this.inModal.inbound.isSS2022) {
|
|
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
|
client.method = this.inModal.inbound.settings.method;
|
|
});
|
|
} else {
|
|
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
|
client.method = "";
|
|
});
|
|
}
|
|
this.inModal.inbound.settings.shadowsockses.forEach((client) => {
|
|
client.password = RandomUtil.randomShadowsocksPassword(
|
|
this.inModal.inbound.settings.method,
|
|
);
|
|
});
|
|
} else {
|
|
if (this.inModal.inbound.settings.shadowsockses.length > 0) {
|
|
this.inModal.inbound.settings.shadowsockses = [];
|
|
}
|
|
}
|
|
},
|
|
setDefaultCertData(index) {
|
|
inModal.inbound.stream.tls.certs[index].certFile = app.defaultCert;
|
|
inModal.inbound.stream.tls.certs[index].keyFile = app.defaultKey;
|
|
},
|
|
async getNewX25519Cert() {
|
|
inModal.loading(true);
|
|
const msg = await HttpUtil.get("/panel/api/server/getNewX25519Cert");
|
|
inModal.loading(false);
|
|
if (!msg.success) {
|
|
return;
|
|
}
|
|
inModal.inbound.stream.reality.privateKey = msg.obj.privateKey;
|
|
inModal.inbound.stream.reality.settings.publicKey = msg.obj.publicKey;
|
|
},
|
|
clearX25519Cert() {
|
|
this.inbound.stream.reality.privateKey = "";
|
|
this.inbound.stream.reality.settings.publicKey = "";
|
|
},
|
|
async getNewmldsa65() {
|
|
inModal.loading(true);
|
|
const msg = await HttpUtil.get("/panel/api/server/getNewmldsa65");
|
|
inModal.loading(false);
|
|
if (!msg.success) {
|
|
return;
|
|
}
|
|
inModal.inbound.stream.reality.mldsa65Seed = msg.obj.seed;
|
|
inModal.inbound.stream.reality.settings.mldsa65Verify = msg.obj.verify;
|
|
},
|
|
clearMldsa65() {
|
|
this.inbound.stream.reality.mldsa65Seed = "";
|
|
this.inbound.stream.reality.settings.mldsa65Verify = "";
|
|
},
|
|
randomizeRealityTarget() {
|
|
if (typeof getRandomRealityTarget !== "undefined") {
|
|
const randomTarget = getRandomRealityTarget();
|
|
this.inbound.stream.reality.target = randomTarget.target;
|
|
this.inbound.stream.reality.serverNames = randomTarget.sni;
|
|
}
|
|
},
|
|
async getNewEchCert() {
|
|
inModal.loading(true);
|
|
const msg = await HttpUtil.post("/panel/api/server/getNewEchCert", {
|
|
sni: inModal.inbound.stream.tls.sni,
|
|
});
|
|
inModal.loading(false);
|
|
if (!msg.success) {
|
|
return;
|
|
}
|
|
inModal.inbound.stream.tls.echServerKeys = msg.obj.echServerKeys;
|
|
inModal.inbound.stream.tls.settings.echConfigList =
|
|
msg.obj.echConfigList;
|
|
},
|
|
clearEchCert() {
|
|
this.inbound.stream.tls.echServerKeys = "";
|
|
this.inbound.stream.tls.settings.echConfigList = "";
|
|
},
|
|
async getNewVlessEnc() {
|
|
const selected = inModal.inbound.settings.selectedAuth;
|
|
if (!selected) {
|
|
this.clearVlessEnc();
|
|
return;
|
|
}
|
|
|
|
inModal.loading(true);
|
|
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
|
inModal.loading(false);
|
|
|
|
if (!msg.success) {
|
|
return;
|
|
}
|
|
|
|
const auths = msg.obj.auths || [];
|
|
const block = auths.find((a) => a.label === selected);
|
|
|
|
if (!block) {
|
|
console.error("No auth block for", selected);
|
|
return;
|
|
}
|
|
|
|
inModal.inbound.settings.decryption = block.decryption;
|
|
inModal.inbound.settings.encryption = block.encryption;
|
|
},
|
|
clearVlessEnc() {
|
|
this.inbound.settings.decryption = "none";
|
|
this.inbound.settings.encryption = "none";
|
|
this.inbound.settings.selectedAuth = undefined;
|
|
},
|
|
// Vision Seed methods - must be in Vue methods for proper template binding.
|
|
// Mirror the inModal helpers but use Vue.set so the form re-renders.
|
|
updateTestseed(index, value) {
|
|
if (!Array.isArray(this.inbound.settings.testseed)) {
|
|
this.$set(this.inbound.settings, "testseed", []);
|
|
}
|
|
const seed = this.inbound.settings.testseed;
|
|
while (seed.length <= index) seed.push(null);
|
|
this.$set(seed, index, value);
|
|
// Collapse to empty when every slot is cleared so testseed is omitted from JSON.
|
|
if (seed.every(v => v === null || v === undefined || v === "")) {
|
|
this.$set(this.inbound.settings, "testseed", []);
|
|
}
|
|
},
|
|
setRandomTestseed() {
|
|
// Positive integers only (>=1) so the resulting array passes validation.
|
|
const newSeed = [
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
Math.floor(Math.random() * 999) + 1,
|
|
];
|
|
this.$set(this.inbound.settings, "testseed", newSeed);
|
|
},
|
|
resetTestseed() {
|
|
// Empty == "use server defaults [900, 500, 900, 256]". Placeholders will show in the form.
|
|
this.$set(this.inbound.settings, "testseed", []);
|
|
},
|
|
testseedError() {
|
|
return inModal.testseedError();
|
|
},
|
|
},
|
|
});
|
|
</script>
|
|
{{end}} |