Files
3x-ui/web/html/modals/inbound_modal.html
MHSanaei 3b64a62137 refactor(vless): drop selectedAuth, expose two explicit auth buttons
selectedAuth was UI-only metadata (Xray never reads it) and entirely
redundant with the encryption string itself — the dropdown only
controlled which block from `xray vlessenc` to apply. Replace it with
two explicit buttons ("X25519" and "ML-KEM-768") so the user picks
the auth mode in one click instead of dropdown + Get-New-Keys.

- VLESSSettings drops the field from constructor, fromJson, and toJson;
  legacy `selectedAuth` values still in DB will be silently shed on the
  next save.
- getNewVlessEnc(authLabel) now takes the label as a parameter; clear
  resets only decryption/encryption.
- Fallbacks visibility now keys on encryption === "none" (the same
  thing the dropdown was effectively gating on).
- Info modal drops the redundant Authentication tag and colours the
  encryption tag red when it's "none", green otherwise.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 15:08:06 +02:00

368 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 = "";
},
// Pulls the requested auth block from `xray vlessenc` (which always returns
// both X25519 and ML-KEM-768 variants) and applies it to the inbound's
// decryption/encryption strings. The auth mode is implied by the resulting
async getNewVlessEnc(authLabel) {
if (!authLabel) 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 === authLabel);
if (!block) {
console.error("No auth block for", authLabel);
return;
}
inModal.inbound.settings.decryption = block.decryption;
inModal.inbound.settings.encryption = block.encryption;
},
clearVlessEnc() {
this.inbound.settings.decryption = "none";
this.inbound.settings.encryption = "none";
},
// 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}}