Compare commits

...

60 Commits

Author SHA1 Message Date
MHSanaei
50603fd430 fix: get client reverse tag in the outbound 2026-05-06 00:50:40 +02:00
MHSanaei
8bea0fde2b v2.9.4 2026-05-05 21:31:24 +02:00
MHSanaei
b2d32f588f new: vless reverse
legacy reverse removed
2026-05-05 21:00:03 +02:00
lolka1333
8177f6dc66 ws/inbounds: realtime fixes + perf for 10k+ client inbounds (#4123)
* ws/inbounds: realtime fixes + perf for 10k+ client inbounds

- hub: dedup, throttle, panic-restart, deadlock fix, race tests
- client: backoff cap + slow-retry instead of giving up
- broadcast: delta-only payload, count-based invalidate fallback
- filter: fix empty online list (Inbound has no .id, use dbInbound.toInbound)
- perf: O(N²)→O(N) traffic merge, bulk delete, /setEnable endpoint
- traffic: monotonic all_time + UI clamp + propagate in delta handler
- session: persist on update/logout (fixes logout-after-password-change)
- ui: protocol tags flex, traffic bar normalize

* Remove hub_test.go file

* fix: ws hub, inbound service, and frontend correctness

- propagate DelInbound error on disable path in SetInboundEnable
- skip empty emails in updateClientTraffics to avoid constraint violations
- use consistent IN ? clause, drop redundant ErrRecordNotFound guards
- Hub.Unregister: direct removeClient fallback when channel is full
- applyClientStatsDelta: O(1) email lookup via per-inbound Map cache
- WS payload size check: Blob.size instead of .length for real byte count

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: chunk large IN ? queries and fix IPv6 same-origin check

* fix: unify clientStats cache, throttle clarity, hub constants

* fix(ui): align traffic/expiry cell columns across all rows

* style(ui): redesign outbounds table for visual consistency

* style(ui): redesign routing table for visual consistency

* fix:

* fix:

* fix:

* fix:

* fix:

* fix: font

* refactor: simplify outbound tone functions for consistency and maintainability

---------

Co-authored-by: lolka1333 <test123@gmail.com>
2026-05-05 17:27:49 +02:00
MHSanaei
77d94b25d0 Add 'active' filter option to inbounds 2026-05-04 23:33:48 +02:00
MHSanaei
32b7ada549 subpage: enabled state
Track and surface a subscription's enabled state from backend to frontend so the UI can show inactive subscriptions and use it in active-state logic.

Changes:
- sub/subService.go: track hasEnabledClient, set traffic.Enable, add Enabled to PageData and populate it in BuildPageData.
- sub/subController.go: include enabled in the page context.
- web/html/settings/panel/subscription/subpage.html: emit data-enabled attribute and render an "inactive" tag when disabled.
- web/assets/js/subscription.js: read data-enabled and include it in isActive() checks.

This ensures subscriptions with no enabled clients are marked inactive in the UI and excluded from being considered active.
2026-05-04 23:27:57 +02:00
MHSanaei
6099a07ff0 feat: add configurable auto-restart on client auto-disable
Add a configurable option to restart Xray when clients are auto-disabled and persist disable actions.

Changes include:
- New setting restartXrayOnClientDisable (default true), getters/setters in SettingService, UI toggle in general settings, and translations for multiple locales.
- AddTraffic signature updated to return a third bool (clientsDisabled). disableInvalidClients now calls Xray API to remove users, marks client_traffics.enable=false, updates inbound.Settings JSON so clients appear disabled in stored settings, and returns appropriate counts/errors.
- XrayTrafficJob now checks the clientsDisabled flag and restarts Xray when the setting is enabled (with fallback to mark Xray as needing restart on failure).
- XrayService.GetXrayConfig call adjusted to ignore AddTraffic returns.
- Subscription generation (subService/subJson/subClash) no longer filters clients by their enable flag when matching subId.
- Minor fixes: check_client_ip_job now checks scanner.Err and improved API error handling/logging.

These changes ensure auto-disabled clients are propagated to Xray and the stored inbound settings, and provide an option to restart Xray automatically after auto-disable events.
2026-05-04 23:19:25 +02:00
MHSanaei
e9806832ec reality: remove apple, icloud 2026-05-04 19:49:28 +02:00
MHSanaei
15ebf3df10 fix: client count for Hysteria
#4143
2026-05-04 17:49:53 +02:00
MHSanaei
d44b70682c Update QUIC params defaults and UI validations
#4142
Adjust QUIC parameter defaults and tighten form validation across inbound/outbound components.

- Set default brutalUp/brutalDown to 65537 and only include them in JSON when congestion is 'brutal' or 'force-brutal'.
- Change keepAlivePeriod defaults (inbound QUIC -> 5s, Hysteria stream -> 2s) and enforce minimums in the UI.
- Expose and serialize additional QUIC fields in outbound QuicParams: init/max stream windows, init/max connection windows, maxIdleTimeout, disablePathMTUDiscovery, maxIncomingStreams.
- Add UI min/placeholder constraints: stream/connection receive windows min=16384 and updated placeholders to show defaults, brutal fields min=65537, maxIncomingStreams min=8 (placeholders updated), keepAlive min adjusted.
- Add Wireguard and Hysteria entries to Protocols.

Touched files: web/assets/js/model/inbound.js, web/assets/js/model/outbound.js, web/html/form/outbound.html, web/html/form/stream/stream_finalmask.html.
2026-05-04 17:42:55 +02:00
MHSanaei
fb75e3d7c7 Check scanner error in GetXrayLogs
Add a check for scanner.Err() after scanning log lines and return nil if an error occurred. This prevents further processing of potentially incomplete or invalid log entries when the scanner encountered an error.
2026-05-04 17:02:00 +02:00
MHSanaei
e9979b6774 API: Check client existence
#3706
2026-05-04 17:00:09 +02:00
MHSanaei
2b83dc047b Bump Go module dependency versions 2026-05-04 16:40:50 +02:00
MHSanaei
c90f8a05bf fix(security): sanitize remote IP headers and escape log viewer output
#4135
2026-05-04 16:39:29 +02:00
MHSanaei
9f96ef83ec Freedom outbound: Add finalRules 2026-05-04 15:54:31 +02:00
MHSanaei
e19061d513 TLS: Remove ECH Force Query 2026-05-04 13:20:24 +02:00
MHSanaei
51e2fb6dbf translate update
#4117
2026-04-28 19:17:11 +02:00
Farhad H. P. Shirvan
f21ed92296 feat: add panel update functionality via web GUI (#4117)
* feat: add panel update functionality via web GUI

* feat: enhance panel update notifications in web GUI

* feat: implement panel update modal and enhance translation strings

* fix design
2026-04-28 18:46:55 +02:00
pwnnex
22de983752 xray-setting: pin api routing rule to index 0 on save (#4124)
when the admin adds a custom outbound (eg vless cascade to a second
server) and a routing rule sending all inbound traffic to it, that
catch-all gets evaluated before the existing api->api rule, so the
panel's internal stats inbound's traffic ends up on the cascade
outbound. the grpc stats query then can't see anything, GetTraffic
returns no inbound/user counters, and every client appears offline
with zero traffic even though the actual proxy path works fine.

before save, find the api rule and move it to the front of
routing.rules. if it's missing entirely, insert a default. other
rules keep their relative order.

closes #4113. probably also fixes the long-standing #2818 where the
documented workaround was "manually move the api rule to the top".
2026-04-28 17:49:39 +02:00
MHSanaei
0b5c239f98 v2.9.3 2026-04-27 15:31:32 +02:00
MHSanaei
03393c9f52 Minor changes 2026-04-27 15:02:43 +02:00
MHSanaei
b56db67759 fix: handle Init error in GetXrayTraffic to prevent nil pointer panic
#3969
2026-04-27 14:11:28 +02:00
MHSanaei
6d05702d00 TCP Masks 2026-04-27 03:06:41 +02:00
MHSanaei
9791b05a4e kcp: noise, header-custom, sudoku 2026-04-27 01:28:06 +02:00
MHSanaei
0aca2d3b3d sub: kcp finalmask 2026-04-26 23:04:47 +02:00
MHSanaei
8529f4f0cf kcp: mtu and tti
Add KCP-specific fields mtu and tti to inbound stream handling in web/assets/js/model/inbound.js. The changes add obj.mtu/obj.tti when serializing the kcp stream and set params for mtu and tti in the various KCP parameter-building branches so these values are preserved and transmitted where KCP is used.
2026-04-26 21:32:50 +02:00
MHSanaei
abc5cf3439 Increase KCP maxSendingWindow to 2MiB 2026-04-26 20:49:02 +02:00
MHSanaei
a7e7788e29 Bump Xray release to v26.4.25 2026-04-26 20:45:00 +02:00
MHSanaei
8620344925 Replace with-block with explicit settings 2026-04-26 20:37:03 +02:00
MHSanaei
47e229e323 Default to dark theme when unset 2026-04-26 20:16:27 +02:00
MHSanaei
4521beab7c wireguard: link 2026-04-26 20:06:24 +02:00
MHSanaei
a62c637632 DNS outbound: Add rules 2026-04-26 17:34:31 +02:00
dependabot[bot]
35609b7b13 Bump github.com/Azure/go-ntlmssp (#4094)
Bumps the go_modules group with 1 update in the / directory: [github.com/Azure/go-ntlmssp](https://github.com/Azure/go-ntlmssp).


Updates `github.com/Azure/go-ntlmssp` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/Azure/go-ntlmssp/releases)
- [Commits](https://github.com/Azure/go-ntlmssp/compare/v0.1.0...v0.1.1)

---
updated-dependencies:
- dependency-name: github.com/Azure/go-ntlmssp
  dependency-version: 0.1.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-24 10:41:11 +02:00
pwnnex
a4b1b3d06d Merge pull request #4092 from pwnnex/fix/iplimit-live-only-slot-count
iplimit: dont count idle db-only ips toward the per-client limit (#4091)
2026-04-23 21:36:37 +03:00
pwnnex
5f7c7c5f3d iplimit: dont count idle db-only ips toward the per-client limit
after #4083 the staleness window is 30 minutes, which still lets an ip
that stopped connecting a few minutes ago sit in the db blob and keep
the protected slot on the ascending sort. the ip that is actually
connecting right now gets classified as excess and sent to fail2ban,
and never lands in inbound_client_ips.ips so the panel doesnt show it
until you clear the log by hand.

only count ips observed in the current scan toward the limit. db-only
entries stay in the blob for display but dont participate in the ban
decision. live subset still uses the "protect oldest, ban newcomer"
rule.

closes #4091. followup to #4077.
2026-04-23 21:11:45 +03:00
Rs.Nest
6bcaf61c44 Feature: Copy clients between inbounds (#4087)
* feat: copy clients between inbounds

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* fix: copy clients modal not opening

* revert: undo install.sh/deploy.sh changes; i18n: add copy-clients translations for all languages

---------

Co-authored-by: Нестеров Руслан <r.nesterov@comagic.dev>
2026-04-23 15:19:07 +02:00
MHSanaei
ff25072690 SS: remove unsupported cipher method 2026-04-22 21:44:39 +02:00
pwnnex
530c1597b8 Merge pull request #4086 from pwnnex/fix/hysteria2-protocol-aliases
hysteria: accept "hysteria2" as a protocol string (#4081)
2026-04-22 16:02:05 +00:00
pwnnex
c8e16d8c41 Merge pull request #4085 from pwnnex/fix/iplimit-install-nftables
x-ui.sh: bundle nftables when installing fail2ban
2026-04-22 15:58:00 +00:00
pwnnex
17f67ef3a5 sub: dont panic on bad externalProxy entry in genHysteriaLink
The externalProxy fanout from #4073 did `int(ep["port"].(float64))`
with no ok-check. If any entry is missing port or has the wrong
type it panics, and since this runs in the /sub/<id> handler the
whole subscription returns 500. Skip malformed entries instead.
2026-04-22 18:55:27 +03:00
pwnnex
eb4791a1cd hysteria: also accept "hysteria2" protocol string
UI stores v1 and v2 both as "hysteria" with settings.version, but
inbounds that came in from imports / manual SQL can carry the
literal "hysteria2" string and get silently dropped everywhere we
switch on protocol.

Add Hysteria2 constant + IsHysteria helper, use it in the places
that gate on protocol (sub SQL, getLink, genHysteriaLink, clash
buildProxy, json gen, inbound.go validation, xray AddUser).

Existing "hysteria" inbounds are untouched.

closes #4081
2026-04-22 18:55:09 +03:00
pwnnex
71ac920436 x-ui.sh: install nftables alongside fail2ban in install_iplimit
On fresh Debian 12+, Ubuntu 24+ and recent RHEL-family minimal images
the fail2ban package ships with `banaction = nftables-multiport` as
the default in /etc/fail2ban/jail.conf but does not pull in the
`nftables` package as a dependency. The first SSH brute-force attempt
hits the default sshd jail and fail2ban logs

    stderr: /bin/sh: 1: nft: not found
    returned 127 -- HINT on 127: "Command not found"

repeatedly, which users mistake for a 3x-ui regression (see the
discussion on #4083). The 3x-ipl jail itself is unaffected — it uses
an iptables-based action configured in create_iplimit_jails — so this
is only stray noise, but noisy enough to look like a real failure on
first install.

Add `nftables` to the package list in every branch of install_iplimit
so new installs end up with a working default sshd jail out of the
box. Existing installs where `nftables` is already present are a
no-op.
2026-04-22 18:50:42 +03:00
pwnnex
e6d0c33937 Merge pull request #4083 from pwnnex/fix/iplimit-stale-db-evict
Fix IP Limit continuous ban loop after a legitimate ban expires (#4077)
2026-04-22 14:09:55 +00:00
pwnnex
eef2d311f4 Fix IP Limit continuous ban loop from stale DB entries (#4077)
After 60abeaa flipped the excess-IP selector to "oldest wins,
newest loses" (to protect the original/current connections), the
per-client IP table in `inbound_client_ips.ips` never evicted IPs
that stopped connecting. Their stored timestamp stayed ancient, so
on every subsequent run they counted as the "oldest protected"
slot(s) and whichever IP was actually using the config now was
classified as "new excess" and re-banned via fail2ban.

This is exactly the #4077 scenario: two IPs connect once and get
recorded, the ban lifts after the configured duration, the lone
legitimate IP that reconnects gets banned again, and again, and
again — a permanent 3xipl.log loop with no real abuser anywhere.

Fix: when merging the persisted `old` list with the freshly
observed `new` log lines, drop entries whose last-seen timestamp
is older than `ipStaleAfterSeconds` (30 minutes). A client that's
actually still active refreshes its timestamp any time xray emits
a new `accepted` line for a fresh TCP, so the cutoff is far above
even idle streaming sessions; a client that's genuinely gone falls
out of the table in bounded time and frees its slot.

Extracted the merge into `mergeClientIps` so it can be exercised
by unit tests without spinning up the full DB-backed job.

Tests cover:
- stale old entry is dropped (the #4077 regression)
- fresh old entries are still carried forward (access-log rotation
  is still backed by the persisted table)
- newer timestamp wins when the same IP appears in both lists
- a clock-skewed old `new` entry can't resurrect a stale IP
- a zero cutoff never over-evicts

Closes #4077
2026-04-22 16:53:32 +03:00
MHSanaei
772d2b6de4 v2.9.2 2026-04-22 11:20:56 +02:00
MHSanaei
8f30d14716 Extract bot command setup into trySetBotCommands 2026-04-22 10:47:30 +02:00
pwnnex
9611c9def6 Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053) (#4073)
* Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053)

Two related gaps on the Hysteria side of the subscription layer:

1) `genHysteriaLink` ignored `externalProxy` entirely, so an admin who
   pointed a Hysteria inbound at an alternate endpoint (e.g. a CDN
   hostname forwarding UDP back to the node) still got a link with the
   original server address. Mirror what `genVlessLink` / `genTrojanLink`
   already do: fan out one link per entry, substituting `dest` / `port`
   and picking up the entry's remark suffix. As a bonus, the salamander
   obfs password is now copied into the URL too — the panel-side link
   generator already did this, so the subscription output was lagging
   behind it.

2) `buildProxy` in `subClashService.go` had a protocol switch with cases
   for VMESS / VLESS / Trojan / Shadowsocks and a `default: return nil`.
   Hysteria inbounds fell into the default branch and silently vanished
   from the Clash YAML. Route Hysteria to a dedicated
   `buildHysteriaProxy` helper before the transport/security helpers run
   (applyTransport / applySecurity model xray streams, which Hysteria
   doesn't use).

   `buildHysteriaProxy` reads `inbound.StreamSettings` directly instead
   of going through `streamData` / `tlsData`, because those prune
   fields (`allowInsecure`, the salamander `finalmask.udp` block) that
   the mihomo Hysteria proxy wants preserved. Output shape matches
   mihomo's expectations:

     type: hysteria2                  # or "hysteria" for v1
     password / auth-str: <client auth>
     sni, alpn, skip-cert-verify, client-fingerprint
     obfs: salamander
     obfs-password: <finalmask.udp[salamander].settings.password>

The existing `getProxies` fanout over `externalProxy` already plugs in
for Clash, so with Hysteria now recognised, External Proxy entries
also flow through to the Clash output for Hysteria inbounds.

Closes #4053

* gofmt: align map keys in buildHysteriaProxy

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-22 10:01:21 +02:00
Imgodmaoyouknow
292eb992f4 fix(panel): set ALPN to h3 when switching to Hysteria protocol (#4076)
- Automatically explicitly set ALPN to ['h3'] for Hysteria to prevent QUIC handshake mismatch.
2026-04-22 09:56:03 +02:00
MHSanaei
814e6ad69c Lower minimum Xray version
Update GetXrayVersions filter to accept Xray releases >= 26.3.10 instead of the previous >= 26.4.17. This changes the conditional in web/service/server.go so releases from 26.3.10 onward are included when building the versions list.
2026-04-21 21:20:59 +02:00
MHSanaei
0a38624ba7 Add None option VLESS auth selection 2026-04-21 21:18:59 +02:00
MHSanaei
b86473df02 Run cache cleanup daily and reduce cutoff to 1 day 2026-04-21 20:36:28 +02:00
pwnnex
15be803da9 Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069)
`getXraySetting` builds its response as

    { "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... }

and embeds the raw DB value as the `xraySetting` field without
checking whether the stored value already has that exact shape.

The frontend pulls the textarea content from `result.xraySetting`
and saves it back verbatim. If the DB ever ends up holding the
response-shaped wrapper instead of a real xray config (older
installs where this happened at least once, users who imported a
copy-pasted response into the textarea, a botched migration, etc.),
the next save nests another layer, the one after that nests a
third, and the Vue-side JSON.parse of the resulting blob silently
fails — the Xray Settings page goes blank.

Fix both ends of the round-trip:

* Add `service.UnwrapXrayTemplateConfig`. It peels off any number of
  `xraySetting`-keyed layers, leaving a real xray config behind.
  The check is conservative: if the outer object already contains
  any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`,
  `dns`, `log`, `policy`, `stats`), it is returned unchanged, and
  there is a depth cap to avoid pathological inputs.

* `SaveXraySetting` unwraps before validation so a round-tripped
  wrapper from an already-corrupted page can no longer re-poison
  the DB on save.

* `getXraySetting` unwraps on read and, when it finds a wrapper,
  rewrites the DB with the corrected value. Existing broken installs
  heal themselves on the next visit to the page.

Includes unit tests for the passthrough, single-wrap, multi-wrap,
string-encoded-inner, and false-positive cases.

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 20:30:02 +02:00
MHSanaei
c79b45e512 Readme: Remove custom GeoSite/GeoIP DAT section
Remove the "Custom GeoSite / GeoIP DAT" section from the main README and all localized READMEs (ar_EG, es_ES, fa_IR, ru_RU, zh_CN). Also apply minor formatting cleanups: normalize language header spacing and remove trailing spaces from the Stargazers badge lines.
2026-04-21 20:20:43 +02:00
MHSanaei
86a8eb16b4 fix timelocation for windows
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:05:52 +02:00
MHSanaei
0fd0389d5c sub json fix fragment noises effect
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:02:39 +02:00
pwnnex
2983ac3f8e Fix xhttp xPadding settings missing from generated links (panel + subs) (#4065)
* Fix: propagate xhttp xPadding settings into generated subscription links

The four `genXLink` helpers in `sub/subService.go` only copied `path`,
`host` and `mode` out of `xhttpSettings` when building vmess:// /
vless:// / trojan:// / ss:// URLs. Everything else — `xPaddingBytes`,
`xPaddingObfsMode`, `xPaddingKey`, `xPaddingHeader`,
`xPaddingPlacement`, `xPaddingMethod` — was silently dropped.

That meant an admin who set, say, `xPaddingBytes: "80-600"` plus obfs
mode with a custom `xPaddingKey` on the inbound had a server config
that no client could match from the copy-pasted link: the client kept
the xray/sing-box internal defaults (`100-1000`, `x_padding`,
`Referer`), hit the server, and was rejected by

    invalid padding (queryInHeader=Referer, key=x_padding) length: 0

The user-visible symptom on OpenWRT / Podkop / sing-box was
"xhttp inbound just won't connect" — no obvious pointer to what was
actually wrong because the link itself *looks* complete.

Fix:

  * New helper `applyXhttpPaddingParams(xhttp, params)` writes
    `x_padding_bytes=<range>` (flat, sing-box family reads this) and
    an `extra=<url-encoded-json>` blob carrying the full set of xhttp
    settings (xray-core family reads this). Both encodings are emitted
    side-by-side so every mainstream client can pick at least one up.
  * All four link generators (`genVmessLink` via the obj map,
    `genVlessLink`, `genTrojanLink`, `genShadowsocksLink`) now invoke
    the copy.
  * Obfs-only fields (`xPaddingKey`, `xPaddingHeader`,
    `xPaddingPlacement`, `xPaddingMethod`) are only included when
    `xPaddingObfsMode` is actually true and the admin filled them in.
    An inbound with no custom padding produces exactly the same URL
    as before — existing subscriptions are unaffected.

* Also propagate xhttp xPadding settings into the panel's own Info/QR links

The previous commit covered the subscription service
(sub/subService.go). The admin-panel side — the "Copy URL" / QR /
Info buttons inside inbound details — has four more
xhttp-emitting link generators in `web/assets/js/model/inbound.js`
(`genVmessLink`, `genVLESSLink`, `genTrojanLink`, `genSSLink`) that
had the exact same gap: only `path`, `host` and `mode` were copied.

Mirror the server-side fix on the client:

* Add two static helpers on `Inbound`:
  - `Inbound.applyXhttpPaddingToParams(xhttp, params)` for
    `vless://` / `trojan://` / `ss://` style URLs — writes
    `x_padding_bytes=<range>` (sing-box family) and
    `extra=<url-encoded-json>` (xray-core family).
  - `Inbound.applyXhttpPaddingToObj(xhttp, obj)` for the VMess base64
    JSON body — sets the same fields directly on the object.
* Call them from all four link generators so an admin who enables
  obfs mode + a custom `xPaddingKey` / `xPaddingHeader` actually
  gets a working URL from the panel.
* Only non-empty fields are emitted, so default inbounds produce
  exactly the same URL as before.

Also fixes a latent positional-args bug in
`web/assets/js/model/outbound.js`: both VMess-JSON (L933) and
`fromParamLink` (L975) were calling
`new xHTTPStreamSettings(path, host, mode)` — but the 3rd positional
arg of the constructor is `headers`, not `mode`, so `mode` was
landing in the `headers` slot and the actual `mode` field stayed at
its default. Construct explicitly and set `mode` by name; while
here, also pick up `x_padding_bytes` and the `extra` JSON blob from
the imported URL so the symmetric case of importing a padded link
works too.

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:15:51 +02:00
pwnnex
975d6d1bad Fix: hysteria link gen crashes when echConfigList is a string (#4064)
`genHysteriaLink` was calling `.join(',')` on
`this.stream.tls.settings.echConfigList`, but that field is bound to an
`<a-input>` (single-line string) in `tls_settings.html` and defaults to
`''` in `TlsStreamSettings.Settings`. Calling `.join()` on a string
throws `TypeError: echConfigList.join is not a function`, which breaks
the Info / QR buttons for every hysteria / hysteria2 inbound.

All three sibling link generators (`genVmessLink`, `genVlessLink`,
`genTrojanLink`) already pass the value directly:

    params.set("ech", this.stream.tls.settings.echConfigList)

`URLSearchParams.set` will stringify arrays with `,` on its own, so the
same one-liner works for both string and array inputs. Align
`genHysteriaLink` with the other three.

Fixes #4063

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:05:53 +02:00
MHSanaei
ab7a7f7c6b Reduce observatory probe intervals and timeout 2026-04-21 18:47:38 +02:00
MHSanaei
733f44ef0f balancerTags with a default empty entry 2026-04-21 17:24:42 +02:00
MHSanaei
faec3ca038 CodeQL: ignore v* tag pushes 2026-04-21 15:17:59 +02:00
135 changed files with 11235 additions and 6849 deletions

View File

@@ -1,7 +1,7 @@
name: Cleanup Caches
on:
schedule:
- cron: '0 3 * * 0' # every Sunday
- cron: "0 3 * * *" # every day
workflow_dispatch:
jobs:
@@ -10,16 +10,16 @@ jobs:
permissions:
actions: write
steps:
- name: Delete caches older than 3 days
- name: Delete caches older than 1 day
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CUTOFF_DATE=$(date -d "3 days ago" -Ins --utc | sed 's/+0000/Z/')
CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/')
echo "Deleting caches older than: $CUTOFF_DATE"
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
--jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null)
if [ -z "$CACHE_IDS" ]; then
echo "No old caches found to delete."
else
@@ -28,4 +28,4 @@ jobs:
gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID
done
echo "Old caches deleted successfully."
fi
fi

View File

@@ -2,9 +2,11 @@ name: "CodeQL Advanced"
on:
push:
tags-ignore:
- "v*"
pull_request:
schedule:
- cron: '18 2 * * 2'
- cron: "18 2 * * 2"
jobs:
analyze:
@@ -22,24 +24,24 @@ jobs:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"

View File

@@ -126,7 +126,7 @@ jobs:
cd x-ui/bin
# Download dependencies
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
Xray_URL="https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
if [ "${{ matrix.platform }}" == "amd64" ]; then
wget -q ${Xray_URL}Xray-linux-64.zip
unzip Xray-linux-64.zip
@@ -244,7 +244,7 @@ jobs:
cd x-ui\bin
# Download Xray for Windows
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/"
$Xray_URL = "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/"
Invoke-WebRequest -Uri "${Xray_URL}Xray-windows-64.zip" -OutFile "Xray-windows-64.zip"
Expand-Archive -Path "Xray-windows-64.zip" -DestinationPath .
Remove-Item "Xray-windows-64.zip"

View File

@@ -27,7 +27,7 @@ case $1 in
esac
mkdir -p build/bin
cd build/bin
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.17/Xray-linux-${ARCH}.zip"
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.4.25/Xray-linux-${ARCH}.zip"
unzip "Xray-linux-${ARCH}.zip"
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
mv xray "xray-linux-${FNAME}"
@@ -37,4 +37,4 @@ curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/release
curl -sfLRo geosite_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geosite.dat
curl -sfLRo geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
curl -sfLRo geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
cd ../../
cd ../../

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
## مصادر DAT مخصصة GeoSite / GeoIP
يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_&lt;alias&gt;.dat` و`geoip_&lt;alias&gt;.dat`.
**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`).
**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-``_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز.
## البدء السريع
```
@@ -61,4 +53,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
## النجوم عبر الزمن
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales.
## Fuentes DAT personalizadas GeoSite / GeoIP
Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_&lt;alias&gt;.dat` y `geoip_&lt;alias&gt;.dat`.
**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`).
**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-``_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada.
## Inicio Rápido
```
@@ -62,4 +54,4 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
## Estrellas a lo Largo del Tiempo
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد.
## منابع DAT سفارشی GeoSite / GeoIP
سرپرستان می‌توانند از طریق پنل فایل‌های `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی به‌روزرسانی ژئوفایل‌های داخلی). فایل‌ها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیش‌فرض `bin/`) با نام‌های ثابت `geosite_&lt;alias&gt;.dat` و `geoip_&lt;alias&gt;.dat` ذخیره می‌شوند.
**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`).
**نام‌های رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمال‌شده (`strings.ToLower`، `-``_`) مقایسه می‌شود. نام‌های واردشده و رکورد پایگاه داده بازنویسی نمی‌شوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان می‌خورند.
## شروع سریع
```
@@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
## ستاره‌ها در طول زمان
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features.
## Custom GeoSite / GeoIP DAT sources
Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_&lt;alias&gt;.dat` and `geoip_&lt;alias&gt;.dat`.
**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`).
**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-``_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry.
## Quick Start
```bash

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
## Пользовательские GeoSite / GeoIP (DAT)
В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_&lt;alias&gt;.dat` и `geoip_&lt;alias&gt;.dat`.
**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`).
**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-``_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись.
## Быстрый старт
```
@@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
## Звезды с течением времени
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

View File

@@ -1,4 +1,4 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
<p align="center">
<picture>
@@ -22,14 +22,6 @@
作为原始 X-UI 项目的增强版本3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
## 自定义 GeoSite / GeoIPDAT
管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_&lt;alias&gt;.dat``geoip_&lt;alias&gt;.dat`
**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag``ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。
**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower``-``_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir``geoip_ir` 视为同一保留项。
## 快速开始
```
@@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
## 随时间变化的星标数
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)

View File

@@ -1 +1 @@
2.9.1
2.9.4

View File

@@ -21,9 +21,21 @@ const (
Shadowsocks Protocol = "shadowsocks"
Mixed Protocol = "mixed"
WireGuard Protocol = "wireguard"
Hysteria Protocol = "hysteria"
// UI stores Hysteria v1 and v2 both as "hysteria" and uses
// settings.version to discriminate. Imports from outside the panel
// can carry the literal "hysteria2" string, so IsHysteria below
// accepts both.
Hysteria Protocol = "hysteria"
Hysteria2 Protocol = "hysteria2"
)
// IsHysteria returns true for both "hysteria" and "hysteria2".
// Use instead of a bare ==model.Hysteria check: a v2 inbound stored
// with the literal v2 string would otherwise fall through (#4081).
func IsHysteria(p Protocol) bool {
return p == Hysteria || p == Hysteria2
}
// User represents a user account in the 3x-ui panel.
type User struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -117,22 +129,27 @@ type CustomGeoResource struct {
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime;column:updated_at"`
}
type ClientReverse struct {
Tag string `json:"tag"`
}
// Client represents a client configuration for Xray inbounds with traffic limits and settings.
type Client struct {
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
ID string `json:"id,omitempty"` // Unique client identifier
Security string `json:"security"` // Security method (e.g., "auto", "aes-128-gcm")
Password string `json:"password,omitempty"` // Client password
Flow string `json:"flow,omitempty"` // Flow control (XTLS)
Reverse *ClientReverse `json:"reverse,omitempty"` // VLESS simple reverse proxy settings
Auth string `json:"auth,omitempty"` // Auth password (Hysteria)
Email string `json:"email"` // Client email identifier
LimitIP int `json:"limitIp"` // IP limit for this client
TotalGB int64 `json:"totalGB" form:"totalGB"` // Total traffic limit in GB
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
Enable bool `json:"enable" form:"enable"` // Whether the client is enabled
TgID int64 `json:"tgId" form:"tgId"` // Telegram user ID for notifications
SubID string `json:"subId" form:"subId"` // Subscription identifier
Comment string `json:"comment" form:"comment"` // Client comment
Reset int `json:"reset" form:"reset"` // Reset period in days
CreatedAt int64 `json:"created_at,omitempty"` // Creation timestamp
UpdatedAt int64 `json:"updated_at,omitempty"` // Last update timestamp
}

View File

@@ -0,0 +1,22 @@
package model
import "testing"
func TestIsHysteria(t *testing.T) {
cases := []struct {
in Protocol
want bool
}{
{Hysteria, true},
{Hysteria2, true},
{VLESS, false},
{Shadowsocks, false},
{Protocol(""), false},
{Protocol("hysteria3"), false},
}
for _, c := range cases {
if got := IsHysteria(c.in); got != c.want {
t.Errorf("IsHysteria(%q) = %v, want %v", c.in, got, c.want)
}
}
}

24
go.mod
View File

@@ -15,9 +15,9 @@ require (
github.com/mymmrac/telego v1.8.0
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.26.3
github.com/shirou/gopsutil/v4 v4.26.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/valyala/fasthttp v1.70.0
github.com/xlzd/gotp v0.1.0
@@ -26,20 +26,20 @@ require (
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
golang.org/x/text v0.36.0
google.golang.org/grpc v1.80.0
google.golang.org/grpc v1.81.0
gorm.io/driver/sqlite v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic v1.15.1 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
@@ -57,12 +57,12 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
github.com/mattn/go-isatty v0.0.21 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -72,7 +72,7 @@ require (
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.8.8 // indirect
github.com/sagernet/sing v0.8.9 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
@@ -84,7 +84,7 @@ require (
github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
@@ -95,7 +95,7 @@ require (
golang.org/x/tools v0.44.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect

68
go.sum
View File

@@ -1,5 +1,5 @@
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
@@ -10,16 +10,16 @@ github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEc
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -107,8 +107,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -119,10 +119,10 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak=
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -138,8 +138,8 @@ github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 h1:lDH9UUVJtmYCjyT0C
github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -156,12 +156,12 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.8.8 h1:1dRlGJ3wm4d2nwjKI1R/dr/7GKDKgUvXyD4OAWlQyt8=
github.com/sagernet/sing v0.8.8/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing v0.8.9 h1:iX8FyMrWNl/divVgTe7cLT9n36v6bfzfnCYlcM1cLaU=
github.com/sagernet/sing v0.8.9/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -203,20 +203,20 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.1 h1:j2U/Qp+wvueSpqitLCSZPT/+ZpVc1xzuwdHWwl7d8ro=
go.mongodb.org/mongo-driver/v2 v2.5.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -256,10 +256,10 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -18,7 +18,7 @@ xui_service="${XUI_SERVICE:=/etc/systemd/system}"
if [[ -f /etc/os-release ]]; then
source /etc/os-release
release=$ID
elif [[ -f /usr/lib/os-release ]]; then
elif [[ -f /usr/lib/os-release ]]; then
source /usr/lib/os-release
release=$ID
else
@@ -59,16 +59,16 @@ is_domain() {
# Port helpers
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
if command -v ss > /dev/null 2>&1; then
ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
if command -v netstat > /dev/null 2>&1; then
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
if command -v lsof > /dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN > /dev/null 2>&1 && return 0
fi
return 1
}
@@ -77,35 +77,35 @@ install_base() {
case "${release}" in
ubuntu | debian | armbian)
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
;;
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update && yum install -y cronie curl tar tzdata socat ca-certificates openssl
else
dnf -y update && dnf install -y -q cronie curl tar tzdata socat ca-certificates openssl
fi
;;
;;
arch | manjaro | parch)
pacman -Syu && pacman -Syu --noconfirm cronie curl tar tzdata socat ca-certificates openssl
;;
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh && zypper -q install -y cron curl tar timezone socat ca-certificates openssl
;;
;;
alpine)
apk update && apk add dcron curl tar tzdata socat ca-certificates openssl
;;
;;
*)
apt-get update && apt-get install -y -q cron curl tar tzdata socat ca-certificates openssl
;;
;;
esac
}
gen_random_string() {
local length="$1"
openssl rand -base64 $(( length * 2 )) \
openssl rand -base64 $((length * 2)) \
| tr -dc 'a-zA-Z0-9' \
| head -c "$length"
}
@@ -113,7 +113,7 @@ gen_random_string() {
install_acme() {
echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
cd ~ || return 1
curl -s https://get.acme.sh | sh >/dev/null 2>&1
curl -s https://get.acme.sh | sh > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${red}Failed to install acme.sh${plain}"
return 1
@@ -128,60 +128,60 @@ setup_ssl_certificate() {
local server_ip="$2"
local existing_port="$3"
local existing_webBasePath="$4"
echo -e "${green}Setting up SSL certificate...${plain}"
# Check if acme.sh is installed
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
install_acme
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to install acme.sh, skipping SSL setup${plain}"
return 1
fi
fi
# Create certificate directory
local certPath="/root/cert/${domain}"
mkdir -p "$certPath"
# Issue certificate
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
rm -rf ~/.acme.sh/${domain} 2>/dev/null
rm -rf "$certPath" 2>/dev/null
rm -rf ~/.acme.sh/${domain} 2> /dev/null
rm -rf "$certPath" 2> /dev/null
return 1
fi
# Install certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem \
--reloadcmd "systemctl restart x-ui" >/dev/null 2>&1
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to install certificate${plain}"
return 1
fi
# Enable auto-renew
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
# Secure permissions: private key readable only by owner
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
chmod 600 $certPath/privkey.pem 2> /dev/null
chmod 644 $certPath/fullchain.pem 2> /dev/null
# Set certificate for panel
local webCertFile="/root/cert/${domain}/fullchain.pem"
local webKeyFile="/root/cert/${domain}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" > /dev/null 2>&1
echo -e "${green}SSL certificate installed and configured successfully!${plain}"
return 0
else
@@ -194,14 +194,14 @@ setup_ssl_certificate() {
# Requires acme.sh and port 80 open for HTTP-01 challenge
setup_ip_certificate() {
local ipv4="$1"
local ipv6="$2" # optional
local ipv6="$2" # optional
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
# Check for acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
install_acme
if [ $? -ne 0 ]; then
echo -e "${red}Failed to install acme.sh${plain}"
@@ -273,8 +273,8 @@ setup_ip_certificate() {
# Issue certificate with shortlived profile
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
~/.acme.sh/acme.sh --issue \
${domain_args} \
--standalone \
@@ -288,9 +288,9 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -308,25 +308,25 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
echo -e "${green}Certificate files installed successfully${plain}"
# Enable auto-upgrade for acme.sh (ensures cron job runs)
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
# Secure permissions: private key readable only by owner
chmod 600 ${certDir}/privkey.pem 2>/dev/null
chmod 644 ${certDir}/fullchain.pem 2>/dev/null
chmod 600 ${certDir}/privkey.pem 2> /dev/null
chmod 644 ${certDir}/fullchain.pem 2> /dev/null
# Configure panel to use the certificate
echo -e "${green}Setting certificate paths for the panel...${plain}"
${xui_folder}/x-ui cert -webCert "${certDir}/fullchain.pem" -webCertKey "${certDir}/privkey.pem"
if [ $? -ne 0 ]; then
echo -e "${yellow}Warning: Could not set certificate paths automatically${plain}"
echo -e "${yellow}Certificate files are at:${plain}"
@@ -346,9 +346,9 @@ setup_ip_certificate() {
ssl_cert_issue() {
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##')
local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
# check for acme.sh first
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
echo "acme.sh could not be found. Installing now..."
cd ~ || return 1
curl -s https://get.acme.sh | sh
@@ -364,18 +364,18 @@ ssl_cert_issue() {
local domain=""
while true; do
read -rp "Please enter your domain name: " domain
domain="${domain// /}" # Trim whitespace
domain="${domain// /}" # Trim whitespace
if [[ -z "$domain" ]]; then
echo -e "${red}Domain name cannot be empty. Please try again.${plain}"
continue
fi
if ! is_domain "$domain"; then
echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}"
continue
fi
break
done
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
@@ -383,9 +383,9 @@ ssl_cert_issue() {
# detect existing certificate and reuse it if present
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
@@ -412,7 +412,7 @@ ssl_cert_issue() {
# Stop panel temporarily
echo -e "${yellow}Stopping panel temporarily...${plain}"
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
systemctl stop x-ui 2> /dev/null || rc-service x-ui stop 2> /dev/null
if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate
@@ -421,7 +421,7 @@ ssl_cert_issue() {
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
@@ -441,18 +441,18 @@ ssl_cert_issue() {
echo -e "${green}\t0.${plain} Keep default reloadcmd"
read -rp "Choose an option: " choice
case "$choice" in
1)
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
;;
2)
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
read -rp "Please enter your custom reloadcmd: " reloadCmd
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
;;
*)
echo -e "${green}Keeping default reloadcmd${plain}"
;;
1)
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
;;
2)
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
read -rp "Please enter your custom reloadcmd: " reloadCmd
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
;;
*)
echo -e "${green}Keeping default reloadcmd${plain}"
;;
esac
fi
@@ -469,14 +469,14 @@ ssl_cert_issue() {
installWroteFiles=1
fi
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
fi
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
fi
@@ -486,18 +486,18 @@ ssl_cert_issue() {
echo -e "${yellow}Auto renew setup had issues, certificate details:${plain}"
ls -lah /root/cert/${domain}/
# Secure permissions: private key readable only by owner
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
chmod 600 $certPath/privkey.pem 2> /dev/null
chmod 644 $certPath/fullchain.pem 2> /dev/null
else
echo -e "${green}Auto renew succeeded, certificate details:${plain}"
ls -lah /root/cert/${domain}/
# Secure permissions: private key readable only by owner
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
chmod 600 $certPath/privkey.pem 2> /dev/null
chmod 644 $certPath/fullchain.pem 2> /dev/null
fi
# start panel
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
# Prompt user to set panel paths after successful certificate installation
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
@@ -513,14 +513,14 @@ ssl_cert_issue() {
echo ""
echo -e "${green}Access URL: https://${domain}:${existing_port}/${existing_webBasePath}${plain}"
echo -e "${yellow}Panel will restart to apply SSL certificate...${plain}"
systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null
systemctl restart x-ui 2> /dev/null || rc-service x-ui restart 2> /dev/null
else
echo -e "${red}Error: Certificate or private key file not found for domain: $domain.${plain}"
fi
else
echo -e "${yellow}Skipping panel path setting.${plain}"
fi
return 0
}
@@ -528,7 +528,7 @@ ssl_cert_issue() {
# Sets global `SSL_HOST` to the chosen domain/IP for Access URL usage
prompt_and_setup_ssl() {
local panel_port="$1"
local web_base_path="$2" # expected without leading slash
local web_base_path="$2" # expected without leading slash
local server_ip="$3"
local ssl_choice=""
@@ -539,124 +539,124 @@ prompt_and_setup_ssl() {
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace
ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
ssl_choice="2"
fi
case "$ssl_choice" in
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
fi
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2> /dev/null | tail -1 | awk '{print $1}')
fi
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
;;
2)
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
# Ask for optional IPv6
local ipv6_addr=""
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
# Stop panel if running (port 80 needed)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop >/dev/null 2>&1
else
systemctl stop x-ui >/dev/null 2>&1
fi
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
if [ $? -eq 0 ]; then
SSL_HOST="${server_ip}"
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
else
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
SSL_HOST="${server_ip}"
fi
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
;;
2)
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Remove spaces
# Ask for optional IPv6
local ipv6_addr=""
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
# Stop panel if running (port 80 needed)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop > /dev/null 2>&1
else
echo -e "${red}Error: File is empty!${plain}"
systemctl stop x-ui > /dev/null 2>&1
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
if [ $? -eq 0 ]; then
SSL_HOST="${server_ip}"
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
SSL_HOST="${server_ip}"
fi
done
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Remove spaces
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" > /dev/null 2>&1
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
;;
;;
esac
}
@@ -676,7 +676,7 @@ config_after_install() {
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
@@ -684,13 +684,13 @@ config_after_install() {
break
fi
done
if [[ ${#existing_webBasePath} -lt 4 ]]; then
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
local config_webBasePath=$(gen_random_string 18)
local config_username=$(gen_random_string 10)
local config_password=$(gen_random_string 10)
read -rp "Would you like to customize the Panel Port settings? (If not, a random port will be applied) [y/n]: " config_confirm
if [[ "${config_confirm}" == "y" || "${config_confirm}" == "Y" ]]; then
read -rp "Please set up the panel port: " config_port
@@ -699,9 +699,9 @@ config_after_install() {
local config_port=$(shuf -i 1024-62000 -n 1)
echo -e "${yellow}Generated random port: ${config_port}${plain}"
fi
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}" -port "${config_port}" -webBasePath "${config_webBasePath}"
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green} SSL Certificate Setup (MANDATORY) ${plain}"
@@ -711,7 +711,7 @@ config_after_install() {
echo ""
prompt_and_setup_ssl "${config_port}" "${config_webBasePath}" "${server_ip}"
# Display final credentials and access information
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
@@ -750,7 +750,7 @@ config_after_install() {
if [[ "$existing_hasDefaultCredential" == "true" ]]; then
local config_username=$(gen_random_string 10)
local config_password=$(gen_random_string 10)
echo -e "${yellow}Default credentials detected. Security update required...${plain}"
${xui_folder}/x-ui setting -username "${config_username}" -password "${config_password}"
echo -e "Generated new random login credentials:"
@@ -778,13 +778,13 @@ config_after_install() {
echo -e "${green}SSL certificate already configured. No action needed.${plain}"
fi
fi
${xui_folder}/x-ui migrate
}
install_x-ui() {
cd ${xui_folder%/x-ui}/
# Download resources
if [ $# == 0 ]; then
tag_version=$(curl -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
@@ -806,12 +806,12 @@ install_x-ui() {
tag_version=$1
tag_version_numeric=${tag_version#v}
min_version="2.3.5"
if [[ "$(printf '%s\n' "$min_version" "$tag_version_numeric" | sort -V | head -n1)" != "$min_version" ]]; then
echo -e "${red}Please use a newer version (at least v2.3.5). Exiting installation.${plain}"
exit 1
fi
url="https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz"
echo -e "Beginning to install x-ui $1"
curl -4fLRo ${xui_folder}-linux-$(arch).tar.gz ${url}
@@ -825,7 +825,7 @@ install_x-ui() {
echo -e "${red}Failed to download x-ui.sh${plain}"
exit 1
fi
# Stop x-ui service and remove old resources
if [[ -e ${xui_folder}/ ]]; then
if [[ $release == "alpine" ]]; then
@@ -835,22 +835,22 @@ install_x-ui() {
fi
rm ${xui_folder}/ -rf
fi
# Extract resources and set permissions
tar zxvf x-ui-linux-$(arch).tar.gz
rm x-ui-linux-$(arch).tar.gz -f
cd x-ui
chmod +x x-ui
chmod +x x-ui.sh
# Check the system's architecture and rename the file accordingly
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
mv bin/xray-linux-$(arch) bin/xray-linux-arm
chmod +x bin/xray-linux-arm
fi
chmod +x x-ui bin/xray-linux-$(arch)
# Update x-ui cli and se set permission
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
chmod +x /usr/bin/x-ui
@@ -870,7 +870,7 @@ install_x-ui() {
echo -e "${green}Created /etc/.gitignore and added x-ui.db for etckeeper${plain}"
fi
fi
if [[ $release == "alpine" ]]; then
curl -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc
if [[ $? -ne 0 ]]; then
@@ -883,73 +883,73 @@ install_x-ui() {
else
# Install systemd service file
service_installed=false
if [ -f "x-ui.service" ]; then
echo -e "${green}Found x-ui.service in extracted files, installing...${plain}"
cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
if [ "$service_installed" = false ]; then
case "${release}" in
ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then
echo -e "${green}Found x-ui.service.debian in extracted files, installing...${plain}"
cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Found x-ui.service.arch in extracted files, installing...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
*)
if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Found x-ui.service.rhel in extracted files, installing...${plain}"
cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
esac
fi
# If service file not found in tar.gz, download from GitHub
if [ "$service_installed" = false ]; then
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
case "${release}" in
ubuntu | debian | armbian)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
;;
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
;;
arch | manjaro | parch)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
;;
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
;;
*)
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
;;
curl -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
;;
esac
if [[ $? -ne 0 ]]; then
echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
exit 1
fi
service_installed=true
fi
if [ "$service_installed" = true ]; then
echo -e "${green}Setting up systemd unit...${plain}"
chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1
chmod 644 ${xui_service}/x-ui.service >/dev/null 2>&1
chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
systemctl daemon-reload
systemctl enable x-ui
systemctl start x-ui
@@ -958,7 +958,7 @@ install_x-ui() {
exit 1
fi
fi
echo -e "${green}x-ui ${tag_version}${plain} installation finished, it is running now..."
echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐

20
main.go
View File

@@ -130,20 +130,22 @@ func runWebServer() {
}
// resetSetting resets all panel settings to their default values.
func resetSetting() {
func resetSetting() error {
err := database.InitDB(config.GetDBPath())
if err != nil {
fmt.Println("Failed to initialize database:", err)
return
return err
}
settingService := service.SettingService{}
err = settingService.ResetSettings()
if err != nil {
fmt.Println("Failed to reset settings:", err)
return err
} else {
fmt.Println("Settings successfully reset.")
}
return nil
}
// showSetting displays the current panel settings if show is true.
@@ -255,11 +257,11 @@ func updateTgbotSetting(tgBotToken string, tgBotChatid string, tgBotRuntime stri
}
// updateSetting updates various panel settings including port, credentials, base path, listen IP, and two-factor authentication.
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) {
func updateSetting(port int, username string, password string, webBasePath string, listenIP string, resetTwoFactor bool) error {
err := database.InitDB(config.GetDBPath())
if err != nil {
fmt.Println("Database initialization failed:", err)
return
return err
}
settingService := service.SettingService{}
@@ -311,6 +313,8 @@ func updateSetting(port int, username string, password string, webBasePath strin
fmt.Printf("listen %v set successfully", listenIP)
}
}
return nil
}
// updateCert updates the SSL certificate files for the panel.
@@ -481,9 +485,13 @@ func main() {
return
}
if reset {
resetSetting()
if err = resetSetting(); err != nil {
return
}
} else {
updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor)
if err = updateSetting(port, username, password, webBasePath, listenIP, resetTwoFactor); err != nil {
return
}
}
if show {
showSetting(show)

View File

@@ -55,7 +55,7 @@ func (s *SubClashService) GetClash(subId string, host string) (string, string, e
}
}
for _, client := range clients {
if client.Enable && client.SubID == subId {
if client.SubID == subId {
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
proxies = append(proxies, s.getProxies(inbound, client, host)...)
}
@@ -159,6 +159,13 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
}
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
// Hysteria has its own transport + TLS model, applyTransport /
// applySecurity don't fit. IsHysteria also covers the literal
// "hysteria2" protocol string (#4081).
if model.IsHysteria(inbound.Protocol) {
return s.buildHysteriaProxy(inbound, client, extraRemark)
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"server": inbound.Listen,
@@ -222,6 +229,82 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
return proxy
}
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
// directly instead of going through streamData/tlsData, because those
// helpers prune fields (like `allowInsecure` / the salamander obfs
// block) that the hysteria proxy wants preserved.
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
proxyType := "hysteria2"
authKey := "password"
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
proxyType = "hysteria"
authKey = "auth-str"
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"type": proxyType,
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
authKey: client.Auth,
}
var rawStream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
// TLS details — hysteria always uses TLS.
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
proxy["sni"] = serverName
}
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
out := make([]string, 0, len(alpnList))
for _, a := range alpnList {
if s, ok := a.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
proxy["alpn"] = out
}
}
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
proxy["skip-cert-verify"] = true
}
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
proxy["client-fingerprint"] = fp
}
}
}
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
// block the subscription link generator uses.
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
mask, _ := m.(map[string]any)
if mask == nil || mask["type"] != "salamander" {
continue
}
settings, _ := mask["settings"].(map[string]any)
if pw, ok := settings["password"].(string); ok && pw != "" {
proxy["obfs"] = "salamander"
proxy["obfs-password"] = pw
break
}
}
}
}
return proxy
}
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
switch network {
case "", "tcp":

View File

@@ -138,6 +138,7 @@ func (a *SUBController) subs(c *gin.Context) {
"host": page.Host,
"base_path": page.BasePath,
"sId": page.SId,
"enabled": page.Enabled,
"download": page.Download,
"upload": page.Upload,
"total": page.Total,

View File

@@ -22,8 +22,7 @@ var defaultJson string
type SubJsonService struct {
configJson map[string]any
defaultOutbounds []json_util.RawMessage
fragment string
noises string
fragmentOrNoises bool
mux string
inboundService service.InboundService
@@ -42,6 +41,31 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
}
}
fragmentOrNoises := false
if fragment != "" || noises != "" {
fragmentOrNoises = true
defaultOutboundsSettings := map[string]any{
"domainStrategy": "UseIP",
"redirect": "",
}
if fragment != "" {
defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment)
}
if noises != "" {
defaultOutboundsSettings["noises"] = json_util.RawMessage(noises)
}
defaultDirectOutbound := map[string]any{
"protocol": "freedom",
"settings": defaultOutboundsSettings,
"tag": "direct_out",
}
jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ")
defaultOutbounds = append(defaultOutbounds, jsonBytes)
}
if rules != "" {
var newRules []any
routing, _ := configJson["routing"].(map[string]any)
@@ -52,19 +76,10 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
configJson["routing"] = routing
}
if fragment != "" {
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(fragment))
}
if noises != "" {
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(noises))
}
return &SubJsonService{
configJson: configJson,
defaultOutbounds: defaultOutbounds,
fragment: fragment,
noises: noises,
fragmentOrNoises: fragmentOrNoises,
mux: mux,
SubService: subService,
}
@@ -101,7 +116,7 @@ func (s *SubJsonService) GetJson(subId string, host string) (string, string, err
}
for _, client := range clients {
if client.Enable && client.SubID == subId {
if client.SubID == subId {
clientTraffics = append(clientTraffics, s.SubService.getClientTraffics(inbound.ClientStats, client.Email))
newConfigs := s.getConfig(inbound, client, host)
configArray = append(configArray, newConfigs...)
@@ -194,7 +209,7 @@ func (s *SubJsonService) getConfig(inbound *model.Inbound, client model.Client,
newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client))
case "trojan", "shadowsocks":
newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client))
case "hysteria":
case "hysteria", "hysteria2":
newOutbounds = append(newOutbounds, s.genHy(inbound, newStream, client))
}
@@ -224,8 +239,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
}
delete(streamSettings, "sockopt")
if s.fragment != "" {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "fragment", "tcpKeepAliveIdle": 100, "tcpMptcp": true, "penetrate": true}`)
if s.fragmentOrNoises {
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
}
// remove proxy protocol

File diff suppressed because it is too large Load Diff

564
update.sh
View File

@@ -12,16 +12,16 @@ xui_service="${XUI_SERVICE:=/etc/systemd/system}"
# Don't edit this config
b_source="${BASH_SOURCE[0]}"
while [ -h "$b_source" ]; do
b_dir="$(cd -P "$(dirname "$b_source")" >/dev/null 2>&1 && pwd || pwd -P)"
b_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
b_source="$(readlink "$b_source")"
[[ $b_source != /* ]] && b_source="$b_dir/$b_source"
done
cur_dir="$(cd -P "$(dirname "$b_source")" >/dev/null 2>&1 && pwd || pwd -P)"
cur_dir="$(cd -P "$(dirname "$b_source")" > /dev/null 2>&1 && pwd || pwd -P)"
script_name=$(basename "$0")
# Check command exist function
_command_exists() {
type "$1" &>/dev/null
type "$1" &> /dev/null
}
# Fail, log and exit script function
@@ -44,7 +44,7 @@ fi
if [[ -f /etc/os-release ]]; then
source /etc/os-release
release=$ID
elif [[ -f /usr/lib/os-release ]]; then
elif [[ -f /usr/lib/os-release ]]; then
source /usr/lib/os-release
release=$ID
else
@@ -61,7 +61,7 @@ arch() {
armv6* | armv6) echo 'armv6' ;;
armv5* | armv5) echo 'armv5' ;;
s390x) echo 's390x' ;;
*) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" >/dev/null 2>&1 && exit 2;;
*) echo -e "${red}Unsupported CPU architecture!${plain}" && rm -f "${cur_dir}/${script_name}" > /dev/null 2>&1 && exit 2 ;;
esac
}
@@ -84,23 +84,23 @@ is_domain() {
# Port helpers
is_port_in_use() {
local port="$1"
if command -v ss >/dev/null 2>&1; then
ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
if command -v ss > /dev/null 2>&1; then
ss -ltn 2> /dev/null | awk -v p=":${port}$" '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -lnt 2>/dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
if command -v netstat > /dev/null 2>&1; then
netstat -lnt 2> /dev/null | awk -v p=":${port} " '$4 ~ p {exit 0} END {exit 1}'
return
fi
if command -v lsof >/dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN >/dev/null 2>&1 && return 0
if command -v lsof > /dev/null 2>&1; then
lsof -nP -iTCP:${port} -sTCP:LISTEN > /dev/null 2>&1 && return 0
fi
return 1
}
gen_random_string() {
local length="$1"
openssl rand -base64 $(( length * 2 )) \
openssl rand -base64 $((length * 2)) \
| tr -dc 'a-zA-Z0-9' \
| head -c "$length"
}
@@ -109,37 +109,37 @@ install_base() {
echo -e "${green}Updating and install dependency packages...${plain}"
case "${release}" in
ubuntu | debian | armbian)
apt-get update >/dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
;;
apt-get update > /dev/null 2>&1 && apt-get install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
;;
fedora | amzn | virtuozzo | rhel | almalinux | rocky | ol)
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
;;
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
;;
centos)
if [[ "${VERSION_ID}" =~ ^7 ]]; then
yum -y update >/dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
yum -y update > /dev/null 2>&1 && yum install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
else
dnf -y update >/dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl >/dev/null 2>&1
dnf -y update > /dev/null 2>&1 && dnf install -y -q cronie curl tar tzdata socat openssl > /dev/null 2>&1
fi
;;
;;
arch | manjaro | parch)
pacman -Syu >/dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl >/dev/null 2>&1
;;
pacman -Syu > /dev/null 2>&1 && pacman -Syu --noconfirm cronie curl tar tzdata socat openssl > /dev/null 2>&1
;;
opensuse-tumbleweed | opensuse-leap)
zypper refresh >/dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl >/dev/null 2>&1
;;
zypper refresh > /dev/null 2>&1 && zypper -q install -y cron curl tar timezone socat openssl > /dev/null 2>&1
;;
alpine)
apk update >/dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl>/dev/null 2>&1
;;
apk update > /dev/null 2>&1 && apk add dcron curl tar tzdata socat openssl > /dev/null 2>&1
;;
*)
apt-get update >/dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl >/dev/null 2>&1
;;
apt-get update > /dev/null 2>&1 && apt install -y -q cron curl tar tzdata socat openssl > /dev/null 2>&1
;;
esac
}
install_acme() {
echo -e "${green}Installing acme.sh for SSL certificate management...${plain}"
cd ~ || return 1
curl -s https://get.acme.sh | sh >/dev/null 2>&1
curl -s https://get.acme.sh | sh > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${red}Failed to install acme.sh${plain}"
return 1
@@ -154,59 +154,59 @@ setup_ssl_certificate() {
local server_ip="$2"
local existing_port="$3"
local existing_webBasePath="$4"
echo -e "${green}Setting up SSL certificate...${plain}"
# Check if acme.sh is installed
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
install_acme
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to install acme.sh, skipping SSL setup${plain}"
return 1
fi
fi
# Create certificate directory
local certPath="/root/cert/${domain}"
mkdir -p "$certPath"
# Issue certificate
echo -e "${green}Issuing SSL certificate for ${domain}...${plain}"
echo -e "${yellow}Note: Port 80 must be open and accessible from the internet${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
~/.acme.sh/acme.sh --issue -d ${domain} --listen-v6 --standalone --httpport 80 --force
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to issue certificate for ${domain}${plain}"
echo -e "${yellow}Please ensure port 80 is open and try again later with: x-ui${plain}"
rm -rf ~/.acme.sh/${domain} 2>/dev/null
rm -rf "$certPath" 2>/dev/null
rm -rf ~/.acme.sh/${domain} 2> /dev/null
rm -rf "$certPath" 2> /dev/null
return 1
fi
# Install certificate
~/.acme.sh/acme.sh --installcert -d ${domain} \
--key-file /root/cert/${domain}/privkey.pem \
--fullchain-file /root/cert/${domain}/fullchain.pem \
--reloadcmd "systemctl restart x-ui" >/dev/null 2>&1
--reloadcmd "systemctl restart x-ui" > /dev/null 2>&1
if [ $? -ne 0 ]; then
echo -e "${yellow}Failed to install certificate${plain}"
return 1
fi
# Enable auto-renew
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
chmod 600 $certPath/privkey.pem 2>/dev/null
chmod 644 $certPath/fullchain.pem 2>/dev/null
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
chmod 600 $certPath/privkey.pem 2> /dev/null
chmod 644 $certPath/fullchain.pem 2> /dev/null
# Set certificate for panel
local webCertFile="/root/cert/${domain}/fullchain.pem"
local webKeyFile="/root/cert/${domain}/privkey.pem"
if [[ -f "$webCertFile" && -f "$webKeyFile" ]]; then
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" >/dev/null 2>&1
${xui_folder}/x-ui cert -webCert "$webCertFile" -webCertKey "$webKeyFile" > /dev/null 2>&1
echo -e "${green}SSL certificate installed and configured successfully!${plain}"
return 0
else
@@ -219,14 +219,14 @@ setup_ssl_certificate() {
# Requires acme.sh and port 80 open for HTTP-01 challenge
setup_ip_certificate() {
local ipv4="$1"
local ipv6="$2" # optional
local ipv6="$2" # optional
echo -e "${green}Setting up Let's Encrypt IP certificate (shortlived profile)...${plain}"
echo -e "${yellow}Note: IP certificates are valid for ~6 days and will auto-renew.${plain}"
echo -e "${yellow}Default listener is port 80. If you choose another port, ensure external port 80 forwards to it.${plain}"
# Check for acme.sh
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
install_acme
if [ $? -ne 0 ]; then
echo -e "${red}Failed to install acme.sh${plain}"
@@ -298,8 +298,8 @@ setup_ip_certificate() {
# Issue certificate with shortlived profile
echo -e "${green}Issuing IP certificate for ${ipv4}...${plain}"
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force >/dev/null 2>&1
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt --force > /dev/null 2>&1
~/.acme.sh/acme.sh --issue \
${domain_args} \
--standalone \
@@ -313,9 +313,9 @@ setup_ip_certificate() {
echo -e "${red}Failed to issue IP certificate${plain}"
echo -e "${yellow}Please ensure port ${WebPort} is reachable (or forwarded from external port 80)${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
@@ -333,19 +333,19 @@ setup_ip_certificate() {
if [[ ! -f "${certDir}/fullchain.pem" || ! -f "${certDir}/privkey.pem" ]]; then
echo -e "${red}Certificate files not found after installation${plain}"
# Cleanup acme.sh data for both IPv4 and IPv6 if specified
rm -rf ~/.acme.sh/${ipv4} 2>/dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2>/dev/null
rm -rf ${certDir} 2>/dev/null
rm -rf ~/.acme.sh/${ipv4} 2> /dev/null
[[ -n "$ipv6" ]] && rm -rf ~/.acme.sh/${ipv6} 2> /dev/null
rm -rf ${certDir} 2> /dev/null
return 1
fi
echo -e "${green}Certificate files installed successfully${plain}"
# Enable auto-upgrade for acme.sh (ensures cron job runs)
~/.acme.sh/acme.sh --upgrade --auto-upgrade >/dev/null 2>&1
~/.acme.sh/acme.sh --upgrade --auto-upgrade > /dev/null 2>&1
chmod 600 ${certDir}/privkey.pem 2>/dev/null
chmod 644 ${certDir}/fullchain.pem 2>/dev/null
chmod 600 ${certDir}/privkey.pem 2> /dev/null
chmod 644 ${certDir}/fullchain.pem 2> /dev/null
# Configure panel to use the certificate
echo -e "${green}Setting certificate paths for the panel...${plain}"
@@ -369,9 +369,9 @@ setup_ip_certificate() {
ssl_cert_issue() {
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep 'webBasePath:' | awk -F': ' '{print $2}' | tr -d '[:space:]' | sed 's#^/##')
local existing_port=$(${xui_folder}/x-ui setting -show true | grep 'port:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
# check for acme.sh first
if ! command -v ~/.acme.sh/acme.sh &>/dev/null; then
if ! command -v ~/.acme.sh/acme.sh &> /dev/null; then
echo "acme.sh could not be found. Installing now..."
cd ~ || return 1
curl -s https://get.acme.sh | sh
@@ -387,18 +387,18 @@ ssl_cert_issue() {
local domain=""
while true; do
read -rp "Please enter your domain name: " domain
domain="${domain// /}" # Trim whitespace
domain="${domain// /}" # Trim whitespace
if [[ -z "$domain" ]]; then
echo -e "${red}Domain name cannot be empty. Please try again.${plain}"
continue
fi
if ! is_domain "$domain"; then
echo -e "${red}Invalid domain format: ${domain}. Please enter a valid domain name.${plain}"
continue
fi
break
done
echo -e "${green}Your domain is: ${domain}, checking it...${plain}"
@@ -406,9 +406,9 @@ ssl_cert_issue() {
# detect existing certificate and reuse it if present
local cert_exists=0
if ~/.acme.sh/acme.sh --list 2>/dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
if ~/.acme.sh/acme.sh --list 2> /dev/null | awk '{print $1}' | grep -Fxq "${domain}"; then
cert_exists=1
local certInfo=$(~/.acme.sh/acme.sh --list 2>/dev/null | grep -F "${domain}")
local certInfo=$(~/.acme.sh/acme.sh --list 2> /dev/null | grep -F "${domain}")
echo -e "${yellow}Existing certificate found for ${domain}, will reuse it.${plain}"
[[ -n "${certInfo}" ]] && echo "$certInfo"
else
@@ -435,7 +435,7 @@ ssl_cert_issue() {
# Stop panel temporarily
echo -e "${yellow}Stopping panel temporarily...${plain}"
systemctl stop x-ui 2>/dev/null || rc-service x-ui stop 2>/dev/null
systemctl stop x-ui 2> /dev/null || rc-service x-ui stop 2> /dev/null
if [[ ${cert_exists} -eq 0 ]]; then
# issue the certificate
@@ -444,7 +444,7 @@ ssl_cert_issue() {
if [ $? -ne 0 ]; then
echo -e "${red}Issuing certificate failed, please check logs.${plain}"
rm -rf ~/.acme.sh/${domain}
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
else
echo -e "${green}Issuing certificate succeeded, installing certificates...${plain}"
@@ -464,18 +464,18 @@ ssl_cert_issue() {
echo -e "${green}\t0.${plain} Keep default reloadcmd"
read -rp "Choose an option: " choice
case "$choice" in
1)
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
;;
2)
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
read -rp "Please enter your custom reloadcmd: " reloadCmd
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
;;
*)
echo -e "${green}Keeping default reloadcmd${plain}"
;;
1)
echo -e "${green}Reloadcmd is: systemctl reload nginx ; systemctl restart x-ui${plain}"
reloadCmd="systemctl reload nginx ; systemctl restart x-ui"
;;
2)
echo -e "${yellow}It's recommended to put x-ui restart at the end${plain}"
read -rp "Please enter your custom reloadcmd: " reloadCmd
echo -e "${green}Reloadcmd is: ${reloadCmd}${plain}"
;;
*)
echo -e "${green}Keeping default reloadcmd${plain}"
;;
esac
fi
@@ -492,14 +492,14 @@ ssl_cert_issue() {
installWroteFiles=1
fi
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && ( ${installRc} -eq 0 || ${installWroteFiles} -eq 1 ) ]]; then
if [[ -f "/root/cert/${domain}/privkey.pem" && -f "/root/cert/${domain}/fullchain.pem" && (${installRc} -eq 0 || ${installWroteFiles} -eq 1) ]]; then
echo -e "${green}Installing certificate succeeded, enabling auto renew...${plain}"
else
echo -e "${red}Installing certificate failed, exiting.${plain}"
if [[ ${cert_exists} -eq 0 ]]; then
rm -rf ~/.acme.sh/${domain}
fi
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
return 1
fi
@@ -518,7 +518,7 @@ ssl_cert_issue() {
fi
# Restart panel
systemctl start x-ui 2>/dev/null || rc-service x-ui start 2>/dev/null
systemctl start x-ui 2> /dev/null || rc-service x-ui start 2> /dev/null
# Prompt user to set panel paths after successful certificate installation
read -rp "Would you like to set this certificate for the panel? (y/n): " setPanel
@@ -534,21 +534,21 @@ ssl_cert_issue() {
echo ""
echo -e "${green}Access URL: https://${domain}:${existing_port}/${existing_webBasePath}${plain}"
echo -e "${yellow}Panel will restart to apply SSL certificate...${plain}"
systemctl restart x-ui 2>/dev/null || rc-service x-ui restart 2>/dev/null
systemctl restart x-ui 2> /dev/null || rc-service x-ui restart 2> /dev/null
else
echo -e "${red}Error: Certificate or private key file not found for domain: $domain.${plain}"
fi
else
echo -e "${yellow}Skipping panel path setting.${plain}"
fi
return 0
}
# Unified interactive SSL setup (domain or IP)
# Sets global `SSL_HOST` to the chosen domain/IP
prompt_and_setup_ssl() {
local panel_port="$1"
local web_base_path="$2" # expected without leading slash
local web_base_path="$2" # expected without leading slash
local server_ip="$3"
local ssl_choice=""
@@ -559,132 +559,132 @@ prompt_and_setup_ssl() {
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace
ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
ssl_choice="2"
fi
case "$ssl_choice" in
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2>/dev/null | tail -1 | awk '{print $1}')
fi
1)
# User chose Let's Encrypt domain option
echo -e "${green}Using Let's Encrypt for domain certificate...${plain}"
if ssl_cert_issue; then
local cert_domain="${SSL_ISSUED_DOMAIN}"
if [[ -z "${cert_domain}" ]]; then
cert_domain=$(~/.acme.sh/acme.sh --list 2> /dev/null | tail -1 | awk '{print $1}')
fi
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
if [[ -n "${cert_domain}" ]]; then
SSL_HOST="${cert_domain}"
echo -e "${green}✓ SSL certificate configured successfully with domain: ${cert_domain}${plain}"
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${yellow}SSL setup may have completed, but domain extraction failed${plain}"
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
else
echo -e "${red}SSL certificate setup failed for domain mode.${plain}"
SSL_HOST="${server_ip}"
fi
;;
2)
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
# Ask for optional IPv6
local ipv6_addr=""
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
# Stop panel if running (port 80 needed)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop >/dev/null 2>&1
else
systemctl stop x-ui >/dev/null 2>&1
fi
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
if [ $? -eq 0 ]; then
SSL_HOST="${server_ip}"
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
else
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
SSL_HOST="${server_ip}"
fi
# Restart panel after SSL is configured (restart applies new cert settings)
if [[ $release == "alpine" ]]; then
rc-service x-ui restart >/dev/null 2>&1
else
systemctl restart x-ui >/dev/null 2>&1
fi
;;
2)
# User chose Let's Encrypt IP certificate option
echo -e "${green}Using Let's Encrypt for IP certificate (shortlived profile)...${plain}"
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# Ask for optional IPv6
local ipv6_addr=""
read -rp "Do you have an IPv6 address to include? (leave empty to skip): " ipv6_addr
ipv6_addr="${ipv6_addr// /}" # Trim whitespace
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Remove spaces
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
# Stop panel if running (port 80 needed)
if [[ $release == "alpine" ]]; then
rc-service x-ui stop > /dev/null 2>&1
else
echo -e "${red}Error: File is empty!${plain}"
systemctl stop x-ui > /dev/null 2>&1
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
setup_ip_certificate "${server_ip}" "${ipv6_addr}"
if [ $? -eq 0 ]; then
SSL_HOST="${server_ip}"
echo -e "${green}✓ Let's Encrypt IP certificate configured successfully${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
echo -e "${red}✗ IP certificate setup failed. Please check port 80 is open.${plain}"
SSL_HOST="${server_ip}"
fi
done
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" >/dev/null 2>&1
# Restart panel after SSL is configured (restart applies new cert settings)
if [[ $release == "alpine" ]]; then
rc-service x-ui restart > /dev/null 2>&1
else
systemctl restart x-ui > /dev/null 2>&1
fi
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
;;
3)
# User chose Custom Paths (User Provided) option
echo -e "${green}Using custom existing certificate...${plain}"
local custom_cert=""
local custom_key=""
local custom_domain=""
# 3.1 Request Domain to compose Panel URL later
read -rp "Please enter domain name certificate issued for: " custom_domain
custom_domain="${custom_domain// /}" # Remove spaces
# 3.2 Loop for Certificate Path
while true; do
read -rp "Input certificate path (keywords: .crt / fullchain): " custom_cert
# Strip quotes if present
custom_cert=$(echo "$custom_cert" | tr -d '"' | tr -d "'")
if [[ -f "$custom_cert" && -r "$custom_cert" && -s "$custom_cert" ]]; then
break
elif [[ ! -f "$custom_cert" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_cert" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.3 Loop for Private Key Path
while true; do
read -rp "Input private key path (keywords: .key / privatekey): " custom_key
# Strip quotes if present
custom_key=$(echo "$custom_key" | tr -d '"' | tr -d "'")
if [[ -f "$custom_key" && -r "$custom_key" && -s "$custom_key" ]]; then
break
elif [[ ! -f "$custom_key" ]]; then
echo -e "${red}Error: File does not exist! Try again.${plain}"
elif [[ ! -r "$custom_key" ]]; then
echo -e "${red}Error: File exists but is not readable (check permissions)!${plain}"
else
echo -e "${red}Error: File is empty!${plain}"
fi
done
# 3.4 Apply Settings via x-ui binary
${xui_folder}/x-ui cert -webCert "$custom_cert" -webCertKey "$custom_key" > /dev/null 2>&1
# Set SSL_HOST for composing Panel URL
if [[ -n "$custom_domain" ]]; then
SSL_HOST="$custom_domain"
else
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
fi
echo -e "${green}✓ Custom certificate paths applied.${plain}"
echo -e "${yellow}Note: You are responsible for renewing these files externally.${plain}"
systemctl restart x-ui >/dev/null 2>&1 || rc-service x-ui restart >/dev/null 2>&1
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
;;
;;
esac
}
@@ -692,12 +692,12 @@ config_after_update() {
echo -e "${yellow}x-ui settings:${plain}"
${xui_folder}/x-ui setting -show true
${xui_folder}/x-ui migrate
# Properly detect empty cert by checking if cert: line exists and has content after it
local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2>/dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
local existing_cert=$(${xui_folder}/x-ui setting -getCert true 2> /dev/null | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}' | sed 's#^/##')
# Get server IP
local URL_lists=(
"https://api4.ipify.org"
@@ -709,7 +709,7 @@ config_after_update() {
)
local server_ip=""
for ip_address in "${URL_lists[@]}"; do
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2>/dev/null)
local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
local http_code=$(echo "$response" | tail -n1)
local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
@@ -717,7 +717,7 @@ config_after_update() {
break
fi
done
# Handle missing/short webBasePath
if [[ ${#existing_webBasePath} -lt 4 ]]; then
echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
@@ -726,7 +726,7 @@ config_after_update() {
existing_webBasePath="${config_webBasePath}"
echo -e "${green}New WebBasePath: ${config_webBasePath}${plain}"
fi
# Check and prompt for SSL if missing
if [[ -z "$existing_cert" ]]; then
echo ""
@@ -736,16 +736,16 @@ config_after_update() {
echo -e "${yellow}For security, SSL certificate is MANDATORY for all panels.${plain}"
echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
echo ""
if [[ -z "${server_ip}" ]]; then
echo -e "${red}Failed to detect server IP${plain}"
echo -e "${yellow}Please configure SSL manually using: x-ui${plain}"
return
fi
# Prompt and setup SSL (domain or IP)
prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
echo ""
echo -e "${green}═══════════════════════════════════════════${plain}"
echo -e "${green} Panel Access Information ${plain}"
@@ -768,17 +768,17 @@ config_after_update() {
update_x-ui() {
cd ${xui_folder%/x-ui}/
if [ -f "${xui_folder}/x-ui" ]; then
current_xui_version=$(${xui_folder}/x-ui -v)
echo -e "${green}Current x-ui version: ${current_xui_version}${plain}"
else
_fail "ERROR: Current x-ui version: unknown"
fi
echo -e "${green}Downloading new x-ui version...${plain}"
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2>/dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
tag_version=$(${curl_bin} -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" 2> /dev/null | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
if [[ ! -n "$tag_version" ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
tag_version=$(${curl_bin} -4 -Ls "https://api.github.com/repos/MHSanaei/3x-ui/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
@@ -787,110 +787,110 @@ update_x-ui() {
fi
fi
echo -e "Got x-ui latest version: ${tag_version}, beginning the installation..."
${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null
${curl_bin} -fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
if [[ $? -ne 0 ]]; then
echo -e "${yellow}Trying to fetch version with IPv4...${plain}"
${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2>/dev/null
${curl_bin} -4fLRo ${xui_folder}-linux-$(arch).tar.gz https://github.com/MHSanaei/3x-ui/releases/download/${tag_version}/x-ui-linux-$(arch).tar.gz 2> /dev/null
if [[ $? -ne 0 ]]; then
_fail "ERROR: Failed to download x-ui, please be sure that your server can access GitHub"
fi
fi
if [[ -e ${xui_folder}/ ]]; then
echo -e "${green}Stopping x-ui...${plain}"
if [[ $release == "alpine" ]]; then
if [ -f "/etc/init.d/x-ui" ]; then
rc-service x-ui stop >/dev/null 2>&1
rc-update del x-ui >/dev/null 2>&1
rc-service x-ui stop > /dev/null 2>&1
rc-update del x-ui > /dev/null 2>&1
echo -e "${green}Removing old service unit version...${plain}"
rm -f /etc/init.d/x-ui >/dev/null 2>&1
rm -f /etc/init.d/x-ui > /dev/null 2>&1
else
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
_fail "ERROR: x-ui service unit not installed."
fi
else
if [ -f "${xui_service}/x-ui.service" ]; then
systemctl stop x-ui >/dev/null 2>&1
systemctl disable x-ui >/dev/null 2>&1
systemctl stop x-ui > /dev/null 2>&1
systemctl disable x-ui > /dev/null 2>&1
echo -e "${green}Removing old systemd unit version...${plain}"
rm ${xui_service}/x-ui.service -f >/dev/null 2>&1
systemctl daemon-reload >/dev/null 2>&1
rm ${xui_service}/x-ui.service -f > /dev/null 2>&1
systemctl daemon-reload > /dev/null 2>&1
else
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
_fail "ERROR: x-ui systemd unit not installed."
fi
fi
echo -e "${green}Removing old x-ui version...${plain}"
rm ${xui_folder} -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.debian -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.arch -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.service.rhel -f >/dev/null 2>&1
rm ${xui_folder}/x-ui -f >/dev/null 2>&1
rm ${xui_folder}/x-ui.sh -f >/dev/null 2>&1
rm ${xui_folder} -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service.debian -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service.arch -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.service.rhel -f > /dev/null 2>&1
rm ${xui_folder}/x-ui -f > /dev/null 2>&1
rm ${xui_folder}/x-ui.sh -f > /dev/null 2>&1
echo -e "${green}Removing old xray version...${plain}"
rm ${xui_folder}/bin/xray-linux-amd64 -f >/dev/null 2>&1
rm ${xui_folder}/bin/xray-linux-amd64 -f > /dev/null 2>&1
echo -e "${green}Removing old README and LICENSE file...${plain}"
rm ${xui_folder}/bin/README.md -f >/dev/null 2>&1
rm ${xui_folder}/bin/LICENSE -f >/dev/null 2>&1
rm ${xui_folder}/bin/README.md -f > /dev/null 2>&1
rm ${xui_folder}/bin/LICENSE -f > /dev/null 2>&1
else
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
_fail "ERROR: x-ui not installed."
fi
echo -e "${green}Installing new x-ui version...${plain}"
tar zxvf x-ui-linux-$(arch).tar.gz >/dev/null 2>&1
rm x-ui-linux-$(arch).tar.gz -f >/dev/null 2>&1
cd x-ui >/dev/null 2>&1
chmod +x x-ui >/dev/null 2>&1
tar zxvf x-ui-linux-$(arch).tar.gz > /dev/null 2>&1
rm x-ui-linux-$(arch).tar.gz -f > /dev/null 2>&1
cd x-ui > /dev/null 2>&1
chmod +x x-ui > /dev/null 2>&1
# Check the system's architecture and rename the file accordingly
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
mv bin/xray-linux-$(arch) bin/xray-linux-arm >/dev/null 2>&1
chmod +x bin/xray-linux-arm >/dev/null 2>&1
mv bin/xray-linux-$(arch) bin/xray-linux-arm > /dev/null 2>&1
chmod +x bin/xray-linux-arm > /dev/null 2>&1
fi
chmod +x x-ui bin/xray-linux-$(arch) >/dev/null 2>&1
chmod +x x-ui bin/xray-linux-$(arch) > /dev/null 2>&1
echo -e "${green}Downloading and installing x-ui.sh script...${plain}"
${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh >/dev/null 2>&1
${curl_bin} -fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
echo -e "${yellow}Trying to fetch x-ui with IPv4...${plain}"
${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh >/dev/null 2>&1
${curl_bin} -4fLRo /usr/bin/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.sh > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
_fail "ERROR: Failed to download x-ui.sh script, please be sure that your server can access GitHub"
fi
fi
chmod +x ${xui_folder}/x-ui.sh >/dev/null 2>&1
chmod +x /usr/bin/x-ui >/dev/null 2>&1
mkdir -p /var/log/x-ui >/dev/null 2>&1
chmod +x ${xui_folder}/x-ui.sh > /dev/null 2>&1
chmod +x /usr/bin/x-ui > /dev/null 2>&1
mkdir -p /var/log/x-ui > /dev/null 2>&1
echo -e "${green}Changing owner...${plain}"
chown -R root:root ${xui_folder} >/dev/null 2>&1
chown -R root:root ${xui_folder} > /dev/null 2>&1
if [ -f "${xui_folder}/bin/config.json" ]; then
echo -e "${green}Changing on config file permissions...${plain}"
chmod 640 ${xui_folder}/bin/config.json >/dev/null 2>&1
chmod 640 ${xui_folder}/bin/config.json > /dev/null 2>&1
fi
if [[ $release == "alpine" ]]; then
echo -e "${green}Downloading and installing startup unit x-ui.rc...${plain}"
${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc >/dev/null 2>&1
${curl_bin} -fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc >/dev/null 2>&1
${curl_bin} -4fLRo /etc/init.d/x-ui https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.rc > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
_fail "ERROR: Failed to download startup unit x-ui.rc, please be sure that your server can access GitHub"
fi
fi
chmod +x /etc/init.d/x-ui >/dev/null 2>&1
chown root:root /etc/init.d/x-ui >/dev/null 2>&1
rc-update add x-ui >/dev/null 2>&1
rc-service x-ui start >/dev/null 2>&1
chmod +x /etc/init.d/x-ui > /dev/null 2>&1
chown root:root /etc/init.d/x-ui > /dev/null 2>&1
rc-update add x-ui > /dev/null 2>&1
rc-service x-ui start > /dev/null 2>&1
else
if [ -f "x-ui.service" ]; then
echo -e "${green}Installing systemd unit...${plain}"
cp -f x-ui.service ${xui_service}/ >/dev/null 2>&1
cp -f x-ui.service ${xui_service}/ > /dev/null 2>&1
if [[ $? -ne 0 ]]; then
echo -e "${red}Failed to copy x-ui.service${plain}"
exit 1
@@ -901,62 +901,62 @@ update_x-ui() {
ubuntu | debian | armbian)
if [ -f "x-ui.service.debian" ]; then
echo -e "${green}Installing debian-like systemd unit...${plain}"
cp -f x-ui.service.debian ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.debian ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
arch | manjaro | parch)
if [ -f "x-ui.service.arch" ]; then
echo -e "${green}Installing arch-like systemd unit...${plain}"
cp -f x-ui.service.arch ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.arch ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
*)
if [ -f "x-ui.service.rhel" ]; then
echo -e "${green}Installing rhel-like systemd unit...${plain}"
cp -f x-ui.service.rhel ${xui_service}/x-ui.service >/dev/null 2>&1
cp -f x-ui.service.rhel ${xui_service}/x-ui.service > /dev/null 2>&1
if [[ $? -eq 0 ]]; then
service_installed=true
fi
fi
;;
;;
esac
# If service file not found in tar.gz, download from GitHub
if [ "$service_installed" = false ]; then
echo -e "${yellow}Service files not found in tar.gz, downloading from GitHub...${plain}"
case "${release}" in
ubuntu | debian | armbian)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian >/dev/null 2>&1
;;
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.debian > /dev/null 2>&1
;;
arch | manjaro | parch)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch >/dev/null 2>&1
;;
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.arch > /dev/null 2>&1
;;
*)
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel >/dev/null 2>&1
;;
${curl_bin} -4fLRo ${xui_service}/x-ui.service https://raw.githubusercontent.com/MHSanaei/3x-ui/main/x-ui.service.rhel > /dev/null 2>&1
;;
esac
if [[ $? -ne 0 ]]; then
echo -e "${red}Failed to install x-ui.service from GitHub${plain}"
exit 1
fi
fi
fi
chown root:root ${xui_service}/x-ui.service >/dev/null 2>&1
chmod 644 ${xui_service}/x-ui.service >/dev/null 2>&1
systemctl daemon-reload >/dev/null 2>&1
systemctl enable x-ui >/dev/null 2>&1
systemctl start x-ui >/dev/null 2>&1
chown root:root ${xui_service}/x-ui.service > /dev/null 2>&1
chmod 644 ${xui_service}/x-ui.service > /dev/null 2>&1
systemctl daemon-reload > /dev/null 2>&1
systemctl enable x-ui > /dev/null 2>&1
systemctl start x-ui > /dev/null 2>&1
fi
config_after_update
echo -e "${green}x-ui ${tag_version}${plain} updating finished, it is running now..."
echo -e ""
echo -e "┌───────────────────────────────────────────────────────┐

View File

@@ -3,16 +3,15 @@ const Protocols = {
VLESS: 'vless',
TROJAN: 'trojan',
SHADOWSOCKS: 'shadowsocks',
TUNNEL: 'tunnel',
WIREGUARD: 'wireguard',
HYSTERIA: 'hysteria',
MIXED: 'mixed',
HTTP: 'http',
WIREGUARD: 'wireguard',
TUNNEL: 'tunnel',
TUN: 'tun',
HYSTERIA: 'hysteria',
};
const SSMethods = {
AES_256_GCM: 'aes-256-gcm',
CHACHA20_POLY1305: 'chacha20-poly1305',
CHACHA20_IETF_POLY1305: 'chacha20-ietf-poly1305',
XCHACHA20_IETF_POLY1305: 'xchacha20-ietf-poly1305',
@@ -324,7 +323,7 @@ class KcpStreamSettings extends XrayCommonClass {
uplinkCapacity = 5,
downlinkCapacity = 20,
cwndMultiplier = 1,
maxSendingWindow = 1350,
maxSendingWindow = 2097152,
) {
super();
this.mtu = mtu;
@@ -698,7 +697,6 @@ class TlsStreamSettings extends XrayCommonClass {
certificates = [new TlsStreamSettings.Cert()],
alpn = [ALPN_OPTION.H2, ALPN_OPTION.HTTP1],
echServerKeys = '',
echForceQuery = 'none',
settings = new TlsStreamSettings.Settings()
) {
super();
@@ -712,7 +710,6 @@ class TlsStreamSettings extends XrayCommonClass {
this.certs = certificates;
this.alpn = alpn;
this.echServerKeys = echServerKeys;
this.echForceQuery = echForceQuery;
this.settings = settings;
}
@@ -745,7 +742,6 @@ class TlsStreamSettings extends XrayCommonClass {
certs,
json.alpn,
json.echServerKeys,
json.echForceQuery,
settings,
);
}
@@ -762,7 +758,6 @@ class TlsStreamSettings extends XrayCommonClass {
certificates: TlsStreamSettings.toJsonArray(this.certs),
alpn: this.alpn,
echServerKeys: this.echServerKeys,
echForceQuery: this.echForceQuery,
settings: this.settings,
};
}
@@ -876,7 +871,7 @@ class RealityStreamSettings extends XrayCommonClass {
if (!target && !serverNames) {
const randomTarget = typeof getRandomRealityTarget !== 'undefined'
? getRandomRealityTarget()
: { target: 'www.apple.com:443', sni: 'www.apple.com,apple.com' };
: { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' };
target = randomTarget.target;
serverNames = randomTarget.sni;
}
@@ -1086,11 +1081,15 @@ class UdpMask extends XrayCommonClass {
case 'header-wireguard':
return {};
case 'header-custom':
return { client: [], server: [] };
return {
client: Array.isArray(settings.client) ? settings.client : [],
server: Array.isArray(settings.server) ? settings.server : [],
};
case 'noise':
return { reset: 0, noise: [] };
case 'sudoku':
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
return {
reset: settings.reset ?? 0,
noise: Array.isArray(settings.noise) ? settings.noise : [],
};
default:
return settings;
}
@@ -1104,27 +1103,221 @@ class UdpMask extends XrayCommonClass {
}
toJson() {
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
settings = { ...settings, noise: settings.noise.map(cleanItem) };
} else if (this.type === 'header-custom' && settings) {
settings = {
...settings,
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
};
}
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class FinalMaskStreamSettings extends XrayCommonClass {
constructor(udp = []) {
class TcpMask extends XrayCommonClass {
constructor(type = 'fragment', settings = {}) {
super();
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'fragment':
return {
packets: settings.packets ?? 'tlshello',
length: settings.length ?? '',
delay: settings.delay ?? '',
maxSplit: settings.maxSplit ?? '',
};
case 'sudoku':
return {
password: settings.password ?? '',
ascii: settings.ascii ?? '',
customTable: settings.customTable ?? '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0,
};
case 'header-custom':
return {
clients: Array.isArray(settings.clients) ? settings.clients : [],
servers: Array.isArray(settings.servers) ? settings.servers : [],
};
default:
return settings;
}
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(json.udp || []);
return new TcpMask(
json.type || 'fragment',
json.settings || {}
);
}
toJson() {
return {
udp: this.udp.map(udp => udp.toJson())
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'header-custom' && settings) {
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
settings = {
...settings,
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
};
}
return {
type: this.type,
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class QuicParams extends XrayCommonClass {
constructor(
congestion = 'bbr',
debug = false,
brutalUp = 65537,
brutalDown = 65537,
udpHop = undefined,
initStreamReceiveWindow = 8388608,
maxStreamReceiveWindow = 8388608,
initConnectionReceiveWindow = 20971520,
maxConnectionReceiveWindow = 20971520,
maxIdleTimeout = 30,
keepAlivePeriod = 5,
disablePathMTUDiscovery = false,
maxIncomingStreams = 1024,
) {
super();
this.congestion = congestion;
this.debug = debug;
this.brutalUp = brutalUp;
this.brutalDown = brutalDown;
this.udpHop = udpHop;
this.initStreamReceiveWindow = initStreamReceiveWindow;
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
this.maxIdleTimeout = maxIdleTimeout;
this.keepAlivePeriod = keepAlivePeriod;
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
this.maxIncomingStreams = maxIncomingStreams;
}
get hasUdpHop() {
return this.udpHop != null;
}
set hasUdpHop(value) {
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
}
static fromJson(json = {}) {
if (!json || Object.keys(json).length === 0) return undefined;
return new QuicParams(
json.congestion,
json.debug,
json.brutalUp,
json.brutalDown,
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
json.initStreamReceiveWindow,
json.maxStreamReceiveWindow,
json.initConnectionReceiveWindow,
json.maxConnectionReceiveWindow,
json.maxIdleTimeout,
json.keepAlivePeriod,
json.disablePathMTUDiscovery,
json.maxIncomingStreams,
);
}
toJson() {
const result = { congestion: this.congestion };
if (this.debug) result.debug = this.debug;
if (['brutal', 'force-brutal'].includes(this.congestion)) {
if (this.brutalUp) result.brutalUp = this.brutalUp;
if (this.brutalDown) result.brutalDown = this.brutalDown;
}
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
return result;
}
}
class FinalMaskStreamSettings extends XrayCommonClass {
constructor(tcp = [], udp = [], quicParams = undefined) {
super();
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
}
get enableQuicParams() {
return this.quicParams != null;
}
set enableQuicParams(value) {
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(
json.tcp || [],
json.udp || [],
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
);
}
toJson() {
const result = {};
if (this.tcp && this.tcp.length > 0) {
result.tcp = this.tcp.map(t => t.toJson());
}
if (this.udp && this.udp.length > 0) {
result.udp = this.udp.map(udp => udp.toJson());
}
if (this.quicParams) {
result.quicParams = this.quicParams.toJson();
}
return result;
}
}
@@ -1161,6 +1354,16 @@ class StreamSettings extends XrayCommonClass {
this.sockopt = sockopt;
}
addTcpMask(type = 'fragment') {
this.finalmask.tcp.push(new TcpMask(type));
}
delTcpMask(index) {
if (this.finalmask.tcp) {
this.finalmask.tcp.splice(index, 1);
}
}
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
@@ -1172,7 +1375,10 @@ class StreamSettings extends XrayCommonClass {
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
const hasQuicParams = this.finalmask.quicParams != null;
return hasTcp || hasUdp || hasQuicParams;
}
get isTls() {
@@ -1317,6 +1523,104 @@ class Inbound extends XrayCommonClass {
return this.clientStats;
}
// Copy the xPadding* settings into the query-string of a vless/trojan/ss
// link. Without this, the admin's custom xPaddingBytes range and (in
// obfs mode) the custom xPaddingKey / xPaddingHeader / placement /
// method never reach the client — the client keeps xray / sing-box's
// internal defaults and the server rejects every handshake with
// `invalid padding (...) length: 0`.
//
// Two encodings are emitted so each client family can pick at least
// one up:
// - x_padding_bytes=<range> flat, for sing-box-family clients
// - extra=<url-encoded-json> full blob, for xray-core clients
//
// Fields are only included when they actually have a value, so a
// default inbound yields the same URL it did before this helper.
static applyXhttpPaddingToParams(xhttp, params) {
if (!xhttp) return;
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
params.set("x_padding_bytes", xhttp.xPaddingBytes);
}
const extra = {};
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
extra.xPaddingBytes = xhttp.xPaddingBytes;
}
if (xhttp.xPaddingObfsMode === true) {
extra.xPaddingObfsMode = true;
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
extra[k] = xhttp[k];
}
});
}
if (Object.keys(extra).length > 0) {
params.set("extra", JSON.stringify(extra));
}
}
// VMess variant: VMess links are a base64-encoded JSON object, so we
// copy the padding fields directly into the JSON instead of building
// a query string.
static applyXhttpPaddingToObj(xhttp, obj) {
if (!xhttp || !obj) return;
if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
obj.x_padding_bytes = xhttp.xPaddingBytes;
}
if (xhttp.xPaddingObfsMode === true) {
obj.xPaddingObfsMode = true;
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
obj[k] = xhttp[k];
}
});
}
}
static hasShareableFinalMaskValue(value) {
if (value == null) {
return false;
}
if (Array.isArray(value)) {
return value.some(item => Inbound.hasShareableFinalMaskValue(item));
}
if (typeof value === 'object') {
return Object.values(value).some(item => Inbound.hasShareableFinalMaskValue(item));
}
if (typeof value === 'string') {
return value.length > 0;
}
return true;
}
static serializeFinalMask(finalmask) {
if (!finalmask) {
return '';
}
const value = typeof finalmask.toJson === 'function' ? finalmask.toJson() : finalmask;
return Inbound.hasShareableFinalMaskValue(value) ? JSON.stringify(value) : '';
}
// Export finalmask with the same compact JSON payload shape that
// v2rayN-compatible share links use: fm=<json>.
static applyFinalMaskToParams(finalmask, params) {
if (!params) return;
const payload = Inbound.serializeFinalMask(finalmask);
if (payload.length > 0) {
params.set("fm", payload);
}
}
// VMess links are a base64 JSON object, so keep the same fm payload
// under a flat property instead of a URL query string.
static applyFinalMaskToObj(finalmask, obj) {
if (!obj) return;
const payload = Inbound.serializeFinalMask(finalmask);
if (payload.length > 0) {
obj.fm = payload;
}
}
get clients() {
switch (this.protocol) {
case Protocols.VMESS: return this.settings.vmesses;
@@ -1342,6 +1646,8 @@ class Inbound extends XrayCommonClass {
if (protocol === Protocols.HYSTERIA) {
this.stream.network = 'hysteria';
this.stream.security = 'tls';
// Hysteria runs over QUIC and must not inherit TCP TLS ALPN defaults.
this.stream.tls.alpn = [ALPN_OPTION.H3];
}
}
@@ -1511,6 +1817,8 @@ class Inbound extends XrayCommonClass {
}
} else if (network === 'kcp') {
const kcp = this.stream.kcp;
obj.mtu = kcp.mtu;
obj.tti = kcp.tti;
} else if (network === 'ws') {
const ws = this.stream.ws;
obj.path = ws.path;
@@ -1530,8 +1838,11 @@ class Inbound extends XrayCommonClass {
obj.path = xhttp.path;
obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
obj.type = xhttp.mode;
Inbound.applyXhttpPaddingToObj(xhttp, obj);
}
Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
if (tls === 'tls') {
if (!ObjectUtil.isEmpty(this.stream.tls.sni)) {
obj.sni = this.stream.tls.sni;
@@ -1570,6 +1881,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1594,9 +1907,12 @@ class Inbound extends XrayCommonClass {
params.set("path", xhttp.path);
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
params.set("mode", xhttp.mode);
Inbound.applyXhttpPaddingToParams(xhttp, params);
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1670,6 +1986,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1694,9 +2012,12 @@ class Inbound extends XrayCommonClass {
params.set("path", xhttp.path);
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
params.set("mode", xhttp.mode);
Inbound.applyXhttpPaddingToParams(xhttp, params);
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1746,6 +2067,8 @@ class Inbound extends XrayCommonClass {
break;
case "kcp":
const kcp = this.stream.kcp;
params.set("mtu", kcp.mtu);
params.set("tti", kcp.tti);
break;
case "ws":
const ws = this.stream.ws;
@@ -1770,9 +2093,12 @@ class Inbound extends XrayCommonClass {
params.set("path", xhttp.path);
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
params.set("mode", xhttp.mode);
Inbound.applyXhttpPaddingToParams(xhttp, params);
break;
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
if (security === 'tls') {
params.set("security", "tls");
if (this.stream.isTls) {
@@ -1827,7 +2153,7 @@ class Inbound extends XrayCommonClass {
if (this.stream.tls.settings.fingerprint?.length > 0) params.set("fp", this.stream.tls.settings.fingerprint);
if (this.stream.tls.alpn?.length > 0) params.set("alpn", this.stream.tls.alpn);
if (this.stream.tls.settings.allowInsecure) params.set("insecure", "1");
if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList.join(','));
if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList);
if (this.stream.tls.sni?.length > 0) params.set("sni", this.stream.tls.sni);
const udpMasks = this.stream?.finalmask?.udp;
@@ -1840,6 +2166,8 @@ class Inbound extends XrayCommonClass {
}
}
Inbound.applyFinalMaskToParams(this.stream.finalmask, params);
const url = new URL(link);
for (const [key, value] of params) {
url.searchParams.set(key, value);
@@ -1848,7 +2176,7 @@ class Inbound extends XrayCommonClass {
return url.toString();
}
getWireguardLink(address, port, remark, peerId) {
getWireguardTxt(address, port, remark, peerId) {
let txt = `[Interface]\n`
txt += `PrivateKey = ${this.settings.peers[peerId].privateKey}\n`
txt += `Address = ${this.settings.peers[peerId].allowedIPs[0]}\n`
@@ -1870,6 +2198,48 @@ class Inbound extends XrayCommonClass {
return txt;
}
getWireguardLink(address, port, remark, peerId) {
const peer = this.settings?.peers?.[peerId];
if (!peer) return '';
const link = `wireguard://${address}:${port}`;
const url = new URL(link);
url.username = peer.privateKey || '';
if (this.settings?.pubKey) {
url.searchParams.set("publickey", this.settings.pubKey);
}
if (Array.isArray(peer.allowedIPs) && peer.allowedIPs.length > 0 && peer.allowedIPs[0]) {
url.searchParams.set("address", peer.allowedIPs[0]);
}
if (this.settings?.mtu) {
url.searchParams.set("mtu", this.settings.mtu);
}
url.hash = encodeURIComponent(remark);
return url.toString();
}
genWireguardLinks(remark = '', remarkModel = '-ieo') {
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
const separationChar = remarkModel.charAt(0);
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardLink(addr, this.port, remark + separationChar + (index + 1), index));
});
return links.join('\r\n');
}
genWireguardConfigs(remark = '', remarkModel = '-ieo') {
const addr = !ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0" ? this.listen : location.hostname;
const separationChar = remarkModel.charAt(0);
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardTxt(addr, this.port, remark + separationChar + (index + 1), index));
});
return links.join('\r\n');
}
genLink(address = '', port = this.port, forceTls = 'same', remark = '', client) {
switch (this.protocol) {
case Protocols.VMESS:
@@ -1930,11 +2300,7 @@ class Inbound extends XrayCommonClass {
} else {
if (this.protocol == Protocols.SHADOWSOCKS && !this.isSSMultiUser) return this.genSSLink(addr, this.port, 'same', remark);
if (this.protocol == Protocols.WIREGUARD) {
let links = [];
this.settings.peers.forEach((p, index) => {
links.push(this.getWireguardLink(addr, this.port, remark + remarkModel.charAt(0) + (index + 1), index));
});
return links.join('\r\n');
return this.genWireguardConfigs(remark, remarkModel);
}
return '';
}
@@ -2250,27 +2616,34 @@ Inbound.VLESSSettings.VLESS = class extends Inbound.ClientBase {
constructor(
id = RandomUtil.randomUUID(),
flow = '',
reverseTag = '',
email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at,
) {
super(email, limitIp, totalGB, expiryTime, enable, tgId, subId, comment, reset, created_at, updated_at);
this.id = id;
this.flow = flow;
this.reverseTag = reverseTag;
}
static fromJson(json = {}) {
return new Inbound.VLESSSettings.VLESS(
json.id,
json.flow,
json.reverse?.tag ?? '',
...Inbound.ClientBase.commonArgsFromJson(json),
);
}
toJson() {
return {
const json = {
id: this.id,
flow: this.flow,
...this._clientBaseToJson(),
};
if (this.reverseTag) {
json.reverse = { tag: this.reverseTag };
}
return json;
}
};
@@ -2805,4 +3178,4 @@ Inbound.TunSettings = class extends Inbound.Settings {
autoOutboundsInterface: this.autoOutboundsInterface,
};
}
};
};

View File

@@ -6,10 +6,10 @@ const Protocols = {
VLESS: "vless",
Trojan: "trojan",
Shadowsocks: "shadowsocks",
Wireguard: "wireguard",
Hysteria: "hysteria",
Socks: "socks",
HTTP: "http",
Wireguard: "wireguard",
Hysteria: "hysteria"
};
const SSMethods = {
@@ -97,6 +97,74 @@ const Address_Port_Strategy = {
TxtPortAndAddress: "txtportandaddress"
};
const DNSRuleActions = ['direct', 'drop', 'reject', 'hijack'];
function normalizeDNSRuleField(value) {
if (value === null || value === undefined) {
return '';
}
if (Array.isArray(value)) {
return value.map(item => item.toString().trim()).filter(item => item.length > 0).join(',');
}
return value.toString().trim();
}
function normalizeDNSRuleAction(action) {
action = ObjectUtil.isEmpty(action) ? 'direct' : action.toString().toLowerCase().trim();
return DNSRuleActions.includes(action) ? action : 'direct';
}
function parseLegacyDNSBlockTypes(blockTypes) {
if (blockTypes === null || blockTypes === undefined || blockTypes === '') {
return [];
}
if (Array.isArray(blockTypes)) {
return blockTypes
.map(item => Number(item))
.filter(item => Number.isInteger(item) && item >= 0 && item <= 65535);
}
if (typeof blockTypes === 'number') {
return Number.isInteger(blockTypes) && blockTypes >= 0 && blockTypes <= 65535 ? [blockTypes] : [];
}
return blockTypes
.toString()
.split(',')
.map(item => item.trim())
.filter(item => /^\d+$/.test(item))
.map(item => Number(item))
.filter(item => item >= 0 && item <= 65535);
}
function buildLegacyDNSRules(nonIPQuery, blockTypes) {
const mode = ['reject', 'drop', 'skip'].includes(nonIPQuery) ? nonIPQuery : 'reject';
const rules = [];
const parsedBlockTypes = parseLegacyDNSBlockTypes(blockTypes);
if (parsedBlockTypes.length > 0) {
rules.push(new Outbound.DNSRule(mode === 'reject' ? 'reject' : 'drop', parsedBlockTypes.join(',')));
}
rules.push(new Outbound.DNSRule('hijack', '1,28'));
rules.push(new Outbound.DNSRule(mode === 'skip' ? 'direct' : mode));
return rules;
}
function getDNSRulesFromJson(json = {}) {
if (Array.isArray(json.rules) && json.rules.length > 0) {
return json.rules.map(rule => Outbound.DNSRule.fromJson(rule));
}
if (json.nonIPQuery !== undefined || json.blockTypes !== undefined) {
return buildLegacyDNSRules(json.nonIPQuery, json.blockTypes);
}
return [];
}
Object.freeze(Protocols);
Object.freeze(SSMethods);
Object.freeze(TLS_FLOW_CONTROL);
@@ -107,6 +175,7 @@ Object.freeze(WireguardDomainStrategy);
Object.freeze(USERS_SECURITY);
Object.freeze(MODE_OPTION);
Object.freeze(Address_Port_Strategy);
Object.freeze(DNSRuleActions);
class CommonClass {
@@ -431,7 +500,7 @@ class HysteriaStreamSettings extends CommonClass {
initConnectionReceiveWindow = 20971520,
maxConnectionReceiveWindow = 20971520,
maxIdleTimeout = 30,
keepAlivePeriod = 0,
keepAlivePeriod = 2,
disablePathMTUDiscovery = false
) {
super();
@@ -586,11 +655,23 @@ class UdpMask extends CommonClass {
case 'header-wireguard':
return {}; // No settings needed
case 'header-custom':
return { client: [], server: [] };
return {
client: Array.isArray(settings.client) ? settings.client : [],
server: Array.isArray(settings.server) ? settings.server : [],
};
case 'noise':
return { reset: 0, noise: [] };
return {
reset: settings.reset ?? 0,
noise: Array.isArray(settings.noise) ? settings.noise : [],
};
case 'sudoku':
return { ascii: '', customTable: '', customTables: [], paddingMin: 0, paddingMax: 0 };
return {
ascii: settings.ascii || '',
customTable: settings.customTable || '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0
};
default:
return settings;
}
@@ -604,28 +685,221 @@ class UdpMask extends CommonClass {
}
toJson() {
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'noise' && settings && Array.isArray(settings.noise)) {
settings = { ...settings, noise: settings.noise.map(cleanItem) };
} else if (this.type === 'header-custom' && settings) {
settings = {
...settings,
client: Array.isArray(settings.client) ? settings.client.map(cleanItem) : settings.client,
server: Array.isArray(settings.server) ? settings.server.map(cleanItem) : settings.server,
};
}
return {
type: this.type,
settings: (this.settings && Object.keys(this.settings).length > 0) ? this.settings : undefined
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class FinalMaskStreamSettings extends CommonClass {
constructor(udp = []) {
class TcpMask extends CommonClass {
constructor(type = 'fragment', settings = {}) {
super();
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.type = type;
this.settings = this._getDefaultSettings(type, settings);
}
_getDefaultSettings(type, settings = {}) {
switch (type) {
case 'fragment':
return {
packets: settings.packets ?? 'tlshello',
length: settings.length ?? '',
delay: settings.delay ?? '',
maxSplit: settings.maxSplit ?? '',
};
case 'sudoku':
return {
password: settings.password ?? '',
ascii: settings.ascii ?? '',
customTable: settings.customTable ?? '',
customTables: Array.isArray(settings.customTables) ? settings.customTables : [],
paddingMin: settings.paddingMin ?? 0,
paddingMax: settings.paddingMax ?? 0,
};
case 'header-custom':
return {
clients: Array.isArray(settings.clients) ? settings.clients : [],
servers: Array.isArray(settings.servers) ? settings.servers : [],
};
default:
return settings;
}
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(json.udp || []);
return new TcpMask(
json.type || 'fragment',
json.settings || {}
);
}
toJson() {
return {
udp: this.udp.map(udp => udp.toJson())
const cleanItem = item => {
const out = { ...item };
if (out.type === 'array') {
delete out.packet;
} else {
delete out.rand;
delete out.randRange;
}
return out;
};
let settings = this.settings;
if (this.type === 'header-custom' && settings) {
const cleanGroup = group => Array.isArray(group) ? group.map(cleanItem) : group;
settings = {
...settings,
clients: Array.isArray(settings.clients) ? settings.clients.map(cleanGroup) : settings.clients,
servers: Array.isArray(settings.servers) ? settings.servers.map(cleanGroup) : settings.servers,
};
}
return {
type: this.type,
settings: (settings && Object.keys(settings).length > 0) ? settings : undefined
};
}
}
class QuicParams extends CommonClass {
constructor(
congestion = 'bbr',
debug = false,
brutalUp = 65537,
brutalDown = 65537,
udpHop = undefined,
initStreamReceiveWindow = 8388608,
maxStreamReceiveWindow = 8388608,
initConnectionReceiveWindow = 20971520,
maxConnectionReceiveWindow = 20971520,
maxIdleTimeout = 30,
keepAlivePeriod = 5,
disablePathMTUDiscovery = false,
maxIncomingStreams = 1024,
) {
super();
this.congestion = congestion;
this.debug = debug;
this.brutalUp = brutalUp;
this.brutalDown = brutalDown;
this.udpHop = udpHop;
this.initStreamReceiveWindow = initStreamReceiveWindow;
this.maxStreamReceiveWindow = maxStreamReceiveWindow;
this.initConnectionReceiveWindow = initConnectionReceiveWindow;
this.maxConnectionReceiveWindow = maxConnectionReceiveWindow;
this.maxIdleTimeout = maxIdleTimeout;
this.keepAlivePeriod = keepAlivePeriod;
this.disablePathMTUDiscovery = disablePathMTUDiscovery;
this.maxIncomingStreams = maxIncomingStreams;
}
get hasUdpHop() {
return this.udpHop != null;
}
set hasUdpHop(value) {
this.udpHop = value ? (this.udpHop || { ports: '20000-50000', interval: '5-10' }) : undefined;
}
static fromJson(json = {}) {
if (!json || Object.keys(json).length === 0) return undefined;
return new QuicParams(
json.congestion,
json.debug,
json.brutalUp,
json.brutalDown,
json.udpHop ? { ports: json.udpHop.ports, interval: json.udpHop.interval } : undefined,
json.initStreamReceiveWindow,
json.maxStreamReceiveWindow,
json.initConnectionReceiveWindow,
json.maxConnectionReceiveWindow,
json.maxIdleTimeout,
json.keepAlivePeriod,
json.disablePathMTUDiscovery,
json.maxIncomingStreams,
);
}
toJson() {
const result = { congestion: this.congestion };
if (this.debug) result.debug = this.debug;
if (['brutal', 'force-brutal'].includes(this.congestion)) {
if (this.brutalUp) result.brutalUp = this.brutalUp;
if (this.brutalDown) result.brutalDown = this.brutalDown;
}
if (this.udpHop) result.udpHop = { ports: this.udpHop.ports, interval: this.udpHop.interval };
if (this.initStreamReceiveWindow > 0) result.initStreamReceiveWindow = this.initStreamReceiveWindow;
if (this.maxStreamReceiveWindow > 0) result.maxStreamReceiveWindow = this.maxStreamReceiveWindow;
if (this.initConnectionReceiveWindow > 0) result.initConnectionReceiveWindow = this.initConnectionReceiveWindow;
if (this.maxConnectionReceiveWindow > 0) result.maxConnectionReceiveWindow = this.maxConnectionReceiveWindow;
if (this.maxIdleTimeout !== 30 && this.maxIdleTimeout > 0) result.maxIdleTimeout = this.maxIdleTimeout;
if (this.keepAlivePeriod > 0) result.keepAlivePeriod = this.keepAlivePeriod;
if (this.disablePathMTUDiscovery) result.disablePathMTUDiscovery = this.disablePathMTUDiscovery;
if (this.maxIncomingStreams > 0) result.maxIncomingStreams = this.maxIncomingStreams;
return result;
}
}
class FinalMaskStreamSettings extends CommonClass {
constructor(tcp = [], udp = [], quicParams = undefined) {
super();
this.tcp = Array.isArray(tcp) ? tcp.map(t => t instanceof TcpMask ? t : new TcpMask(t.type, t.settings)) : [];
this.udp = Array.isArray(udp) ? udp.map(u => new UdpMask(u.type, u.settings)) : [new UdpMask(udp.type, udp.settings)];
this.quicParams = quicParams instanceof QuicParams ? quicParams : (quicParams ? QuicParams.fromJson(quicParams) : undefined);
}
get enableQuicParams() {
return this.quicParams != null;
}
set enableQuicParams(value) {
this.quicParams = value ? (this.quicParams || new QuicParams()) : undefined;
}
static fromJson(json = {}) {
return new FinalMaskStreamSettings(
json.tcp || [],
json.udp || [],
json.quicParams ? QuicParams.fromJson(json.quicParams) : undefined,
);
}
toJson() {
const result = {};
if (this.tcp && this.tcp.length > 0) {
result.tcp = this.tcp.map(t => t.toJson());
}
if (this.udp && this.udp.length > 0) {
result.udp = this.udp.map(udp => udp.toJson());
}
if (this.quicParams) {
result.quicParams = this.quicParams.toJson();
}
return result;
}
}
@@ -661,6 +935,16 @@ class StreamSettings extends CommonClass {
this.sockopt = sockopt;
}
addTcpMask(type = 'fragment') {
this.finalmask.tcp.push(new TcpMask(type));
}
delTcpMask(index) {
if (this.finalmask.tcp) {
this.finalmask.tcp.splice(index, 1);
}
}
addUdpMask(type = 'salamander') {
this.finalmask.udp.push(new UdpMask(type));
}
@@ -672,7 +956,10 @@ class StreamSettings extends CommonClass {
}
get hasFinalMask() {
return this.finalmask.udp && this.finalmask.udp.length > 0;
const hasTcp = this.finalmask.tcp && this.finalmask.tcp.length > 0;
const hasUdp = this.finalmask.udp && this.finalmask.udp.length > 0;
const hasQuicParams = this.finalmask.quicParams != null;
return hasTcp || hasUdp || hasQuicParams;
}
get isTls() {
@@ -923,6 +1210,10 @@ class Outbound extends CommonClass {
stream.kcp = new KcpStreamSettings();
stream.type = json.type;
stream.seed = json.path;
const mtu = Number(json.mtu);
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
const tti = Number(json.tti);
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
} else if (network === 'ws') {
stream.ws = new WsStreamSettings(json.path, json.host);
} else if (network === 'grpc') {
@@ -930,7 +1221,13 @@ class Outbound extends CommonClass {
} else if (network === 'httpupgrade') {
stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
} else if (network === 'xhttp') {
stream.xhttp = new xHTTPStreamSettings(json.path, json.host, json.mode);
// 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;
stream.xhttp = xh;
}
if (json.tls && json.tls == 'tls') {
@@ -954,6 +1251,7 @@ class Outbound extends CommonClass {
let headerType = url.searchParams.get('headerType') ?? undefined;
let host = url.searchParams.get('host') ?? undefined;
let path = url.searchParams.get('path') ?? undefined;
let seed = url.searchParams.get('seed') ?? path ?? undefined;
let mode = url.searchParams.get('mode') ?? undefined;
if (type === 'tcp' || type === 'none') {
@@ -961,7 +1259,11 @@ class Outbound extends CommonClass {
} else if (type === 'kcp') {
stream.kcp = new KcpStreamSettings();
stream.kcp.type = headerType ?? 'none';
stream.kcp.seed = path;
stream.kcp.seed = seed;
const mtu = Number(url.searchParams.get('mtu'));
if (Number.isFinite(mtu) && mtu > 0) stream.kcp.mtu = mtu;
const tti = Number(url.searchParams.get('tti'));
if (Number.isFinite(tti) && tti > 0) stream.kcp.tti = tti;
} else if (type === 'ws') {
stream.ws = new WsStreamSettings(path, host);
} else if (type === 'grpc') {
@@ -972,7 +1274,25 @@ class Outbound extends CommonClass {
} else if (type === 'httpupgrade') {
stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
} else if (type === 'xhttp') {
stream.xhttp = new xHTTPStreamSettings(path, host, mode);
// Same positional bug as in the VMess-JSON branch above:
// passing `mode` as the 3rd positional arg put it into the
// `headers` slot. Build explicitly instead.
const xh = new xHTTPStreamSettings(path, host);
if (mode) xh.mode = mode;
const xpb = url.searchParams.get('x_padding_bytes');
if (xpb) xh.xPaddingBytes = xpb;
const extraRaw = url.searchParams.get('extra');
if (extraRaw) {
try {
const extra = JSON.parse(extraRaw);
if (typeof extra.xPaddingBytes === 'string' && extra.xPaddingBytes) xh.xPaddingBytes = extra.xPaddingBytes;
if (extra.xPaddingObfsMode === true) xh.xPaddingObfsMode = true;
["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k];
});
} catch (_) { /* ignore malformed extra */ }
}
stream.xhttp = xh;
}
if (security == 'tls') {
@@ -1139,14 +1459,16 @@ Outbound.FreedomSettings = class extends CommonClass {
redirect = '',
fragment = {},
noises = [],
ipsBlocked = [],
finalRules = [],
) {
super();
this.domainStrategy = domainStrategy;
this.redirect = redirect;
this.fragment = fragment || {};
this.noises = Array.isArray(noises) ? noises : [];
this.ipsBlocked = Array.isArray(ipsBlocked) ? ipsBlocked : [];
this.finalRules = Array.isArray(finalRules)
? finalRules.map(rule => rule instanceof Outbound.FreedomSettings.FinalRule ? rule : Outbound.FreedomSettings.FinalRule.fromJson(rule))
: [];
}
addNoise() {
@@ -1157,13 +1479,30 @@ Outbound.FreedomSettings = class extends CommonClass {
this.noises.splice(index, 1);
}
addFinalRule(action = 'block') {
this.finalRules.push(new Outbound.FreedomSettings.FinalRule(action));
}
delFinalRule(index) {
this.finalRules.splice(index, 1);
}
static fromJson(json = {}) {
const finalRules = Array.isArray(json.finalRules)
? json.finalRules.map(rule => Outbound.FreedomSettings.FinalRule.fromJson(rule))
: [];
// Backward compatibility: map legacy ipsBlocked entries to blocking finalRules.
if (finalRules.length === 0 && Array.isArray(json.ipsBlocked) && json.ipsBlocked.length > 0) {
finalRules.push(new Outbound.FreedomSettings.FinalRule('block', '', '', json.ipsBlocked, ''));
}
return new Outbound.FreedomSettings(
json.domainStrategy,
json.redirect,
json.fragment ? Outbound.FreedomSettings.Fragment.fromJson(json.fragment) : {},
json.noises ? json.noises.map(noise => Outbound.FreedomSettings.Noise.fromJson(noise)) : [],
json.ipsBlocked || [],
finalRules,
);
}
@@ -1173,7 +1512,7 @@ Outbound.FreedomSettings = class extends CommonClass {
redirect: ObjectUtil.isEmpty(this.redirect) ? undefined : this.redirect,
fragment: Object.keys(this.fragment).length === 0 ? undefined : this.fragment,
noises: this.noises.length === 0 ? undefined : Outbound.FreedomSettings.Noise.toJsonArray(this.noises),
ipsBlocked: this.ipsBlocked.length === 0 ? undefined : this.ipsBlocked,
finalRules: this.finalRules.length === 0 ? undefined : Outbound.FreedomSettings.FinalRule.toJsonArray(this.finalRules),
};
}
};
@@ -1235,6 +1574,37 @@ Outbound.FreedomSettings.Noise = class extends CommonClass {
}
};
Outbound.FreedomSettings.FinalRule = class extends CommonClass {
constructor(action = 'block', network = '', port = '', ip = [], blockDelay = '') {
super();
this.action = action;
this.network = network;
this.port = port;
this.ip = Array.isArray(ip) ? ip : [];
this.blockDelay = blockDelay;
}
static fromJson(json = {}) {
return new Outbound.FreedomSettings.FinalRule(
json.action,
Array.isArray(json.network) ? json.network.join(',') : json.network,
json.port,
json.ip || [],
json.blockDelay,
);
}
toJson() {
return {
action: ['allow', 'block'].includes(this.action) ? this.action : 'block',
network: ObjectUtil.isEmpty(this.network) ? undefined : this.network,
port: ObjectUtil.isEmpty(this.port) ? undefined : this.port,
ip: this.ip.length === 0 ? undefined : this.ip,
blockDelay: this.action === 'block' && !ObjectUtil.isEmpty(this.blockDelay) ? this.blockDelay : undefined,
};
}
};
Outbound.BlackholeSettings = class extends CommonClass {
constructor(type) {
super();
@@ -1253,20 +1623,69 @@ Outbound.BlackholeSettings = class extends CommonClass {
};
}
};
Outbound.DNSRule = class extends CommonClass {
constructor(action = 'direct', qtype = '', domain = '') {
super();
this.action = action;
this.qtype = qtype;
this.domain = domain;
}
static fromJson(json = {}) {
return new Outbound.DNSRule(
json.action,
normalizeDNSRuleField(json.qtype),
normalizeDNSRuleField(json.domain),
);
}
toJson() {
const rule = {
action: normalizeDNSRuleAction(this.action),
};
const qtype = normalizeDNSRuleField(this.qtype);
if (!ObjectUtil.isEmpty(qtype)) {
if (/^\d+$/.test(qtype)) {
rule.qtype = Number(qtype);
} else {
rule.qtype = qtype;
}
}
const domains = normalizeDNSRuleField(this.domain)
.split(',')
.map(d => d.trim())
.filter(d => d.length > 0);
if (domains.length > 0) {
rule.domain = domains;
}
return rule;
}
};
Outbound.DNSSettings = class extends CommonClass {
constructor(
network = 'udp',
address = '',
port = 53,
nonIPQuery = 'reject',
blockTypes = []
rules = []
) {
super();
this.network = network;
this.address = address;
this.port = port;
this.nonIPQuery = nonIPQuery;
this.blockTypes = blockTypes;
this.rules = Array.isArray(rules) ? rules.map(rule => rule instanceof Outbound.DNSRule ? rule : Outbound.DNSRule.fromJson(rule)) : [];
}
addRule(action = 'direct') {
this.rules.push(new Outbound.DNSRule(action));
}
delRule(index) {
this.rules.splice(index, 1);
}
static fromJson(json = {}) {
@@ -1274,10 +1693,23 @@ Outbound.DNSSettings = class extends CommonClass {
json.network,
json.address,
json.port,
json.nonIPQuery,
json.blockTypes,
getDNSRulesFromJson(json),
);
}
toJson() {
const json = {
network: this.network,
address: this.address,
port: this.port,
};
if (this.rules.length > 0) {
json.rules = Outbound.DNSRule.toJsonArray(this.rules);
}
return json;
}
};
Outbound.VmessSettings = class extends CommonClass {
constructor(address, port, id, security) {
@@ -1315,13 +1747,14 @@ Outbound.VmessSettings = class extends CommonClass {
}
};
Outbound.VLESSSettings = class extends CommonClass {
constructor(address, port, id, flow, encryption, testpre = 0, testseed = [900, 500, 900, 256]) {
constructor(address, port, id, flow, encryption, reverseTag = '', testpre = 0, testseed = [900, 500, 900, 256]) {
super();
this.address = address;
this.port = port;
this.id = id;
this.flow = flow;
this.encryption = encryption;
this.reverseTag = reverseTag;
this.testpre = testpre;
this.testseed = testseed;
}
@@ -1334,6 +1767,7 @@ Outbound.VLESSSettings = class extends CommonClass {
json.id,
json.flow,
json.encryption,
json.reverse?.tag || '',
json.testpre || 0,
json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256]
);
@@ -1347,6 +1781,9 @@ Outbound.VLESSSettings = class extends CommonClass {
flow: this.flow,
encryption: this.encryption,
};
if (!ObjectUtil.isEmpty(this.reverseTag)) {
result.reverse = { tag: this.reverseTag };
}
// Only include Vision settings when flow is set
if (this.flow && this.flow !== '') {
if (this.testpre > 0) {

View File

@@ -1,7 +1,5 @@
// List of popular services for VLESS Reality Target/SNI randomization
const REALITY_TARGETS = [
{ target: 'www.apple.com:443', sni: 'www.apple.com' },
{ target: 'www.icloud.com:443', sni: 'www.icloud.com' },
{ target: 'www.amazon.com:443', sni: 'www.amazon.com' },
{ target: 'aws.amazon.com:443', sni: 'aws.amazon.com' },
{ target: 'www.oracle.com:443', sni: 'www.oracle.com' },

View File

@@ -43,6 +43,7 @@ class AllSetting {
this.subDomain = "";
this.externalTrafficInformEnable = false;
this.externalTrafficInformURI = "";
this.restartXrayOnClientDisable = true;
this.subCertFile = "";
this.subKeyFile = "";
this.subUpdates = 12;

View File

@@ -7,6 +7,7 @@
const data = {
sId: el.getAttribute('data-sid') || '',
enabled: (el.getAttribute('data-enabled') || '').toLowerCase() === 'true',
subUrl: el.getAttribute('data-sub-url') || '',
subJsonUrl: el.getAttribute('data-subjson-url') || '',
subClashUrl: el.getAttribute('data-subclash-url') || '',
@@ -128,9 +129,10 @@
},
isActive() {
const now = Date.now();
const enabledOk = this.app.enabled;
const expiryOk = !this.app.expireMs || this.app.expireMs >= now;
const trafficOk = !this.app.totalByte || (this.app.uploadByte + this.app.downloadByte) <= this.app.totalByte;
return expiryOk && trafficOk;
return enabledOk && expiryOk && trafficOk;
},
shadowrocketUrl() {
const rawUrl = this.app.subUrl + '?flag=shadowrocket';

View File

@@ -152,6 +152,12 @@ class RandomUtil {
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static randomBase64(length = 16) {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static randomBase32String(length = 16) {
const array = new Uint8Array(length);

View File

@@ -1,150 +1,212 @@
/**
* WebSocket client for real-time updates
* WebSocket client for real-time panel updates.
*
* Public API (kept stable for index.html / inbounds.html / xray.html):
* - connect() — open the connection (idempotent)
* - disconnect() — close and stop reconnecting
* - on(event, callback) — subscribe to event
* - off(event, callback) — unsubscribe
* - send(data) — send JSON to the server
* - isConnected — boolean, current state
* - reconnectAttempts — number, attempts since last success
* - maxReconnectAttempts — number, give-up threshold
*
* Built-in events:
* 'connected', 'disconnected', 'error', 'message',
* plus any server-emitted message type (status, traffic, client_stats, ...).
*/
class WebSocketClient {
static #MAX_PAYLOAD_BYTES = 10 * 1024 * 1024; // 10 MB, mirrors hub maxMessageSize.
static #BASE_RECONNECT_MS = 1000;
static #MAX_RECONNECT_MS = 30_000;
// After exhausting maxReconnectAttempts we switch to a polite slow-retry
// cadence rather than giving up forever — a panel that recovers an hour
// later should reconnect without a manual page reload.
static #SLOW_RETRY_MS = 60_000;
constructor(basePath = '') {
this.basePath = basePath;
this.ws = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectDelay = 1000;
this.listeners = new Map();
this.reconnectAttempts = 0;
this.isConnected = false;
this.ws = null;
this.shouldReconnect = true;
this.reconnectTimer = null;
this.listeners = new Map(); // event → Set<callback>
}
// Open the connection. Safe to call repeatedly — no-op if already
// open/connecting. Re-enables reconnects if previously disabled. Cancels
// any pending reconnect timer so an external connect() can't race a
// delayed retry into spawning a second socket.
connect() {
if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
return;
}
this.shouldReconnect = true;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
// Ensure basePath ends with '/' for proper URL construction
let basePath = this.basePath || '';
if (basePath && !basePath.endsWith('/')) {
basePath += '/';
}
const wsUrl = `${protocol}//${window.location.host}${basePath}ws`;
console.log('WebSocket connecting to:', wsUrl, 'basePath:', this.basePath);
try {
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.isConnected = true;
this.reconnectAttempts = 0;
this.emit('connected');
};
this.ws.onmessage = (event) => {
try {
// Validate message size (prevent memory issues)
const maxMessageSize = 10 * 1024 * 1024; // 10MB
if (event.data && event.data.length > maxMessageSize) {
console.error('WebSocket message too large:', event.data.length, 'bytes');
this.ws.close();
return;
}
const message = JSON.parse(event.data);
if (!message || typeof message !== 'object') {
console.error('Invalid WebSocket message format');
return;
}
this.handleMessage(message);
} catch (e) {
console.error('Failed to parse WebSocket message:', e);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
this.emit('error', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.isConnected = false;
this.emit('disconnected');
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
};
} catch (e) {
console.error('Failed to create WebSocket connection:', e);
this.emit('error', e);
}
}
handleMessage(message) {
const { type, payload, time } = message;
// Emit to specific type listeners
this.emit(type, payload, time);
// Emit to all listeners
this.emit('message', { type, payload, time });
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
const callbacks = this.listeners.get(event);
if (!callbacks.includes(callback)) {
callbacks.push(callback);
}
}
off(event, callback) {
if (!this.listeners.has(event)) {
return;
}
const callbacks = this.listeners.get(event);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
emit(event, ...args) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => {
try {
callback(...args);
} catch (e) {
console.error('Error in WebSocket event handler:', e);
}
});
}
this.#cancelReconnect();
this.#openSocket();
}
// Close the connection and stop any pending reconnect attempt. Resets the
// attempt counter so a future connect() starts fresh from the small backoff.
disconnect() {
this.shouldReconnect = false;
this.#cancelReconnect();
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
try { this.ws.close(1000, 'client disconnect'); } catch { /* ignore */ }
this.ws = null;
}
this.isConnected = false;
}
// Subscribe to an event. Re-subscribing the same callback is a no-op.
on(event, callback) {
if (typeof callback !== 'function') return;
let set = this.listeners.get(event);
if (!set) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
}
// Unsubscribe from an event.
off(event, callback) {
const set = this.listeners.get(event);
if (!set) return;
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
}
// Send JSON to the server. Drops silently if not connected — callers
// should rely on connect()/server pushes rather than client-initiated sends.
send(data) {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
// ───── internals ─────
#openSocket() {
const url = this.#buildUrl();
let socket;
try {
socket = new WebSocket(url);
} catch (err) {
console.error('WebSocket: failed to construct connection', err);
this.#emit('error', err);
this.#scheduleReconnect();
return;
}
this.ws = socket;
socket.addEventListener('open', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
this.#emit('connected');
});
socket.addEventListener('message', (event) => this.#onMessage(event));
socket.addEventListener('error', (event) => {
// Browsers fire 'error' before 'close' on failure. We surface it for
// consumers (so polling fallbacks can engage) but don't log every blip
// — bad networks would flood the console otherwise.
this.#emit('error', event);
});
socket.addEventListener('close', () => {
this.isConnected = false;
this.ws = null;
this.#emit('disconnected');
if (this.shouldReconnect) this.#scheduleReconnect();
});
}
#buildUrl() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let basePath = this.basePath || '';
if (basePath && !basePath.endsWith('/')) basePath += '/';
return `${protocol}//${window.location.host}${basePath}ws`;
}
#onMessage(event) {
const data = event.data;
// Reject oversized payloads up front. We compare actual UTF-8 byte
// length (via Blob.size) against the limit — string.length counts
// UTF-16 code units, which can undercount real bytes by up to 4× for
// payloads with non-ASCII characters and bypass the cap.
if (typeof data === 'string') {
const byteLen = new Blob([data]).size;
if (byteLen > WebSocketClient.#MAX_PAYLOAD_BYTES) {
console.error(`WebSocket: payload too large (${byteLen} bytes), closing`);
try { this.ws?.close(1009, 'message too big'); } catch { /* ignore */ }
return;
}
}
let message;
try {
message = JSON.parse(data);
} catch (err) {
console.error('WebSocket: invalid JSON message', err);
return;
}
if (!message || typeof message !== 'object' || typeof message.type !== 'string') {
console.error('WebSocket: malformed message envelope');
return;
}
this.#emit(message.type, message.payload, message.time);
this.#emit('message', message);
}
#emit(event, ...args) {
const set = this.listeners.get(event);
if (!set) return;
for (const callback of set) {
try {
callback(...args);
} catch (err) {
console.error(`WebSocket: handler for "${event}" threw`, err);
}
}
}
#scheduleReconnect() {
if (!this.shouldReconnect) return;
this.#cancelReconnect();
let base;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts += 1;
// Exponential backoff inside the active window.
const exp = WebSocketClient.#BASE_RECONNECT_MS * 2 ** (this.reconnectAttempts - 1);
base = Math.min(WebSocketClient.#MAX_RECONNECT_MS, exp);
} else {
console.warn('WebSocket is not connected');
// Active window exhausted — keep trying once a minute. The page-level
// polling fallback runs in parallel; this just brings WS back when the
// network recovers.
base = WebSocketClient.#SLOW_RETRY_MS;
}
// ±25% jitter so reloads after a panel restart don't reconnect in lockstep.
const delay = base * (0.75 + Math.random() * 0.5);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.#openSocket();
}, delay);
}
#cancelReconnect() {
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
}
// Create global WebSocket client instance
// Safely get basePath from global scope (defined in page.html)
// Global instance — basePath is set by page.html before this script loads.
window.wsClient = new WebSocketClient(typeof basePath !== 'undefined' ? basePath : '');

View File

@@ -27,6 +27,34 @@ func NewInboundController(g *gin.RouterGroup) *InboundController {
return a
}
// broadcastInboundsUpdateClientLimit is the threshold past which we skip the
// full-list push over WebSocket and signal the frontend to re-fetch via REST.
// Mirrors the same heuristic used by the periodic traffic job.
const broadcastInboundsUpdateClientLimit = 5000
// broadcastInboundsUpdate fetches and broadcasts the inbound list for userId.
// At scale (10k+ clients) the marshaled JSON exceeds the WS payload ceiling,
// so we send an invalidate signal instead — frontend re-fetches via REST.
// Skipped entirely when no WebSocket clients are connected.
func (a *InboundController) broadcastInboundsUpdate(userId int) {
if !websocket.HasClients() {
return
}
inbounds, err := a.inboundService.GetInbounds(userId)
if err != nil {
return
}
totalClients := 0
for _, ib := range inbounds {
totalClients += len(ib.ClientStats)
}
if totalClients > broadcastInboundsUpdateClientLimit {
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
return
}
websocket.BroadcastInbounds(inbounds)
}
// initRouter initializes the routes for inbound-related operations.
func (a *InboundController) initRouter(g *gin.RouterGroup) {
@@ -38,9 +66,11 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/add", a.addInbound)
g.POST("/del/:id", a.delInbound)
g.POST("/update/:id", a.updateInbound)
g.POST("/setEnable/:id", a.setInboundEnable)
g.POST("/clientIps/:email", a.getClientIps)
g.POST("/clearClientIps/:email", a.clearClientIps)
g.POST("/addClient", a.addInboundClient)
g.POST("/:id/copyClients", a.copyInboundClients)
g.POST("/:id/delClient/:clientId", a.delInboundClient)
g.POST("/updateClient/:clientId", a.updateInboundClient)
g.POST("/:id/resetClientTraffic/:email", a.resetClientTraffic)
@@ -54,6 +84,12 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.POST("/:id/delClientByEmail/:email", a.delInboundClientByEmail)
}
type CopyInboundClientsRequest struct {
SourceInboundID int `form:"sourceInboundId" json:"sourceInboundId"`
ClientEmails []string `form:"clientEmails" json:"clientEmails"`
Flow string `form:"flow" json:"flow"`
}
// getInbounds retrieves the list of inbounds for the logged-in user.
func (a *InboundController) getInbounds(c *gin.Context) {
user := session.GetLoginUser(c)
@@ -127,9 +163,7 @@ func (a *InboundController) addInbound(c *gin.Context) {
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
a.broadcastInboundsUpdate(user.Id)
}
// delInbound deletes an inbound configuration by its ID.
@@ -148,10 +182,8 @@ func (a *InboundController) delInbound(c *gin.Context) {
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
a.broadcastInboundsUpdate(user.Id)
}
// updateInbound updates an existing inbound configuration.
@@ -178,10 +210,43 @@ func (a *InboundController) updateInbound(c *gin.Context) {
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Broadcast inbounds update via WebSocket
user := session.GetLoginUser(c)
inbounds, _ := a.inboundService.GetInbounds(user.Id)
websocket.BroadcastInbounds(inbounds)
a.broadcastInboundsUpdate(user.Id)
}
// setInboundEnable flips only the enable flag of an inbound. This is a
// dedicated endpoint because the regular update path serialises the entire
// settings JSON (every client) — far too heavy for an interactive switch
// on inbounds with thousands of clients. Frontend optimistically updates
// the UI; we just persist + sync xray + nudge other open admin sessions.
func (a *InboundController) setInboundEnable(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), err)
return
}
type form struct {
Enable bool `json:"enable" form:"enable"`
}
var f form
if err := c.ShouldBind(&f); err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
needRestart, err := a.inboundService.SetInboundEnable(id, f.Enable)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundUpdateSuccess"), nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
// Cross-admin sync: lightweight invalidate signal (a few hundred bytes)
// instead of fetching + serialising the whole inbound list. Other open
// sessions re-fetch via REST. The toggling admin's own UI already
// updated optimistically.
websocket.BroadcastInvalidate(websocket.MessageTypeInbounds)
}
// getClientIps retrieves the IP addresses associated with a client by email.
@@ -260,6 +325,36 @@ func (a *InboundController) addInboundClient(c *gin.Context) {
}
}
// copyInboundClients copies clients from source inbound to target inbound.
func (a *InboundController) copyInboundClients(c *gin.Context) {
targetID, err := strconv.Atoi(c.Param("id"))
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
req := &CopyInboundClientsRequest{}
err = c.ShouldBind(req)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
if req.SourceInboundID <= 0 {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), fmt.Errorf("invalid source inbound id"))
return
}
result, needRestart, err := a.inboundService.CopyInboundClients(targetID, req.SourceInboundID, req.ClientEmails, req.Flow)
if err != nil {
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
return
}
jsonObj(c, result, nil)
if needRestart {
a.xrayService.SetToNeedRestart()
}
}
// delInboundClient deletes a client from an inbound by inbound ID and client ID.
func (a *InboundController) delInboundClient(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))

View File

@@ -10,7 +10,6 @@ import (
"github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/session"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
)
@@ -95,9 +94,8 @@ func (a *IndexController) login(c *gin.Context) {
logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
session.SetLoginUser(c, user)
if err := sessions.Default(c).Save(); err != nil {
logger.Warning("Unable to save session: ", err)
if err := session.SetLoginUser(c, user); err != nil {
logger.Warning("Unable to save session:", err)
return
}
@@ -111,9 +109,8 @@ func (a *IndexController) logout(c *gin.Context) {
if user != nil {
logger.Infof("%s logged out successfully", user.Username)
}
session.ClearSession(c)
if err := sessions.Default(c).Save(); err != nil {
logger.Warning("Unable to save session after clearing:", err)
if err := session.ClearSession(c); err != nil {
logger.Warning("Unable to clear session on logout:", err)
}
c.Redirect(http.StatusTemporaryRedirect, c.GetString("base_path"))
}

View File

@@ -22,6 +22,7 @@ type ServerController struct {
serverService service.ServerService
settingService service.SettingService
panelService service.PanelService
lastStatus *service.Status
@@ -43,6 +44,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/status", a.status)
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
g.GET("/getXrayVersion", a.getXrayVersion)
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
g.GET("/getConfigJson", a.getConfigJson)
g.GET("/getDb", a.getDb)
g.GET("/getNewUUID", a.getNewUUID)
@@ -54,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/stopXrayService", a.stopXrayService)
g.POST("/restartXrayService", a.restartXrayService)
g.POST("/installXray/:version", a.installXray)
g.POST("/updatePanel", a.updatePanel)
g.POST("/updateGeofile", a.updateGeofile)
g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/logs/:count", a.getLogs)
@@ -131,6 +134,16 @@ func (a *ServerController) getXrayVersion(c *gin.Context) {
jsonObj(c, versions, nil)
}
// getPanelUpdateInfo retrieves the current and latest panel version.
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
info, err := a.panelService.GetUpdateInfo()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateCheckPopover"), err)
return
}
jsonObj(c, info, nil)
}
// installXray installs or updates Xray to the specified version.
func (a *ServerController) installXray(c *gin.Context) {
version := c.Param("version")
@@ -138,6 +151,12 @@ func (a *ServerController) installXray(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.xraySwitchVersionPopover"), err)
}
// updatePanel starts a panel self-update to the latest release.
func (a *ServerController) updatePanel(c *gin.Context) {
err := a.panelService.StartUpdate()
jsonMsg(c, I18nWeb(c, "pages.index.panelUpdateStartedPopover"), err)
}
// updateGeofile updates the specified geo file for Xray.
func (a *ServerController) updateGeofile(c *gin.Context) {
fileName := c.Param("fileName")

View File

@@ -99,7 +99,9 @@ func (a *SettingController) updateUser(c *gin.Context) {
if err == nil {
user.Username = form.NewUsername
user.Password, _ = crypto.HashPasswordAsBcrypt(form.NewPassword)
session.SetLoginUser(c, user)
if saveErr := session.SetLoginUser(c, user); saveErr != nil {
err = saveErr
}
}
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.modifyUser"), err)
}

View File

@@ -1,8 +1,10 @@
package controller
import (
"fmt"
"net"
"net/http"
"net/netip"
"strings"
"github.com/mhsanaei/3x-ui/v2/config"
@@ -14,18 +16,58 @@ import (
// getRemoteIp extracts the real IP address from the request headers or remote address.
func getRemoteIp(c *gin.Context) string {
value := c.GetHeader("X-Real-IP")
if value != "" {
return value
if ip, ok := extractTrustedIP(c.GetHeader("X-Real-IP")); ok {
return ip
}
value = c.GetHeader("X-Forwarded-For")
if value != "" {
ips := strings.Split(value, ",")
return ips[0]
if xff := c.GetHeader("X-Forwarded-For"); xff != "" {
for _, part := range strings.Split(xff, ",") {
if ip, ok := extractTrustedIP(part); ok {
return ip
}
}
}
addr := c.Request.RemoteAddr
ip, _, _ := net.SplitHostPort(addr)
return ip
if ip, ok := extractTrustedIP(c.Request.RemoteAddr); ok {
return ip
}
return "unknown"
}
func extractTrustedIP(value string) (string, bool) {
candidate := strings.TrimSpace(value)
if candidate == "" {
return "", false
}
if ip, ok := parseIPCandidate(candidate); ok {
return ip.String(), true
}
if host, _, err := net.SplitHostPort(candidate); err == nil {
if ip, ok := parseIPCandidate(host); ok {
return ip.String(), true
}
}
if strings.Count(candidate, ":") == 1 {
if host, _, err := net.SplitHostPort(fmt.Sprintf("[%s]", candidate)); err == nil {
if ip, ok := parseIPCandidate(host); ok {
return ip.String(), true
}
}
}
return "", false
}
func parseIPCandidate(value string) (netip.Addr, bool) {
ip, err := netip.ParseAddr(strings.TrimSpace(value))
if err != nil {
return netip.Addr{}, false
}
return ip.Unmap(), true
}
// jsonMsg sends a JSON response with a message and error status.

View File

@@ -1,7 +1,9 @@
package controller
import (
"net"
"net/http"
"net/url"
"strings"
"time"
@@ -16,105 +18,80 @@ import (
)
const (
// Time allowed to write a message to the peer
writeWait = 10 * time.Second
// Time allowed to read the next pong message from the peer
pongWait = 60 * time.Second
// Send pings to peer with this period (must be less than pongWait)
pingPeriod = (pongWait * 9) / 10
// Maximum message size allowed from peer
maxMessageSize = 512
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
clientReadLimit = 512
)
var upgrader = ws.Upgrader{
ReadBufferSize: 32768,
WriteBufferSize: 32768,
EnableCompression: true, // Negotiate permessage-deflate compression if the client supports it
CheckOrigin: func(r *http.Request) bool {
// Check origin for security
origin := r.Header.Get("Origin")
if origin == "" {
// Allow connections without Origin header (same-origin requests)
return true
}
// Get the host from the request
host := r.Host
// Extract scheme and host from origin
originURL := origin
// Simple check: origin should match the request host
// This prevents cross-origin WebSocket hijacking
if strings.HasPrefix(originURL, "http://") || strings.HasPrefix(originURL, "https://") {
// Extract host from origin
originHost := strings.TrimPrefix(strings.TrimPrefix(originURL, "http://"), "https://")
if idx := strings.Index(originHost, "/"); idx != -1 {
originHost = originHost[:idx]
}
if idx := strings.Index(originHost, ":"); idx != -1 {
originHost = originHost[:idx]
}
// Compare hosts (without port)
requestHost := host
if idx := strings.Index(requestHost, ":"); idx != -1 {
requestHost = requestHost[:idx]
}
return originHost == requestHost || originHost == "" || requestHost == ""
}
return false
},
EnableCompression: true,
CheckOrigin: checkSameOrigin,
}
// WebSocketController handles WebSocket connections for real-time updates
// checkSameOrigin allows requests with no Origin header (same-origin or non-browser
// clients) and otherwise requires the Origin hostname to match the request hostname.
// Comparison is case-insensitive (RFC 7230 §2.7.3) and ignores port differences
// (the panel often sits behind a reverse proxy on a different port).
func checkSameOrigin(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
u, err := url.Parse(origin)
if err != nil || u.Hostname() == "" {
return false
}
host, _, err := net.SplitHostPort(r.Host)
if err != nil {
// IPv6 literals without a port arrive as "[::1]"; net.SplitHostPort
// fails in that case while url.Hostname() returns the address without
// brackets. Strip them so same-origin checks pass for bare IPv6 hosts.
host = r.Host
if len(host) >= 2 && host[0] == '[' && host[len(host)-1] == ']' {
host = host[1 : len(host)-1]
}
}
return strings.EqualFold(u.Hostname(), host)
}
// WebSocketController handles WebSocket connections for real-time updates.
type WebSocketController struct {
BaseController
hub *websocket.Hub
}
// NewWebSocketController creates a new WebSocket controller
// NewWebSocketController creates a new WebSocket controller.
func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
return &WebSocketController{
hub: hub,
}
return &WebSocketController{hub: hub}
}
// HandleWebSocket handles WebSocket connections
// HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
// Check authentication
if !session.IsLogin(c) {
logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
c.AbortWithStatus(http.StatusUnauthorized)
return
}
// Upgrade connection to WebSocket
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
logger.Error("Failed to upgrade WebSocket connection:", err)
return
}
// Create client
clientID := uuid.New().String()
client := &websocket.Client{
ID: clientID,
Hub: w.hub,
Send: make(chan []byte, 512), // Increased from 256 to 512 to prevent overflow
Topics: make(map[websocket.MessageType]bool),
}
// Register client
client := websocket.NewClient(uuid.New().String())
w.hub.Register(client)
logger.Debugf("WebSocket client %s registered from %s", clientID, getRemoteIp(c))
logger.Debugf("WebSocket client %s registered from %s", client.ID, getRemoteIp(c))
// Start goroutines for reading and writing
go w.writePump(client, conn)
go w.readPump(client, conn)
}
// readPump pumps messages from the WebSocket connection to the hub
// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
// running. Clients send no commands today; frames are discarded.
func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
defer func() {
if r := common.Recover("WebSocket readPump panic"); r != nil {
@@ -124,35 +101,23 @@ func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn)
conn.Close()
}()
conn.SetReadLimit(clientReadLimit)
conn.SetReadDeadline(time.Now().Add(pongWait))
conn.SetPongHandler(func(string) error {
conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
return conn.SetReadDeadline(time.Now().Add(pongWait))
})
conn.SetReadLimit(maxMessageSize)
for {
_, message, err := conn.ReadMessage()
if err != nil {
if _, _, err := conn.ReadMessage(); err != nil {
if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
}
break
return
}
// Validate message size
if len(message) > maxMessageSize {
logger.Warningf("WebSocket message from client %s exceeds max size: %d bytes", client.ID, len(message))
continue
}
// Handle incoming messages (e.g., subscription requests)
// For now, we'll just log them
logger.Debugf("Received WebSocket message from client %s: %s", client.ID, string(message))
}
}
// writePump pumps messages from the hub to the WebSocket connection
// writePump pushes hub messages to the connection and emits keepalive pings.
func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
ticker := time.NewTicker(pingPeriod)
defer func() {
@@ -165,17 +130,13 @@ func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn)
for {
select {
case message, ok := <-client.Send:
case msg, ok := <-client.Send:
conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
// Hub closed the channel
conn.WriteMessage(ws.CloseMessage, []byte{})
return
}
// Send each message individually (no batching)
// This ensures each JSON message is sent separately and can be parsed correctly
if err := conn.WriteMessage(ws.TextMessage, message); err != nil {
if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
return
}

View File

@@ -49,19 +49,41 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
// Older versions of this handler embedded the raw DB value as
// `xraySetting` in the response without checking if the value
// already had that wrapper shape. When the frontend saved it
// back through the textarea verbatim, the wrapper got persisted
// and every subsequent save nested another layer, which is what
// eventually produced the blank Xray Settings page in #4059.
// Strip any such wrapper here, and heal the DB if we found one so
// the next read is O(1) instead of climbing the same pile again.
if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting {
if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil {
xraySetting = unwrapped
} else {
// Don't fail the read — just serve the unwrapped value
// and leave the DB healing for a later save.
xraySetting = unwrapped
}
}
inboundTags, err := a.InboundService.GetInboundTags()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
return
}
clientReverseTags, err := a.InboundService.GetClientReverseTags()
if err != nil {
clientReverseTags = "[]"
}
outboundTestUrl, _ := a.SettingService.GetXrayOutboundTestUrl()
if outboundTestUrl == "" {
outboundTestUrl = "https://www.google.com/generate_204"
}
xrayResponse := map[string]interface{}{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"outboundTestUrl": outboundTestUrl,
xrayResponse := map[string]any{
"xraySetting": json.RawMessage(xraySetting),
"inboundTags": json.RawMessage(inboundTags),
"clientReverseTags": json.RawMessage(clientReverseTags),
"outboundTestUrl": outboundTestUrl,
}
result, err := json.Marshal(xrayResponse)
if err != nil {

View File

@@ -71,6 +71,7 @@ type AllSetting struct {
SubUpdates int `json:"subUpdates" form:"subUpdates"` // Subscription update interval in minutes
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
SubShowInfo bool `json:"subShowInfo" form:"subShowInfo"` // Show client information in subscriptions
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI

View File

@@ -1,6 +1,7 @@
{{ define "page/head_start" }}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="renderer" content="webkit">
@@ -12,6 +13,7 @@
[v-cloak] {
display: none;
}
/* vazirmatn-regular - arabic_latin_latin-ext */
@font-face {
font-display: swap;
@@ -21,10 +23,11 @@
src: url('{{ .base_path }}assets/Vazirmatn-UI-NL-Regular.woff2') format('woff2');
unicode-range: U+0600-06FF, U+200C-200E, U+2010-2011, U+204F, U+2E41, U+FB50-FDFF, U+FE80-FEFC, U+0030-0039;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Vazirmatn', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
}
/* mobile touch scrolling for tabs */
@media (max-width: 576px) {
.ant-tabs-nav-container {
@@ -34,59 +37,69 @@
overscroll-behavior-x: contain;
white-space: nowrap;
max-width: 100%;
padding: 0 !important; /* Remove padding for arrows */
padding: 0 !important;
/* Remove padding for arrows */
}
.ant-tabs-nav-wrap {
overflow: visible !important;
padding: 0 !important;
}
.ant-tabs-nav-scroll {
overflow: visible !important;
box-shadow: none !important;
}
.ant-tabs-nav {
display: flex !important;
transform: none !important; /* Disable JS transform */
width: auto !important;
margin: 0 !important;
display: flex !important;
transform: none !important;
/* Disable JS transform */
width: auto !important;
margin: 0 !important;
}
.ant-tabs-tab-prev,
.ant-tabs-tab-next {
display: none !important; /* Hide arrows */
display: none !important;
/* Hide arrows */
}
.ant-tabs-nav-container::-webkit-scrollbar {
display: none;
}
}
</style>
<title>{{ .host }} {{ i18n .title}}</title>
{{ end }}
{{ end }}
{{ define "page/head_end" }}
{{ define "page/head_end" }}
</head>
{{ end }}
{{ define "page/body_start" }}
<body>
<div id="message"></div>
{{ end }}
{{ end }}
{{ define "page/body_scripts" }}
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qs/qs.min.js"></script>
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script>
const basePath = '{{ .base_path }}';
axios.defaults.baseURL = basePath;
</script>
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
{{ end }}
{{ define "page/body_end" }}
{{ define "page/body_scripts" }}
<script src="{{ .base_path }}assets/vue/vue.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/moment/moment.min.js"></script>
<script src="{{ .base_path }}assets/ant-design-vue/antd.min.js"></script>
<script src="{{ .base_path }}assets/axios/axios.min.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qs/qs.min.js"></script>
<script src="{{ .base_path }}assets/js/axios-init.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script>
const basePath = '{{ .base_path }}';
axios.defaults.baseURL = basePath;
</script>
<script src="{{ .base_path }}assets/js/websocket.js?{{ .cur_ver }}"></script>
{{ end }}
{{ define "page/body_end" }}
</body>
</html>
{{ end }}

View File

@@ -2,30 +2,39 @@
<template slot="actions" slot-scope="text, client, index">
<a-tooltip>
<template slot="title">{{ i18n "qrCode" }}</template>
<a-icon :style="{ fontSize: '22px', marginInlineStart: '14px' }" class="normal-icon" type="qrcode" v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
<a-icon :style="{ fontSize: '22px', marginInlineStart: '14px' }" class="normal-icon" type="qrcode"
v-if="record.hasLink()" @click="showQrcode(record.id,client);"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.client.edit" }}</template>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="edit" @click="openEditClient(record.id,client);"></a-icon>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="edit"
@click="openEditClient(record.id,client);"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "info" }}</template>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle" @click="showInfo(record.id,client);"></a-icon>
<a-icon :style="{ fontSize: '22px' }" class="normal-icon" type="info-circle"
@click="showInfo(record.id,client);"></a-icon>
</a-tooltip>
<a-tooltip>
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)" title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
<a-popconfirm @confirm="resetClientTraffic(client,record.id,false)"
title='{{ i18n "pages.inbounds.resetTrafficContent"}}' :overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "reset"}}' cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" :style="{ color: 'var(--color-primary-100)'}"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet" v-if="client.email.length > 0"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="normal-icon" type="retweet"
v-if="client.email.length > 0"></a-icon>
</a-popconfirm>
</a-tooltip>
<a-tooltip>
<template slot="title">
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
</template>
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}' :overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger" cancel-text='{{ i18n "cancel"}}'>
<a-popconfirm @confirm="delClient(record.id,client,false)" title='{{ i18n "pages.inbounds.deleteClientContent"}}'
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "delete"}}' ok-type="danger"
cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o" :style="{ color: '#e04141' }"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="delete-icon" type="delete" v-if="isRemovable(record.id)"></a-icon>
<a-icon :style="{ fontSize: '22px', cursor: 'pointer' }" class="delete-icon" type="delete"
v-if="isRemovable(record.id)"></a-icon>
</a-popconfirm>
</a-tooltip>
</template>
@@ -34,7 +43,7 @@
</template>
<template slot="online" slot-scope="text, client, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content" >
<template slot="content">
{{ i18n "lastOnline" }}: [[ formatLastOnline(client.email) ]]
</template>
<template v-if="client.enable && isClientOnline(client.email)">
@@ -53,7 +62,8 @@
<template v-else-if="!client.enable">{{ i18n "disabled" }}</template>
<template v-else-if="client.enable && isClientOnline(client.email)">{{ i18n "online" }}</template>
</template>
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''" :color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
<a-badge :class="isClientOnline(client.email)? 'online-animation' : ''"
:color="client.enable ? statsExpColor(record, client.email) : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"></a-badge>
</a-tooltip>
<a-space direction="vertical" :size="2">
<span class="client-email">[[ client.email ]]</span>
@@ -83,24 +93,22 @@
</tr>
</table>
</template>
<table>
<tr class="tr-table-box">
<td class="tr-table-rt"> [[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]] </td>
<td class="tr-table-bar" v-if="!client.enable">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
</td>
<td class="tr-table-bar" v-else-if="client.totalGB > 0">
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
</td>
<td v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress>
</td>
<td class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span>
</td>
</tr>
</table>
<div class="tr-table-box">
<div class="tr-table-rt">[[ SizeFormatter.sizeFormat(getSumStats(record, client.email)) ]]</div>
<div class="tr-table-bar" v-if="!client.enable">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
</div>
<div class="tr-table-bar" v-else-if="client.totalGB > 0">
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
</div>
<div v-else class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :percent="100"></a-progress>
</div>
<div class="tr-table-lt">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
<span v-else class="tr-infinity-ch">&infin;</span>
</div>
</div>
</a-popover>
</template>
@@ -114,15 +122,13 @@
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template>
<table>
<tr class="tr-table-box">
<td class="tr-table-rt"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
<td class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
</td>
<td class="tr-table-lt">[[ client.reset + "d" ]]</td>
</tr>
</table>
<div class="tr-table-box">
<div class="tr-table-rt">[[ IntlUtil.formatRelativeTime(client.expiryTime) ]]</div>
<div class="infinite-bar tr-table-bar">
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
</div>
<div class="tr-table-lt">[[ client.reset + "d" ]]</div>
</div>
</a-popover>
</template>
<template v-else>
@@ -131,11 +137,16 @@
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template>
<a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
<a-tag :style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[
IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
</a-popover>
<a-tag v-else :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)" :style="{ border: 'none' }" class="infinite-tag">
<a-tag v-else :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"
:style="{ border: 'none' }" class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</a-tag>
</template>
@@ -165,7 +176,8 @@
<span :style="{ color: '#FF4D4F' }"> {{ i18n "delete"}}</span>
</a-menu-item>
<a-menu-item>
<a-switch v-model="client.enable" size="small" @change="switchEnableClient(record.id, client, $event)"></a-switch>
<a-switch v-model="client.enable" size="small"
@change="switchEnableClient(record.id, client, $event)"></a-switch>
{{ i18n "enable"}}
</a-menu-item>
</a-menu>
@@ -179,9 +191,11 @@
<td colspan="3" :style="{ textAlign: 'center' }">{{ i18n "pages.inbounds.traffic" }}</td>
</tr>
<tr>
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
SizeFormatter.sizeFormat(getUpStats(record, client.email) + getDownStats(record, client.email)) ]] </td>
<td width="120px" v-if="!client.enable">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false" :percent="statsProgress(record, client.email)" />
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? 'rgb(72 84 105)' : '#bcbcbc'" :show-info="false"
:percent="statsProgress(record, client.email)" />
</td>
<td width="120px" v-else-if="client.totalGB > 0">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
@@ -197,11 +211,14 @@
</tr>
</table>
</template>
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="statsProgress(record, client.email)" />
<a-progress :stroke-color="clientStatsColor(record, client.email)" :show-info="false"
:status="isClientDepleted(record, client.email)? 'exception' : ''"
:percent="statsProgress(record, client.email)" />
</a-popover>
</td>
<td width="120px" v-else class="infinite-bar">
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false" :percent="100"></a-progress>
<a-progress :stroke-color="themeSwitcher.isDarkTheme ? '#2c1e32':'#F2EAF1'" :show-info="false"
:percent="100"></a-progress>
</td>
<td width="80px">
<template v-if="client.totalGB > 0">[[ client._totalGB + "GB" ]]</template>
@@ -216,14 +233,16 @@
</tr>
<tr>
<template v-if="client.expiryTime !=0 && client.reset >0">
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
<td width="80px" :style="{ margin: '0', textAlign: 'right', fontSize: '1em' }"> [[
IntlUtil.formatRelativeTime(client.expiryTime) ]] </td>
<td width="120px" class="infinite-bar">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template>
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''" :percent="expireProgress(client.expiryTime, client.reset)" />
<a-progress :show-info="false" :status="isClientDepleted(record, client.email)? 'exception' : ''"
:percent="expireProgress(client.expiryTime, client.reset)" />
</a-popover>
</td>
<td width="60px">[[ client.reset + "d" ]]</td>
@@ -235,11 +254,16 @@
<span v-if="client.expiryTime < 0">{{ i18n "pages.client.delayedStart" }}</span>
<span v-else>[[ IntlUtil.formatDate(client.expiryTime) ]]</span>
</template>
<a-tag :style="{ minWidth: '50px', border: 'none' }" :color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[ IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
<a-tag :style="{ minWidth: '50px', border: 'none' }"
:color="ColorUtils.userExpiryColor(app.expireDiff, client, themeSwitcher.isDarkTheme)"> [[
IntlUtil.formatRelativeTime(client.expiryTime) ]] </a-tag>
</a-popover>
<a-tag v-else :color="client.enable ? 'purple' : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'" class="infinite-tag">
<a-tag v-else :color="client.enable ? 'purple' : themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc'"
class="infinite-tag">
<svg height="10px" width="14px" viewBox="0 0 640 512" fill="currentColor">
<path d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" fill="currentColor"></path>
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
fill="currentColor"></path>
</svg>
</a-tag>
</template>
@@ -248,7 +272,8 @@
</table>
</template>
<a-badge>
<a-icon v-if="!client.enable" slot="count" type="pause-circle" theme="filled" :style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
<a-icon v-if="!client.enable" slot="count" type="pause-circle" theme="filled"
:style="{ color: themeSwitcher.isDarkTheme ? '#2c3950' : '#bcbcbc' }"></a-icon>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="solution"></a-icon>
</a-button>
@@ -271,4 +296,4 @@
-
</template>
</template>
{{end}}
{{end}}

View File

@@ -1,13 +1,13 @@
{{define "component/customStatistic"}}
<template>
<a-statistic :title="title" :value="value">
<template #prefix>
<slot name="prefix"></slot>
</template>
<template #suffix>
<slot name="suffix"></slot>
</template>
</a-statistic>
<a-statistic :title="title" :value="value">
<template #prefix>
<slot name="prefix"></slot>
</template>
<template #suffix>
<slot name="suffix"></slot>
</template>
</a-statistic>
</template>
{{end}}
@@ -16,9 +16,11 @@
.dark .ant-statistic-content {
color: var(--dark-color-text-primary)
}
.dark .ant-statistic-title {
color: rgba(255, 255, 255, 0.55)
}
.ant-statistic-content {
font-size: 16px;
}
@@ -36,7 +38,7 @@
required: false
}
},
template: `{{template "component/customStatistic"}}`,
template: `{{template "component/customStatistic" .}}`,
});
</script>
{{end}}

View File

@@ -34,7 +34,7 @@
required: false,
},
},
template: `{{template "component/persianDatepickerTemplate"}}`,
template: `{{template "component/persianDatepickerTemplate" .}}`,
data() {
return {
date: '',
@@ -42,7 +42,7 @@
};
},
watch: {
value: function (date) {
value: function(date) {
this.date = this.convertToJalalian(date)
}
},
@@ -52,7 +52,8 @@
},
methods: {
convertToGregorian(date) {
return date ? moment(moment(date, 'jYYYY/jMM/jDD HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')) : null
return date ? moment(moment(date, 'jYYYY/jMM/jDD HH:mm:ss').format('YYYY-MM-DD HH:mm:ss')) :
null
},
convertToJalalian(date) {
return date && moment.isMoment(date) ? date.format('jYYYY/jMM/jDD HH:mm:ss') : null

View File

@@ -26,7 +26,7 @@
type: String,
required: false,
defaultValue: "default",
validator: function (value) {
validator: function(value) {
return ['small', 'default'].includes(value)
}
}
@@ -46,4 +46,4 @@
}
})
</script>
{{end}}
{{end}}

View File

@@ -43,8 +43,7 @@
Vue.component('a-sidebar', {
data() {
return {
tabs: [
{
tabs: [{
key: '{{ .base_path }}panel/',
icon: 'dashboard',
title: '{{ i18n "menu.dashboard"}}'
@@ -79,8 +78,8 @@
},
methods: {
openLink(key) {
return key.startsWith('http') ?
window.open(key) :
return key.startsWith('http') ?
window.open(key) :
location.href = key
},
closeDrawer() {
@@ -97,7 +96,7 @@
}
}
},
template: `{{template "component/sidebar/content"}}`,
template: `{{template "component/sidebar/content" .}}`,
});
</script>
{{end}}

View File

@@ -1,237 +1,302 @@
{{define "component/sortableTableTrigger"}}
<a-icon type="drag" class="sortable-icon" :style="{ cursor: 'move' }" @mouseup="mouseUpHandler" @mousedown="mouseDownHandler"
@click="clickHandler" />
<a-icon type="drag" class="sortable-icon"
role="button" tabindex="0"
:aria-label="ariaLabel"
@pointerdown="onPointerDown"
@keydown="onKeyDown" />
{{end}}
{{define "component/aTableSortable"}}
<script>
const DRAGGABLE_ROW_CLASS = 'draggable-row';
const findParentRowElement = (el) => {
if (!el || !el.tagName) {
return null;
} else if (el.classList.contains(DRAGGABLE_ROW_CLASS)) {
return el;
} else if (el.parentNode) {
return findParentRowElement(el.parentNode);
} else {
return null;
}
}
/**
* Sortable a-table — drag-to-reorder rows using Pointer Events.
*
* Why a rewrite:
* - Old impl set `draggable: true` on every row, which (a) broke text
* selection inside cells, (b) let HTML5 start a drag from anywhere on
* the row even when the state machine wasn't primed, producing
* "phantom drags" that didn't reorder anything.
* - HTML5 drag has no touch support on most mobile browsers and no
* keyboard fallback at all.
* - The drag-image hack cloned the entire table — slow on big lists.
*
* New design:
* - Only the explicit drag handle initiates a drag, via Pointer Events
* (one API for mouse + touch + pen). Rows are not draggable.
* - During drag, `data-source` is reordered live: the source row visually
* slides into the target slot and other rows shift around it. The live
* reorder IS the visual feedback — no separate floating preview.
* - On commit, emits `onsort(sourceIndex, targetIndex)` — same event name
* and signature as before, so existing call sites stay unchanged.
* - Keyboard support: the handle is focusable; ArrowUp / ArrowDown move
* the row by one; Escape cancels a pointer-drag in progress.
*/
const ROW_CLASS = 'sortable-row';
Vue.component('a-table-sortable', {
data() {
return {
sortingElementIndex: null,
newElementIndex: null,
// null when idle. While dragging:
// { sourceIndex, targetIndex, pointerId, sourceKey }
drag: null,
};
},
props: {
'data-source': {
type: undefined,
required: false,
},
'customRow': {
type: undefined,
required: false,
}
'data-source': { type: undefined, required: false },
'customRow': { type: undefined, required: false },
'row-key': { type: undefined, required: false },
},
inheritAttrs: false,
provide() {
const sortable = {}
Object.defineProperty(sortable, "setSortableIndex", {
const sortable = {};
// Methods exposed to the trigger child via inject. Defined as getters
// so `this` binds to the component instance, not the plain object.
Object.defineProperty(sortable, 'startDrag', {
enumerable: true,
get: () => this.setCurrentSortableIndex,
get: () => this.startDrag,
});
Object.defineProperty(sortable, "resetSortableIndex", {
Object.defineProperty(sortable, 'moveByKeyboard', {
enumerable: true,
get: () => this.resetSortableIndex,
get: () => this.moveByKeyboard,
});
return {
sortable,
}
return { sortable };
},
render: function (createElement) {
return createElement('a-table', {
class: {
'ant-table-is-sorting': this.isDragging(),
},
props: {
...this.$attrs,
'data-source': this.records,
customRow: (record, index) => this.customRowRender(record, index),
},
on: this.$listeners,
nativeOn: {
drop: (e) => this.dropHandler(e),
},
scopedSlots: this.$scopedSlots,
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`
}
}, this.$slots.default,)
},
created() {
this.$memoSort = {};
beforeDestroy() {
this.detachPointerListeners();
},
methods: {
isDragging() {
const currentIndex = this.sortingElementIndex;
return currentIndex !== null && currentIndex !== undefined;
isDragging() { return this.drag !== null; },
// Resolve the row key for a record. Used to identify the source row
// even after data-source is reordered live during drag.
keyOf(record, fallback) {
const rk = this.rowKey;
if (typeof rk === 'function') return rk(record);
if (typeof rk === 'string') return record && record[rk];
return fallback;
},
resetSortableIndex(e, index) {
this.sortingElementIndex = null;
this.newElementIndex = null;
this.$memoSort = {};
},
setCurrentSortableIndex(e, index) {
this.sortingElementIndex = index;
},
dragStartHandler(e, index) {
if (!this.isDragging()) {
e.preventDefault();
return;
}
const hideDragImage = this.$el.cloneNode(true);
hideDragImage.id = "hideDragImage-hide";
hideDragImage.style.opacity = 0;
e.dataTransfer.setDragImage(hideDragImage, 0, 0);
},
dragStopHandler(e, index) {
const hideDragImage = document.getElementById('hideDragImage-hide');
if (hideDragImage) hideDragImage.remove();
this.resetSortableIndex(e, index);
},
dragOverHandler(e, index) {
if (!this.isDragging()) {
return;
}
startDrag(e, sourceIndex) {
// Primary button only (mouse left / first touch).
if (e.button != null && e.button !== 0) return;
e.preventDefault();
const currentIndex = this.sortingElementIndex;
if (index === currentIndex) {
this.newElementIndex = null;
return;
const record = this.dataSource && this.dataSource[sourceIndex];
this.drag = {
sourceIndex,
targetIndex: sourceIndex,
pointerId: e.pointerId,
sourceKey: this.keyOf(record, sourceIndex),
};
// Capture the pointer so move/up keep firing even if the cursor leaves
// the icon. Try/catch because some older browsers throw on capture.
if (e.target && typeof e.target.setPointerCapture === 'function' && e.pointerId != null) {
try { e.target.setPointerCapture(e.pointerId); } catch (_) {}
}
const row = findParentRowElement(e.target);
if (!row) {
return;
}
const rect = row.getBoundingClientRect();
const offsetTop = e.pageY - rect.top;
if (offsetTop < rect.height / 2) {
this.newElementIndex = Math.max(index - 1, 0);
this.attachPointerListeners();
},
attachPointerListeners() {
this._onMove = (ev) => this.onPointerMove(ev);
this._onUp = (ev) => this.onPointerUp(ev);
this._onCancel = (ev) => this.cancelDrag(ev);
document.addEventListener('pointermove', this._onMove, true);
document.addEventListener('pointerup', this._onUp, true);
document.addEventListener('pointercancel', this._onCancel, true);
document.addEventListener('keydown', this._onCancel, true);
},
detachPointerListeners() {
if (!this._onMove) return;
document.removeEventListener('pointermove', this._onMove, true);
document.removeEventListener('pointerup', this._onUp, true);
document.removeEventListener('pointercancel', this._onCancel, true);
document.removeEventListener('keydown', this._onCancel, true);
this._onMove = this._onUp = this._onCancel = null;
},
onPointerMove(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
// Hit-test: find which row the pointer Y is inside (or closest to).
const rows = this.$el.querySelectorAll('tr.' + ROW_CLASS);
if (!rows.length) return;
const y = e.clientY;
const firstRect = rows[0].getBoundingClientRect();
const lastRect = rows[rows.length - 1].getBoundingClientRect();
let target = this.drag.targetIndex;
if (y < firstRect.top) {
target = 0;
} else if (y > lastRect.bottom) {
target = rows.length - 1;
} else {
this.newElementIndex = index;
for (let i = 0; i < rows.length; i++) {
const rect = rows[i].getBoundingClientRect();
if (y >= rect.top && y <= rect.bottom) {
target = i;
break;
}
}
}
if (target !== this.drag.targetIndex) {
this.drag = Object.assign({}, this.drag, { targetIndex: target });
}
},
dropHandler(e) {
if (this.isDragging()) {
this.$emit('onsort', this.sortingElementIndex, this.newElementIndex);
onPointerUp(e) {
if (!this.drag) return;
if (this.drag.pointerId != null && e.pointerId !== this.drag.pointerId) return;
this.commitDrag();
},
commitDrag() {
const d = this.drag;
this.detachPointerListeners();
this.drag = null;
if (d && d.sourceIndex !== d.targetIndex) {
this.$emit('onsort', d.sourceIndex, d.targetIndex);
}
},
cancelDrag(e) {
// Triggered by pointercancel and keydown handlers. For keydown, only
// act on Escape; otherwise let the event flow to other listeners.
if (e && e.type === 'keydown' && e.key !== 'Escape') return;
this.detachPointerListeners();
this.drag = null;
},
// Keyboard reorder: commit immediately by emitting onsort. No "preview"
// state needed since the move is one row up or down.
moveByKeyboard(direction, sourceIndex) {
const target = sourceIndex + direction;
if (target < 0 || target >= (this.dataSource || []).length) return;
this.$emit('onsort', sourceIndex, target);
},
customRowRender(record, index) {
const parentMethodResult = this.customRow?.(record, index) || {};
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
return {
...parentMethodResult,
attrs: {
...(parentMethodResult?.attrs || {}),
draggable: true,
},
on: {
...(parentMethodResult?.on || {}),
dragstart: (e) => this.dragStartHandler(e, index),
dragend: (e) => this.dragStopHandler(e, index),
dragover: (e) => this.dragOverHandler(e, index),
},
class: {
...(parentMethodResult?.class || {}),
[DRAGGABLE_ROW_CLASS]: true,
['dragging']: this.isDragging() ? (newIndex === null ? index === currentIndex : index === newIndex) : false,
},
};
}
const parent = (typeof this.customRow === 'function')
? (this.customRow(record, index) || {})
: {};
const d = this.drag;
const isSource = d && this.keyOf(record, index) === d.sourceKey;
return Object.assign({}, parent, {
// CRITICAL: no `draggable: true`. Drag is initiated only by the
// handle icon. Leaves text-selection on cells working normally.
attrs: Object.assign({}, parent.attrs || {}),
class: Object.assign({}, parent.class || {}, {
[ROW_CLASS]: true,
'sortable-source-row': !!isSource,
}),
});
},
},
computed: {
// Render-data: dataSource with the source row spliced into targetIndex.
// When idle or when target equals source, returns the original list
// unchanged so Ant Design's table treats this as a stable reference.
records() {
const newIndex = this.newElementIndex;
const currentIndex = this.sortingElementIndex;
if (!this.isDragging() || newIndex === null || currentIndex === newIndex) {
return this.dataSource;
}
if (this.$memoSort.newIndex === newIndex) {
return this.$memoSort.list;
}
let list = [...this.dataSource];
list.splice(newIndex, 0, list.splice(currentIndex, 1)[0]);
this.$memoSort = {
newIndex,
list,
};
const d = this.drag;
if (!d || d.sourceIndex === d.targetIndex) return this.dataSource;
const list = (this.dataSource || []).slice();
const [item] = list.splice(d.sourceIndex, 1);
list.splice(d.targetIndex, 0, item);
return list;
}
}
},
},
render(h) {
return h('a-table', {
class: { 'sortable-table': true, 'sortable-table-dragging': this.isDragging() },
props: Object.assign({}, this.$attrs, {
'data-source': this.records,
'row-key': this.rowKey,
customRow: (record, index) => this.customRowRender(record, index),
locale: {
filterConfirm: `{{ i18n "confirm" }}`,
filterReset: `{{ i18n "reset" }}`,
emptyText: `{{ i18n "noData" }}`,
},
}),
on: this.$listeners,
scopedSlots: this.$scopedSlots,
}, this.$slots.default);
},
});
Vue.component('a-table-sort-trigger', {
template: `{{template "component/sortableTableTrigger"}}`,
template: `{{template "component/sortableTableTrigger" .}}`,
props: {
'item-index': {
type: undefined,
required: false
}
'item-index': { type: undefined, required: false },
},
inject: ['sortable'],
computed: {
ariaLabel() {
// Localised label is overkill for an internal a11y string; English is
// fine here and matches screen-reader expectations across locales.
return 'Drag to reorder row ' + (((this.itemIndex == null ? 0 : this.itemIndex) + 1));
},
},
methods: {
mouseDownHandler(e) {
if (this.sortable) {
this.sortable.setSortableIndex(e, this.itemIndex);
onPointerDown(e) {
if (this.sortable && this.sortable.startDrag) {
this.sortable.startDrag(e, this.itemIndex);
}
},
mouseUpHandler(e) {
if (this.sortable) {
this.sortable.resetSortableIndex(e, this.itemIndex);
onKeyDown(e) {
if (!this.sortable || !this.sortable.moveByKeyboard) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
this.sortable.moveByKeyboard(-1, this.itemIndex);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
this.sortable.moveByKeyboard(+1, this.itemIndex);
}
},
clickHandler(e) {
e.preventDefault();
},
}
})
},
});
</script>
<style>
@media only screen and (max-width: 767px) {
.sortable-icon {
display: none;
}
/* Drag handle — focusable, keyboard-accessible, touch-friendly hit area.
`touch-action: none` is critical: it tells the browser not to interpret
touch on the icon as a scroll/zoom gesture, so pointermove fires for
drag-tracking. Without it, mobile browsers eat the pointer events. */
.sortable-icon {
display: inline-flex;
align-items: center;
justify-content: center;
cursor: grab;
padding: 6px;
border-radius: 6px;
color: rgba(255, 255, 255, 0.5);
transition: background-color 0.15s ease, color 0.15s ease;
user-select: none;
touch-action: none;
}
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active { cursor: grabbing; }
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
}
.ant-table-is-sorting .draggable-row td {
background-color: #ffffff !important;
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.dark .ant-table-is-sorting .draggable-row td {
background-color: var(--dark-color-surface-100) !important;
/* While dragging: the source row gets a soft green wash so the user can
track which row is being moved. Other rows transition smoothly as the
data-source is reordered. */
.sortable-table-dragging .sortable-source-row > td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging td {
background-color: rgb(232 244 242) !important;
color: rgba(0, 0, 0, 0.3);
.sortable-table-dragging .sortable-source-row .routing-index,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.dark .ant-table-is-sorting .dragging td {
background-color: var(--dark-color-table-hover) !important;
color: rgba(255, 255, 255, 0.3);
.sortable-table-dragging .sortable-row > td {
transition: background-color 0.18s ease;
}
.ant-table-is-sorting .dragging {
opacity: 1;
box-shadow: 1px -2px 2px #008771;
transition: all 0.2s;
}
.ant-table-is-sorting .dragging .ant-table-row-index {
opacity: 0.3;
/* Disable text selection across the whole table while a drag is in
progress — selection during drag is never useful and looks broken. */
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;
}
</style>
{{end}}
{{end}}

View File

@@ -24,9 +24,11 @@
{{define "component/themeSwitchTemplateLogin"}}
<template>
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10" :style="{ width: '100%' }">
<a-space @mousedown="themeSwitcher.animationsOff()" id="change-theme" direction="vertical" :size="10"
:style="{ width: '100%' }">
<a-space direction="horizontal" size="small">
<a-switch size="small" :default-checked="themeSwitcher.isDarkTheme" @change="themeSwitcher.toggleTheme()"></a-switch>
<a-switch size="small" :default-checked="themeSwitcher.isDarkTheme"
@change="themeSwitcher.toggleTheme()"></a-switch>
<span>{{ i18n "menu.dark" }}</span>
</a-space>
<a-space v-if="themeSwitcher.isDarkTheme" direction="horizontal" size="small">
@@ -40,7 +42,8 @@
{{define "component/aThemeSwitch"}}
<script>
function createThemeSwitcher() {
const isDarkTheme = localStorage.getItem('dark-mode') === 'true';
const darkModePreference = localStorage.getItem('dark-mode');
const isDarkTheme = darkModePreference === null ? true : darkModePreference === 'true';
const isUltra = localStorage.getItem('isUltraDarkThemeEnabled') === 'true';
if (isUltra) {
document.documentElement.setAttribute('data-theme', 'ultra-dark');
@@ -92,7 +95,7 @@
}
const themeSwitcher = createThemeSwitcher();
Vue.component('a-theme-switch', {
template: `{{template "component/themeSwitchTemplate"}}`,
template: `{{template "component/themeSwitchTemplate" .}}`,
data: () => ({
themeSwitcher
}),
@@ -104,7 +107,7 @@
}
});
Vue.component('a-theme-switch-login', {
template: `{{template "component/themeSwitchTemplateLogin"}}`,
template: `{{template "component/themeSwitchTemplateLogin" .}}`,
data: () => ({
themeSwitcher
}),

View File

@@ -1,11 +1,5 @@
{{define "form/client"}}
<a-form
layout="horizontal"
v-if="client"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form layout="horizontal" v-if="client" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.inbounds.enable" }}'>
<a-switch v-model="client.enable"></a-switch>
</a-form-item>
@@ -16,33 +10,22 @@
<span>{{ i18n "pages.inbounds.emailDesc" }}</span>
</template>
{{ i18n "pages.inbounds.email" }}
<a-icon
type="sync"
@click="client.email = RandomUtil.randomLowerAndNum(9)"
></a-icon>
<a-icon type="sync" @click="client.email = RandomUtil.randomLowerAndNum(9)"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.email"></a-input>
</a-form-item>
<a-form-item
v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS"
>
<a-form-item v-if="inbound.protocol === Protocols.TROJAN || inbound.protocol === Protocols.SHADOWSOCKS">
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "reset" }}</span>
</template>
{{ i18n "password" }}
<a-icon
v-if="inbound.protocol === Protocols.SHADOWSOCKS"
@click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
type="sync"
></a-icon>
<a-icon
v-if="inbound.protocol === Protocols.TROJAN"
@click="client.password = RandomUtil.randomSeq(10)"
type="sync"
>
<a-icon v-if="inbound.protocol === Protocols.SHADOWSOCKS"
@click="client.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)" type="sync"></a-icon>
<a-icon v-if="inbound.protocol === Protocols.TROJAN" @click="client.password = RandomUtil.randomSeq(10)"
type="sync">
</a-icon>
</a-tooltip>
</template>
@@ -55,42 +38,26 @@
<span>{{ i18n "reset" }}</span>
</template>
Auth Password
<a-icon
@click="client.auth = RandomUtil.randomSeq(10)"
type="sync"
></a-icon>
<a-icon @click="client.auth = RandomUtil.randomSeq(10)" type="sync"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.auth"></a-input>
</a-form-item>
<a-form-item
v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS"
>
<a-form-item v-if="inbound.protocol === Protocols.VMESS || inbound.protocol === Protocols.VLESS">
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "reset" }}</span>
</template>
ID
<a-icon
@click="client.id = RandomUtil.randomUUID()"
type="sync"
></a-icon>
<a-icon @click="client.id = RandomUtil.randomUUID()" type="sync"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.id"></a-input>
</a-form-item>
<a-form-item
v-if="inbound.protocol === Protocols.VMESS"
label='{{ i18n "security" }}'
>
<a-select
v-model="client.security"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="key in USERS_SECURITY" :value="key"
>[[ key ]]</a-select-option
>
<a-form-item v-if="inbound.protocol === Protocols.VMESS" label='{{ i18n "security" }}'>
<a-select v-model="client.security" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="client.email && app.subSettings?.enable">
@@ -100,10 +67,7 @@
<span>{{ i18n "pages.inbounds.subscriptionDesc" }}</span>
</template>
Subscription
<a-icon
@click="client.subId = RandomUtil.randomLowerAndNum(16)"
type="sync"
></a-icon>
<a-icon @click="client.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.subId"></a-input>
@@ -118,11 +82,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number
:style="{ width: '50%' }"
v-model.number="client.tgId"
min="0"
></a-input-number>
<a-input-number :style="{ width: '50%' }" v-model.number="client.tgId" min="0"></a-input-number>
</a-form-item>
<a-form-item v-if="client.email" label='{{ i18n "comment" }}'>
<a-input v-model.trim="client.comment"></a-input>
@@ -139,9 +99,7 @@
</template>
<a-input-number v-model.number="client.limitIp" min="0"></a-input-number>
</a-form-item>
<a-form-item
v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit"
>
<a-form-item v-if="app.ipLimitEnable && client.limitIp > 0 && client.email && isEdit">
<template slot="label">
<a-tooltip>
<template slot="title">
@@ -160,27 +118,29 @@
</span>
</a-tooltip>
<a-form layout="block">
<a-textarea
id="clientIPs"
readonly
@click="getDBClientIps(client.email)"
placeholder="Click To Get IPs"
:auto-size="{ minRows: 5, maxRows: 10 }"
>
<a-textarea id="clientIPs" readonly @click="getDBClientIps(client.email)" placeholder="Click To Get IPs"
:auto-size="{ minRows: 5, maxRows: 10 }">
</a-textarea>
</a-form>
</a-form-item>
<a-form-item v-if="inbound.canEnableTlsFlow()" label="Flow">
<a-select
v-model="client.flow"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="client.flow" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value selected>{{ i18n "none" }}</a-select-option>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key"
>[[ key ]]</a-select-option
>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="inbound.protocol === Protocols.VLESS">
<template slot="label">
<a-tooltip>
<template slot="title">
<span>{{ i18n "pages.xray.outbound.reverseTagDesc" }}</span>
</template>
{{ i18n "pages.xray.outbound.reverseTag" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="client.reverseTag" :placeholder='`{{ i18n "pages.xray.outbound.reverseTagPlaceholder" }}`'></a-input>
</a-form-item>
<a-form-item>
<template slot="label">
<a-tooltip>
@@ -201,45 +161,28 @@
</a-tag>
<a-tooltip>
<template slot="title">{{ i18n "pages.inbounds.resetTraffic" }}</template>
<a-icon
type="retweet"
@click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
v-if="client.email.length > 0"
></a-icon>
<a-icon type="retweet" @click="resetClientTraffic(client.email,clientStats.inboundId,$event.target)"
v-if="client.email.length > 0"></a-icon>
</a-tooltip>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
<a-switch v-model="delayedStart" @click="client._expiryTime=0"></a-switch>
</a-form-item>
<a-form-item v-if="delayedStart" label='{{ i18n "pages.client.expireDays" }}'>
<a-input-number
v-model.number="delayedExpireDays"
:min="0"
></a-input-number>
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
</a-form-item>
<a-form-item v-else>
<template slot="label">
<a-tooltip>
<template slot="title"
>{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template
>
<template slot="title">{{ i18n "pages.inbounds.leaveBlankToNeverExpire" }}</template>
{{ i18n "pages.inbounds.expireDate" }}
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-date-picker
v-if="datepicker == 'gregorian'"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme"
v-model="client._expiryTime"
></a-date-picker>
<a-persian-datepicker
v-else
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
value="client._expiryTime"
v-model="client._expiryTime"
></a-persian-datepicker>
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }" format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme" v-model="client._expiryTime"></a-date-picker>
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
value="client._expiryTime" v-model="client._expiryTime"></a-persian-datepicker>
<a-tag color="red" v-if="isEdit && isExpiry">Expired</a-tag>
</a-form-item>
<a-form-item v-if="client.expiryTime != 0">
@@ -253,4 +196,4 @@
<a-input-number v-model.number="client.reset" :min="0"></a-input-number>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -102,69 +102,69 @@
<!-- vmess settings -->
<template v-if="inbound.protocol === Protocols.VMESS">
{{template "form/vmess"}}
{{template "form/vmess" .}}
</template>
<!-- vless settings -->
<template v-if="inbound.protocol === Protocols.VLESS">
{{template "form/vless"}}
{{template "form/vless" .}}
</template>
<!-- trojan settings -->
<template v-if="inbound.protocol === Protocols.TROJAN">
{{template "form/trojan"}}
{{template "form/trojan" .}}
</template>
<!-- shadowsocks -->
<template v-if="inbound.protocol === Protocols.SHADOWSOCKS">
{{template "form/shadowsocks"}}
{{template "form/shadowsocks" .}}
</template>
<!-- tunnel -->
<template v-if="inbound.protocol === Protocols.TUNNEL">
{{template "form/tunnel"}}
{{template "form/tunnel" .}}
</template>
<!-- mixed -->
<template v-if="inbound.protocol === Protocols.MIXED">
{{template "form/mixed"}}
{{template "form/mixed" .}}
</template>
<!-- http -->
<template v-if="inbound.protocol === Protocols.HTTP">
{{template "form/http"}}
{{template "form/http" .}}
</template>
<!-- wireguard -->
<template v-if="inbound.protocol === Protocols.WIREGUARD">
{{template "form/wireguard"}}
{{template "form/wireguard" .}}
</template>
<!-- tun -->
<template v-if="inbound.protocol === Protocols.TUN">
{{template "form/tun"}}
{{template "form/tun" .}}
</template>
<!-- hysteria -->
<template v-if="inbound.protocol === Protocols.HYSTERIA">
{{template "form/hysteria"}}
{{template "form/hysteria" .}}
</template>
<!-- stream settings -->
<template v-if="inbound.canEnableStream()">
{{template "form/streamSettings"}}
{{template "form/externalProxy" }}
{{template "form/streamSettings" .}}
{{template "form/externalProxy" .}}
</template>
<!-- tls settings -->
<template v-if="inbound.canEnableTls()">
{{template "form/tlsSettings"}}
{{template "form/tlsSettings" .}}
</template>
<!-- sniffing -->
<a-collapse>
<a-collapse-panel header='Sniffing'>
{{template "form/sniffing"}}
{{template "form/sniffing" .}}
</a-collapse-panel>
</a-collapse>

File diff suppressed because it is too large Load Diff

View File

@@ -25,13 +25,13 @@
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
</a-select>
</a-form-item>
</a-form-item>
<a-form-item label='Follow Redirect'>
<a-switch v-model="inbound.settings.followRedirect"></a-switch>
</a-form-item>
</a-form>
<!-- sockopt -->
<template>
{{template "form/streamSockopt"}}
{{template "form/streamSockopt" .}}
</template>
{{end}}
{{end}}

View File

@@ -5,7 +5,8 @@
<td width="45%">{{ i18n "username" }}</td>
<td width="45%">{{ i18n "password" }}</td>
<td>
<a-button icon="plus" size="small" @click="inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())"></a-button>
<a-button icon="plus" size="small"
@click="inbound.settings.addAccount(new Inbound.HttpSettings.HttpAccount())"></a-button>
</td>
</tr>
</table>
@@ -23,4 +24,4 @@
<a-switch v-model="inbound.settings.allowTransparent" />
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,9 +1,7 @@
{{define "form/hysteria"}}
<a-collapse activeKey="0"
v-for="(client, index) in inbound.settings.hysterias.slice(0,1)"
v-if="!isEdit">
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.hysterias.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
{{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
@@ -22,11 +20,9 @@
</table>
</a-collapse-panel>
</a-collapse>
<a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item :label="'{{ i18n "pages.inbounds.stream.tcp.version" }}'">
<a-input-number v-model.number="inbound.settings.version" :min="2"
:max="2" disabled></a-input-number>
<a-input-number v-model.number="inbound.settings.version" :min="2" :max="2" disabled></a-input-number>
</a-form-item>
</a-form>
{{end}}

View File

@@ -1,12 +1,8 @@
{{define "form/shadowsocks"}}
<template v-if="inbound.isSSMultiUser">
<a-collapse
activeKey="0"
v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)"
v-if="!isEdit"
>
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.shadowsockses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
{{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
@@ -16,10 +12,8 @@
<th>{{ i18n "pages.inbounds.email" }}</th>
<th>Password</th>
</tr>
<tr
v-for="(client, index) in inbound.settings.shadowsockses"
:class="index % 2 == 1 ? ' client-table-odd-row' : ''"
>
<tr v-for="(client, index) in inbound.settings.shadowsockses"
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
<td>[[ client.email ]]</td>
<td>[[ client.password ]]</td>
</tr>
@@ -27,20 +21,11 @@
</a-collapse-panel>
</a-collapse>
</template>
<a-form
:colon=" false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon=" false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "encryption" }}'>
<a-select
v-model="inbound.settings.method"
@change="SSMethodChange"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="(method,method_name) in SSMethods" :value="method"
>[[ method_name ]]</a-select-option
>
<a-select v-model="inbound.settings.method" @change="SSMethodChange"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="(method,method_name) in SSMethods" :value="method">[[ method_name ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="inbound.isSS2022">
@@ -50,20 +35,15 @@
<span>{{ i18n "reset" }}</span>
</template>
Password
<a-icon
@click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
type="sync"
></a-icon>
<a-icon @click="inbound.settings.password = RandomUtil.randomShadowsocksPassword(inbound.settings.method)"
type="sync"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="inbound.settings.password"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.network" }}'>
<a-select
v-model="inbound.settings.network"
:style="{ width: '100px' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.settings.network" :style="{ width: '100px' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tcp,udp">TCP,UDP</a-select-option>
<a-select-option value="tcp">TCP</a-select-option>
<a-select-option value="udp">UDP</a-select-option>
@@ -73,4 +53,4 @@
<a-switch v-model="inbound.settings.ivCheck"></a-switch>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,9 +1,5 @@
{{define "form/mixed"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.inbounds.enable" }} UDP'>
<a-switch v-model="inbound.settings.udp"></a-switch>
</a-form-item>
@@ -11,10 +7,8 @@
<a-input v-model.trim="inbound.settings.ip"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "password" }}'>
<a-switch
:checked="inbound.settings.auth === 'password'"
@change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"
></a-switch>
<a-switch :checked="inbound.settings.auth === 'password'"
@change="checked => inbound.settings.auth = checked ? 'password' : 'noauth'"></a-switch>
</a-form-item>
<template v-if="inbound.settings.auth === 'password'">
<table :style="{ width: '100%', textAlign: 'center', margin: '1rem 0' }">
@@ -22,42 +16,21 @@
<td width="45%">{{ i18n "username" }}</td>
<td width="45%">{{ i18n "password" }}</td>
<td>
<a-button
icon="plus"
size="small"
@click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"
></a-button>
<a-button icon="plus" size="small"
@click="inbound.settings.addAccount(new Inbound.MixedSettings.SocksAccount())"></a-button>
</td>
</tr>
</table>
<a-input-group
compact
v-for="(account, index) in inbound.settings.accounts"
:style="{ marginBottom: '10px' }"
>
<a-input
:style="{ width: '50%' }"
v-model.trim="account.user"
placeholder='{{ i18n "username" }}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(account, index) in inbound.settings.accounts" :style="{ marginBottom: '10px' }">
<a-input :style="{ width: '50%' }" v-model.trim="account.user" placeholder='{{ i18n "username" }}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="account.pass"
placeholder='{{ i18n "password" }}'
>
<a-input :style="{ width: '50%' }" v-model.trim="account.pass" placeholder='{{ i18n "password" }}'>
<template slot="addonAfter">
<a-button
icon="minus"
size="small"
@click="inbound.settings.delAccount(index)"
></a-button>
<a-button icon="minus" size="small" @click="inbound.settings.delAccount(index)"></a-button>
</template>
</a-input>
</a-input-group>
</template>
</a-form>
{{end}}
{{end}}

View File

@@ -1,7 +1,7 @@
{{define "form/trojan"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.trojans.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
{{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
@@ -19,35 +19,35 @@
</a-collapse-panel>
</a-collapse>
<template v-if=" inbound.isTcp">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Fallbacks">
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
</a-form-item>
</a-form>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Fallbacks">
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
</a-form-item>
</a-form>
<!-- trojan fallbacks -->
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
@click="() => inbound.settings.delFallback(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='SNI'>
<a-input v-model="fallback.name"></a-input>
</a-form-item>
<a-form-item label='ALPN'>
<a-input v-model="fallback.alpn"></a-input>
</a-form-item>
<a-form-item label='Path'>
<a-input v-model="fallback.path"></a-input>
</a-form-item>
<a-form-item label='Dest'>
<a-input v-model="fallback.dest"></a-input>
</a-form-item>
<a-form-item label='xVer'>
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
</a-form-item>
</a-form>
<a-divider style="margin:5px 0;"></a-divider>
</template>
{{end}}
<!-- trojan fallbacks -->
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
@click="() => inbound.settings.delFallback(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='SNI'>
<a-input v-model="fallback.name"></a-input>
</a-form-item>
<a-form-item label='ALPN'>
<a-input v-model="fallback.alpn"></a-input>
</a-form-item>
<a-form-item label='Path'>
<a-input v-model="fallback.path"></a-input>
</a-form-item>
<a-form-item label='Dest'>
<a-input v-model="fallback.dest"></a-input>
</a-form-item>
<a-form-item label='xVer'>
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
</a-form-item>
</a-form>
<a-divider style="margin:5px 0;"></a-divider>
</template>
{{end}}

View File

@@ -1,9 +1,5 @@
{{define "form/tun"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item>
<template slot="label">
<a-tooltip>
@@ -26,38 +22,18 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number
v-model.number="inbound.settings.mtu[0]"
:min="1"
:max="9000"
placeholder="1500"
></a-input-number>
<a-input-number v-model.number="inbound.settings.mtu[0]" :min="1" :max="9000" placeholder="1500"></a-input-number>
</a-form-item>
<a-form-item label="MTU IPv6">
<a-input-number
v-model.number="inbound.settings.mtu[1]"
:min="1"
:max="9000"
placeholder="1280"
></a-input-number>
<a-input-number v-model.number="inbound.settings.mtu[1]" :min="1" :max="9000" placeholder="1280"></a-input-number>
</a-form-item>
<a-form-item label="Gateway">
<a-select
mode="tags"
v-model="inbound.settings.gateway"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="IPv4/IPv6 gateway"
></a-select>
<a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']"
placeholder="IPv4/IPv6 gateway"></a-select>
</a-form-item>
<a-form-item label="DNS">
<a-select
mode="tags"
v-model="inbound.settings.dns"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="DNS servers"
></a-select>
<a-select mode="tags" v-model="inbound.settings.dns" :style="{ width: '100%' }" :token-separators="[',']"
placeholder="DNS servers"></a-select>
</a-form-item>
<a-form-item>
<template slot="label">
@@ -69,26 +45,14 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number
v-model.number="inbound.settings.userLevel"
:min="0"
placeholder="0"
></a-input-number>
<a-input-number v-model.number="inbound.settings.userLevel" :min="0" placeholder="0"></a-input-number>
</a-form-item>
<a-form-item label="Auto Routing Table">
<a-select
mode="tags"
v-model="inbound.settings.autoSystemRoutingTable"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="e.g. vpn, proxy"
></a-select>
<a-select mode="tags" v-model="inbound.settings.autoSystemRoutingTable" :style="{ width: '100%' }"
:token-separators="[',']" placeholder="e.g. vpn, proxy"></a-select>
</a-form-item>
<a-form-item label="Auto Outbounds">
<a-input
v-model.trim="inbound.settings.autoOutboundsInterface"
placeholder="auto"
></a-input>
<a-input v-model.trim="inbound.settings.autoOutboundsInterface" placeholder="auto"></a-input>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,7 +1,7 @@
{{define "form/vless"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vlesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
{{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
@@ -12,8 +12,7 @@
<th>{{ i18n "pages.inbounds.email" }}</th>
<th>ID</th>
</tr>
<tr v-for="(client, index) in inbound.settings.vlesses"
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
<tr v-for="(client, index) in inbound.settings.vlesses" :class="index % 2 == 1 ? ' client-table-odd-row' : ''">
<td>[[ client.email ]]</td>
<td>[[ client.id ]]</td>
</tr>
@@ -21,103 +20,104 @@
</a-collapse-panel>
</a-collapse>
<template v-if=" !inbound.stream.isTLS || !inbound.stream.isReality">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Authentication">
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="X25519, not Post-Quantum">X25519 (not
Post-Quantum)</a-select-option>
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
(Post-Quantum)</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="decryption">
<a-input v-model.trim="inbound.settings.decryption"></a-input>
</a-form-item>
<a-form-item label="encryption">
<a-input v-model="inbound.settings.encryption"></a-input>
</a-form-item>
<a-form-item label=" ">
<a-space>
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
keys</a-button>
<a-button danger @click="clearVlessEnc">Clear</a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Fallbacks">
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
</a-form-item>
</a-form>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Authentication">
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="undefined">None</a-select-option>
<a-select-option value="X25519, not Post-Quantum">X25519 (not
Post-Quantum)</a-select-option>
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
(Post-Quantum)</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="decryption">
<a-input v-model.trim="inbound.settings.decryption"></a-input>
</a-form-item>
<a-form-item label="encryption">
<a-input v-model="inbound.settings.encryption"></a-input>
</a-form-item>
<a-form-item label=" ">
<a-space>
<a-button type="primary" icon="import" @click="getNewVlessEnc">Get New
keys</a-button>
<a-button danger @click="clearVlessEnc">Clear</a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
<template v-if="inbound.isTcp && !inbound.settings.selectedAuth">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Fallbacks">
<a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
</a-form-item>
</a-form>
<!-- vless fallbacks -->
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
@click="() => inbound.settings.delFallback(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='SNI'>
<a-input v-model="fallback.name"></a-input>
</a-form-item>
<a-form-item label='ALPN'>
<a-input v-model="fallback.alpn"></a-input>
</a-form-item>
<a-form-item label='Path'>
<a-input v-model="fallback.path"></a-input>
</a-form-item>
<a-form-item label='Dest'>
<a-input v-model="fallback.dest"></a-input>
</a-form-item>
<a-form-item label='xVer'>
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
<template v-if="inbound.canEnableVisionSeed()">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Vision Seed">
<a-row :gutter="8">
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }"
placeholder="900" addon-before="[0]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }"
placeholder="500" addon-before="[1]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }"
placeholder="900" addon-before="[2]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }"
placeholder="256" addon-before="[3]"></a-input-number>
</a-col>
</a-row>
<a-space :size="8" :style="{ marginTop: '8px' }">
<a-button type="primary" @click="setRandomTestseed">
Rand
</a-button>
<a-button @click="resetTestseed">
Reset
</a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
{{end}}
<!-- vless fallbacks -->
<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
@click="() => inbound.settings.delFallback(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='SNI'>
<a-input v-model="fallback.name"></a-input>
</a-form-item>
<a-form-item label='ALPN'>
<a-input v-model="fallback.alpn"></a-input>
</a-form-item>
<a-form-item label='Path'>
<a-input v-model="fallback.path"></a-input>
</a-form-item>
<a-form-item label='Dest'>
<a-input v-model="fallback.dest"></a-input>
</a-form-item>
<a-form-item label='xVer'>
<a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
<template v-if="inbound.canEnableVisionSeed()">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Vision Seed">
<a-row :gutter="8">
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[0] !== undefined) ? inbound.settings.testseed[0] : 900"
@change="(val) => updateTestseed(0, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
addon-before="[0]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[1] !== undefined) ? inbound.settings.testseed[1] : 500"
@change="(val) => updateTestseed(1, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="500"
addon-before="[1]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[2] !== undefined) ? inbound.settings.testseed[2] : 900"
@change="(val) => updateTestseed(2, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="900"
addon-before="[2]"></a-input-number>
</a-col>
<a-col :span="6">
<a-input-number
:value="(inbound.settings.testseed && inbound.settings.testseed[3] !== undefined) ? inbound.settings.testseed[3] : 256"
@change="(val) => updateTestseed(3, val)" :min="0" :max="9999" :style="{ width: '100%' }" placeholder="256"
addon-before="[3]"></a-input-number>
</a-col>
</a-row>
<a-space :size="8" :style="{ marginTop: '8px' }">
<a-button type="primary" @click="setRandomTestseed">
Rand
</a-button>
<a-button @click="resetTestseed">
Reset
</a-button>
</a-space>
</a-form-item>
</a-form>
<a-divider :style="{ margin: '5px 0' }"></a-divider>
</template>
{{end}}

View File

@@ -1,7 +1,7 @@
{{define "form/vmess"}}
<a-collapse activeKey="0" v-for="(client, index) in inbound.settings.vmesses.slice(0,1)" v-if="!isEdit">
<a-collapse-panel header='{{ i18n "pages.inbounds.client" }}'>
{{template "form/client"}}
{{template "form/client" .}}
</a-collapse-panel>
</a-collapse>
<a-collapse v-else>
@@ -12,8 +12,8 @@
<th>ID</th>
<th>{{ i18n "security" }}</th>
</tr>
<tr v-for="(client, index) in inbound.settings.vmesses"
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
<tr v-for="(client, index) in inbound.settings.vmesses"
:class="index % 2 == 1 ? ' client-table-odd-row' : ''">
<td>[[ client.email ]]</td>
<td>[[ client.id ]]</td>
<td>[[ client.security ]]</td>

View File

@@ -1,9 +1,5 @@
{{define "form/sniffing"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item>
<span slot="label">
{{ i18n "enabled" }}
@@ -19,9 +15,7 @@
<template v-if="inbound.sniffing.enabled">
<a-form-item :wrapper-col="{span:24}">
<a-checkbox-group v-model="inbound.sniffing.destOverride">
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key"
>[[ value ]]</a-checkbox
>
<a-checkbox v-for="key,value in SNIFFING_OPTION" :value="key">[[ value ]]</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="Metadata Only">
@@ -31,23 +25,13 @@
<a-switch v-model="inbound.sniffing.routeOnly"></a-switch>
</a-form-item>
<a-form-item label="IPs Excluded">
<a-select
mode="tags"
v-model="inbound.sniffing.ipsExcluded"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="IP/CIDR/geoip:*/ext:*"
></a-select>
<a-select mode="tags" v-model="inbound.sniffing.ipsExcluded" :style="{ width: '100%' }" :token-separators="[',']"
placeholder="IP/CIDR/geoip:*/ext:*"></a-select>
</a-form-item>
<a-form-item label="Domains Excluded">
<a-select
mode="tags"
v-model="inbound.sniffing.domainsExcluded"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="domain:*/ext:*"
></a-select>
<a-select mode="tags" v-model="inbound.sniffing.domainsExcluded" :style="{ width: '100%' }"
:token-separators="[',']" placeholder="domain:*/ext:*"></a-select>
</a-form-item>
</template>
</a-form>
{{end}}
{{end}}

View File

@@ -1,67 +1,31 @@
{{define "form/externalProxy"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<a-form-item label="External Proxy">
<a-switch v-model="externalProxy"></a-switch>
<a-button
icon="plus"
v-if="externalProxy"
type="primary"
:style="{ marginLeft: '10px' }"
size="small"
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"
></a-button>
<a-button icon="plus" v-if="externalProxy" type="primary" :style="{ marginLeft: '10px' }" size="small"
@click="inbound.stream.externalProxy.push({forceTls: 'same', dest: '', port: 443, remark: ''})"></a-button>
</a-form-item>
<a-input-group
:style="{ margin: '8px 0' }"
compact
v-for="(row, index) in inbound.stream.externalProxy"
>
<a-input-group :style="{ margin: '8px 0' }" compact v-for="(row, index) in inbound.stream.externalProxy">
<template>
<a-tooltip title="Force TLS">
<a-select
v-model="row.forceTls"
:style="{ width: '20%', margin: '0px' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="same"
>{{ i18n "pages.inbounds.same" }}</a-select-option
>
<a-select v-model="row.forceTls" :style="{ width: '20%', margin: '0px' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="same">{{ i18n "pages.inbounds.same" }}</a-select-option>
<a-select-option value="none">{{ i18n "none" }}</a-select-option>
<a-select-option value="tls">TLS</a-select-option>
</a-select>
</a-tooltip>
</template>
<a-input
:style="{ width: '30%' }"
v-model.trim="row.dest"
placeholder='{{ i18n "host" }}'
></a-input>
<a-input :style="{ width: '30%' }" v-model.trim="row.dest" placeholder='{{ i18n "host" }}'></a-input>
<a-tooltip title='{{ i18n "pages.inbounds.port" }}'>
<a-input-number
:style="{ width: '15%' }"
v-model.number="row.port"
min="1"
max="65535"
></a-input-number>
<a-input-number :style="{ width: '15%' }" v-model.number="row.port" min="1" max="65535"></a-input-number>
</a-tooltip>
<a-input
:style="{ width: '30%', top: '0' }"
v-model.trim="row.remark"
placeholder='{{ i18n "remark" }}'
>
<a-input :style="{ width: '30%', top: '0' }" v-model.trim="row.remark" placeholder='{{ i18n "remark" }}'>
<template slot="addonAfter">
<a-button
icon="minus"
size="small"
@click="inbound.stream.externalProxy.splice(index, 1)"
></a-button>
<a-button icon="minus" size="small" @click="inbound.stream.externalProxy.splice(index, 1)"></a-button>
</template>
</a-input>
</a-input-group>
</a-form>
{{end}}
{{end}}

View File

@@ -1,260 +1,408 @@
{{define "form/streamFinalMask"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"
v-if="inbound.protocol == Protocols.HYSTERIA || ['kcp', 'xhttp', 'raw', 'tcp', 'httpupgrade', 'ws', 'grpc'].includes(inbound.stream.network)">
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<a-form-item label="UDP Masks">
<a-button
icon="plus"
type="primary"
size="small"
@click="inbound.stream.addUdpMask(inbound.protocol === Protocols.HYSTERIA ? 'salamander' : 'mkcp-aes128gcm')"
></a-button>
</a-form-item>
<template
v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0"
>
<a-form
v-for="(mask, index) in inbound.stream.finalmask.udp"
:key="index"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-divider :style="{ margin: '0' }">
UDP Mask [[ index + 1 ]]
<a-icon
type="delete"
@click="() => inbound.stream.delUdpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="mask.type"
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<template v-if="inbound.protocol === Protocols.HYSTERIA">
<a-select-option value="salamander"
>Salamander (Hysteria2)</a-select-option
>
</template>
<template v-else>
<a-select-option value="mkcp-aes128gcm"
>mKCP AES-128-GCM</a-select-option
>
<a-select-option value="header-dns">Header DNS</a-select-option>
<a-select-option value="header-dtls"
>Header DTLS 1.2</a-select-option
>
<a-select-option value="header-srtp">Header SRTP</a-select-option>
<a-select-option value="header-utp">Header uTP</a-select-option>
<a-select-option value="header-wechat"
>Header WeChat Video</a-select-option
>
<a-select-option value="header-wireguard"
>Header WireGuard</a-select-option
>
<a-select-option value="mkcp-original"
>mKCP Original</a-select-option
>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom"
>Header Custom</a-select-option
>
<a-select-option value="noise">Noise</a-select-option>
<!-- TCP Masks for raw/tcp/httpupgrade/ws/grpc/xhttp -->
<template v-if="['raw', 'tcp', 'httpupgrade', 'ws', 'grpc', 'xhttp'].includes(inbound.stream.network)">
<a-form-item label="TCP Masks">
<a-button icon="plus" type="primary" size="small" @click="inbound.stream.addTcpMask('fragment')"></a-button>
</a-form-item>
<template v-if="inbound.stream.finalmask.tcp && inbound.stream.finalmask.tcp.length > 0">
<a-form v-for="(mask, index) in inbound.stream.finalmask.tcp" :key="index" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }">
TCP Mask [[ index + 1 ]]
<a-icon type="delete" @click="() => inbound.stream.delTcpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="mask.type" @change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="fragment">Fragment</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="sudoku">Sudoku</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item
label="Password"
v-if="['mkcp-aes128gcm', 'salamander', 'sudoku'].includes(mask.type)"
>
<a-input
v-model.trim="mask.settings.password"
placeholder="Obfuscation password"
></a-input>
</a-form-item>
<a-form-item label="Domain" v-if="mask.type === 'header-dns'">
<a-input
v-model.trim="mask.settings.domain"
placeholder="e.g., www.example.com"
></a-input>
</a-form-item>
<template v-if="mask.type === 'xdns'">
<a-form-item label="Domains">
<a-select
mode="tags"
v-model="mask.settings.domains"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="e.g., www.example.com"
></a-select>
</a-select>
</a-form-item>
</template>
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number v-model.number="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="c.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="c.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
<!-- Fragment settings -->
<template v-if="mask.type === 'fragment'">
<a-form-item label="Packets">
<a-select v-model="mask.settings.packets" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tlshello">tlshello</a-select-option>
<a-select-option value="1-3">1-3</a-select-option>
<a-select-option value="1-5">1-5</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"
/>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input-number v-model.number="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="s.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="s.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model.number="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-icon
type="plus"
type="primary"
size="small"
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: '', delay: ''})"
/>
</a-form-item>
<template v-for="(n, index) in mask.settings.noise" :key="index">
<a-divider :style="{ margin: '0' }">
Noise [[ index + 1 ]]
<a-icon
type="delete"
@click="() => mask.settings.noise.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"
></a-icon>
</a-divider>
<a-form-item label="Rand">
<a-input v-model.trim="n.rand" placeholder="1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="n.randRange" placeholder="0-255" />
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select
v-model="n.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model.trim="n.packet" placeholder="binary data" />
<a-form-item label="Length">
<a-input v-model.trim="mask.settings.length" placeholder="e.g. 100-200" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model.trim="n.delay" placeholder="10-20" />
<a-input v-model.trim="mask.settings.delay" placeholder="e.g. 10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model.trim="mask.settings.maxSplit" placeholder="e.g. 3-6" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'sudoku'">
<a-form-item label="ASCII">
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
<!-- Sudoku settings (TCP) -->
<template v-if="mask.type === 'sudoku'">
<a-form-item label="Password">
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password" />
</a-form-item>
<a-form-item label="ASCII">
<a-input v-model.trim="mask.settings.ascii" placeholder="ASCII" />
</a-form-item>
<a-form-item label="Custom Table">
<a-input v-model.trim="mask.settings.customTable" placeholder="Custom Table" />
</a-form-item>
<a-form-item label="Custom Tables">
<a-input v-model.trim="mask.settings.customTables" placeholder="Custom Tables" />
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
</a-form-item>
</template>
<!-- Header Custom (TCP) clients/servers/errors are 2D arrays of groups -->
<template v-if="mask.type === 'header-custom'">
<!-- Clients -->
<a-form-item label="Clients">
<a-button icon="plus" type="primary" size="small"
@click="mask.settings.clients.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="'cg'+gi">
<a-divider :style="{ margin: '0' }">
Clients Group [[ gi + 1 ]]
<a-icon type="delete" @click="mask.settings.clients.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'ci'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="item.type === 'base64'">
<a-input v-model.trim="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<!-- Servers -->
<a-form-item label="Servers">
<a-button icon="plus" type="primary" size="small"
@click="mask.settings.servers.push([{delay: 0, rand: 0, randRange: '0-255', type: 'array', packet: []}])"></a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="'sg'+gi">
<a-divider :style="{ margin: '0' }">
Servers Group [[ gi + 1 ]]
<a-icon type="delete" @click="mask.settings.servers.splice(gi, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"></a-icon>
</a-divider>
<template v-for="(item, ii) in group" :key="'si'+ii">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="item.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') item.packet = RandomUtil.randomBase64(); else if(t === 'array') { item.rand = 0; item.packet = []; } else { item.packet = ''; } }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input-number v-model.number="item.delay" :min="0" />
</a-form-item>
<template v-if="item.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="item.rand" :min="0" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="item.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="item.type === 'base64'">
<a-input v-model.trim="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="item.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="item.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
</template>
</a-form>
</template>
</template>
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'kcp'">
<a-form-item label="UDP Masks">
<a-button icon="plus" type="primary" size="small"
@click="inbound.stream.addUdpMask(inbound.protocol === Protocols.HYSTERIA ? 'salamander' : 'mkcp-aes128gcm')"></a-button>
</a-form-item>
<template v-if="inbound.stream.finalmask.udp && inbound.stream.finalmask.udp.length > 0">
<a-form v-for="(mask, index) in inbound.stream.finalmask.udp" :key="index" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-divider :style="{ margin: '0' }">
UDP Mask [[ index + 1 ]]
<a-icon type="delete" @click="() => inbound.stream.delUdpMask(index)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="mask.type"
@change="(type) => { mask.settings = mask._getDefaultSettings(type, {}); if(inbound.stream.network === 'kcp') { inbound.stream.kcp.mtu = type === 'xdns' ? 900 : 1350; } }"
:dropdown-class-name="themeSwitcher.currentTheme">
<template v-if="inbound.protocol === Protocols.HYSTERIA">
<a-select-option value="salamander">Salamander (Hysteria2)</a-select-option>
</template>
<template v-else>
<a-select-option value="mkcp-aes128gcm">mKCP AES-128-GCM</a-select-option>
<a-select-option value="header-dns">Header DNS</a-select-option>
<a-select-option value="header-dtls">Header DTLS 1.2</a-select-option>
<a-select-option value="header-srtp">Header SRTP</a-select-option>
<a-select-option value="header-utp">Header uTP</a-select-option>
<a-select-option value="header-wechat">Header WeChat Video</a-select-option>
<a-select-option value="header-wireguard">Header WireGuard</a-select-option>
<a-select-option value="mkcp-original">mKCP Original</a-select-option>
<a-select-option value="xdns">xDNS</a-select-option>
<a-select-option value="xicmp">xICMP</a-select-option>
<a-select-option value="header-custom">Header Custom</a-select-option>
<a-select-option value="noise">Noise</a-select-option>
</template>
</a-select>
</a-form-item>
<a-form-item label="Custom Table">
<a-input
v-model.trim="mask.settings.customTable"
placeholder="Custom Table"
/>
<a-form-item label="Password" v-if="['mkcp-aes128gcm', 'salamander'].includes(mask.type)">
<a-input v-model.trim="mask.settings.password" placeholder="Obfuscation password"></a-input>
</a-form-item>
<a-form-item label="Custom Tables">
<a-input
v-model.trim="mask.settings.customTables"
placeholder="Custom Tables"
/>
<a-form-item label="Domain" v-if="mask.type === 'header-dns'">
<a-input v-model.trim="mask.settings.domain" placeholder="e.g., www.example.com"></a-input>
</a-form-item>
<a-form-item label="Padding Min">
<a-input-number v-model.number="mask.settings.paddingMin" :min="0" />
<template v-if="mask.type === 'xdns'">
<a-form-item label="Domains">
<a-select mode="tags" v-model="mask.settings.domains" :style="{ width: '100%' }" :token-separators="[',']"
placeholder="e.g., www.example.com"></a-select>
</a-form-item>
</template>
<template v-if="mask.type === 'noise'">
<a-form-item label="Reset">
<a-input-number v-model.number="mask.settings.reset" :min="0" />
</a-form-item>
<a-form-item label="Noise">
<a-button icon="plus" type="primary" size="small"
@click="mask.settings.noise.push({rand: '1-8192', randRange: '0-255', type: 'array', packet: [], delay: '10-20'})"></a-button>
</a-form-item>
<template v-for="(n, index) in mask.settings.noise" :key="index">
<a-divider :style="{ margin: '0' }">
Noise [[ index + 1 ]]
<a-icon type="delete" @click="() => mask.settings.noise.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="n.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') n.packet = RandomUtil.randomBase64(); else if(t === 'array') n.packet = []; else n.packet = ''; }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="n.type === 'array'">
<a-form-item label="Rand">
<a-input v-model.trim="n.rand" placeholder="0 or 1-8192" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="n.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="n.type === 'base64'">
<a-input v-model.trim="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="n.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="n.packet" placeholder="binary data" />
</a-form-item>
<a-form-item label="Delay">
<a-input v-model.trim="n.delay" placeholder="10-20" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button icon="plus" type="primary" size="small"
@click="mask.settings.client.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"></a-button>
</a-form-item>
<template v-for="(c, index) in mask.settings.client" :key="index">
<a-divider :style="{ margin: '0' }">
Client [[ index + 1 ]]
<a-icon type="delete" @click="mask.settings.client.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="c.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') c.packet = RandomUtil.randomBase64(); else if(t === 'array') c.packet = []; else c.packet = ''; }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="c.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="c.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="c.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="c.type === 'base64'">
<a-input v-model.trim="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="c.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="c.packet" placeholder="binary data" />
</a-form-item>
</template>
<a-divider :style="{ margin: '0' }"></a-divider>
<a-form-item label="Server">
<a-button icon="plus" type="primary" size="small"
@click="mask.settings.server.push({rand: 0, randRange: '0-255', type: 'array', packet: []})"></a-button>
</a-form-item>
<template v-for="(s, index) in mask.settings.server" :key="index">
<a-divider :style="{ margin: '0' }">
Server [[ index + 1 ]]
<a-icon type="delete" @click="mask.settings.server.splice(index, 1)"
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
</a-divider>
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="s.type" :dropdown-class-name="themeSwitcher.currentTheme"
@change="t => { if(t === 'base64') s.packet = RandomUtil.randomBase64(); else if(t === 'array') s.packet = []; else s.packet = ''; }">
<a-select-option value="array">Array</a-select-option>
<a-select-option value="str">String</a-select-option>
<a-select-option value="hex">Hex</a-select-option>
<a-select-option value="base64">Base64</a-select-option>
</a-select>
</a-form-item>
<template v-if="s.type === 'array'">
<a-form-item label="Rand">
<a-input-number v-model.number="s.rand" />
</a-form-item>
<a-form-item label="Rand Range">
<a-input v-model.trim="s.randRange" placeholder="0-255" />
</a-form-item>
</template>
<a-form-item v-else label="Packet">
<a-input-group compact v-if="s.type === 'base64'">
<a-input v-model.trim="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button icon="reload" @click="s.packet = RandomUtil.randomBase64()" />
</a-input-group>
<a-input v-else v-model.trim="s.packet" placeholder="binary data" />
</a-form-item>
</template>
</template>
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
<a-input v-model.trim="mask.settings.ip" placeholder="0.0.0.0" />
</a-form-item>
<a-form-item label="ID">
<a-input-number v-model.number="mask.settings.id" :min="0" />
</a-form-item>
</template>
</a-form>
</template>
</template>
<!-- quicParams only for xhttp H3 and hysteria -->
<template v-if="inbound.protocol == Protocols.HYSTERIA || inbound.stream.network == 'xhttp'">
<a-form-item label="QUIC Params">
<a-switch v-model="inbound.stream.finalmask.enableQuicParams"></a-switch>
</a-form-item>
<template v-if="inbound.stream.finalmask.enableQuicParams">
<a-form-item label="Congestion">
<a-select v-model="inbound.stream.finalmask.quicParams.congestion"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="reno">Reno</a-select-option>
<a-select-option value="bbr">BBR</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
<a-select-option value="force-brutal">Force Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Debug">
<a-switch v-model="inbound.stream.finalmask.quicParams.debug"></a-switch>
</a-form-item>
<template v-if="['brutal','force-brutal'].includes(inbound.stream.finalmask.quicParams.congestion)">
<a-form-item label="Brutal Up">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalUp" :min="65537" placeholder="65537" />
</a-form-item>
<a-form-item label="Padding Max">
<a-input-number v-model.number="mask.settings.paddingMax" :min="0" />
<a-form-item label="Brutal Down">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.brutalDown" :min="65537" placeholder="65537" />
</a-form-item>
</template>
<template v-if="mask.type === 'xicmp'">
<a-form-item label="IP">
<a-input v-model.trim="mask.settings.ip" placeholder="0.0.0.0" />
<a-form-item label="UDP Hop">
<a-switch v-model="inbound.stream.finalmask.quicParams.hasUdpHop"></a-switch>
</a-form-item>
<template v-if="inbound.stream.finalmask.quicParams.hasUdpHop">
<a-form-item label="Hop Ports">
<a-input v-model.trim="inbound.stream.finalmask.quicParams.udpHop.ports" placeholder="e.g. 20000-50000" />
</a-form-item>
<a-form-item label="ID">
<a-input-number v-model.number="mask.settings.id" :min="0" />
<a-form-item label="Hop Interval (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.udpHop.interval" :min="5" />
</a-form-item>
</template>
</a-form>
<a-form-item label="Max Idle Timeout (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep Alive Period (s)">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.keepAlivePeriod" :min="2" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU Dis">
<a-switch v-model="inbound.stream.finalmask.quicParams.disablePathMTUDiscovery"></a-switch>
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxIncomingStreams" :min="8"
placeholder="1024 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number v-model.number="inbound.stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
</template>
</template>
</a-form>
{{end}}
{{end}}

View File

@@ -1,9 +1,5 @@
{{define "form/streamGRPC"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Service Name">
<a-input v-model.trim="inbound.stream.grpc.serviceName"></a-input>
</a-form-item>
@@ -14,4 +10,4 @@
<a-switch v-model="inbound.stream.grpc.multiMode"></a-switch>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,13 +1,7 @@
{{define "form/streamHTTPUpgrade"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Proxy Protocol">
<a-switch
v-model="inbound.stream.httpupgrade.acceptProxyProtocol"
></a-switch>
<a-switch v-model="inbound.stream.httpupgrade.acceptProxyProtocol"></a-switch>
</a-form-item>
<a-form-item label='{{ i18n "host" }}'>
<a-input v-model.trim="inbound.stream.httpupgrade.host"></a-input>
@@ -16,39 +10,20 @@
<a-input v-model.trim="inbound.stream.httpupgrade.path"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button
icon="plus"
size="small"
@click="inbound.stream.httpupgrade.addHeader('', '')"
></a-button>
<a-button icon="plus" size="small" @click="inbound.stream.httpupgrade.addHeader('', '')"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group
compact
v-for="(header, index) in inbound.stream.httpupgrade.headers"
>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(header, index) in inbound.stream.httpupgrade.headers">
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-button
icon="minus"
slot="addonAfter"
size="small"
@click="inbound.stream.httpupgrade.removeHeader(index)"
></a-button>
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small"
@click="inbound.stream.httpupgrade.removeHeader(index)"></a-button>
</a-input>
</a-input-group>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,14 +1,7 @@
{{define "form/streamHysteria"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="UDP Idle Timeout">
<a-input-number
v-model.number="inbound.stream.hysteria.udpIdleTimeout"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.hysteria.udpIdleTimeout" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="Masquerade">
<a-switch v-model="inbound.stream.hysteria.masqueradeSwitch"></a-switch>
@@ -16,85 +9,50 @@
<template v-if="inbound.stream.hysteria.masqueradeSwitch">
<a-divider :style="{ margin: '5px 0 0' }">Masquerade</a-divider>
<a-form-item label="Type">
<a-select
v-model="inbound.stream.hysteria.masquerade.type"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.hysteria.masquerade.type" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="file">File</a-select-option>
<a-select-option value="proxy">Proxy</a-select-option>
<a-select-option value="string">String</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Dir"
v-if="inbound.stream.hysteria.masquerade.type === 'file'"
>
<a-form-item label="Dir" v-if="inbound.stream.hysteria.masquerade.type === 'file'">
<a-input v-model.trim="inbound.stream.hysteria.masquerade.dir"></a-input>
</a-form-item>
<template v-if="inbound.stream.hysteria.masquerade.type === 'proxy'">
<a-form-item label="URL">
<a-input
v-model.trim="inbound.stream.hysteria.masquerade.url"
></a-input>
<a-input v-model.trim="inbound.stream.hysteria.masquerade.url"></a-input>
</a-form-item>
<a-form-item label="Rewrite Host">
<a-switch
v-model="inbound.stream.hysteria.masquerade.rewriteHost"
></a-switch>
<a-switch v-model="inbound.stream.hysteria.masquerade.rewriteHost"></a-switch>
</a-form-item>
<a-form-item label="Insecure">
<a-switch
v-model="inbound.stream.hysteria.masquerade.insecure"
></a-switch>
<a-switch v-model="inbound.stream.hysteria.masquerade.insecure"></a-switch>
</a-form-item>
</template>
<template v-if="inbound.stream.hysteria.masquerade.type === 'string'">
<a-form-item label="Content">
<a-input
v-model.trim="inbound.stream.hysteria.masquerade.content"
></a-input>
<a-input v-model.trim="inbound.stream.hysteria.masquerade.content"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button
size="small"
@click="inbound.stream.hysteria.masquerade.addHeader('', '')"
>+</a-button
>
<a-button size="small" @click="inbound.stream.hysteria.masquerade.addHeader('', '')">+</a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group
compact
v-for="(header, index) in inbound.stream.hysteria.masquerade.headers"
>
<a-input
style="width: 50%"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
>
<template slot="addonBefore" style="margin: 0"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(header, index) in inbound.stream.hysteria.masquerade.headers">
<a-input style="width: 50%" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
<template slot="addonBefore" style="margin: 0">[[ index+1 ]]</template>
</a-input>
<a-input
style="width: 50%"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-button
slot="addonAfter"
size="small"
@click="inbound.stream.hysteria.masquerade.removeHeader(index)"
>-</a-button
>
<a-input style="width: 50%" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button slot="addonAfter" size="small"
@click="inbound.stream.hysteria.masquerade.removeHeader(index)">-</a-button>
</a-input>
</a-input-group>
</a-form-item>
<a-form-item label="Status Code">
<a-input-number
v-model.number="inbound.stream.hysteria.masquerade.statusCode"
></a-input-number>
<a-input-number v-model.number="inbound.stream.hysteria.masquerade.statusCode"></a-input-number>
</a-form-item>
</template>
</template>
</a-form>
{{end}}
{{end}}

View File

@@ -1,46 +1,22 @@
{{define "form/streamKCP"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="MTU">
<a-input-number
v-model.number="inbound.stream.kcp.mtu"
:min="576"
:max="1460"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.mtu" :min="576" :max="1460"></a-input-number>
</a-form-item>
<a-form-item label="TTI (ms)">
<a-input-number
v-model.number="inbound.stream.kcp.tti"
:min="10"
:max="100"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.tti" :min="10" :max="100"></a-input-number>
</a-form-item>
<a-form-item label="Uplink (MB/s)">
<a-input-number
v-model.number="inbound.stream.kcp.upCap"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.upCap" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="Downlink (MB/s)">
<a-input-number
v-model.number="inbound.stream.kcp.downCap"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.downCap" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="CWND Multiplier">
<a-input-number
v-model.number="inbound.stream.kcp.cwndMultiplier"
:min="1"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.cwndMultiplier" :min="1"></a-input-number>
</a-form-item>
<a-form-item label="Max Sending Window">
<a-input-number
v-model.number="inbound.stream.kcp.maxSendingWindow"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.kcp.maxSendingWindow" :min="0"></a-input-number>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,18 +1,10 @@
{{define "form/streamSettings"}}
<!-- select stream network -->
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
v-if="inbound.protocol != Protocols.HYSTERIA"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }"
v-if="inbound.protocol != Protocols.HYSTERIA">
<a-form-item label='{{ i18n "transmission" }}'>
<a-select
v-model="inbound.stream.network"
:style="{ width: '75%' }"
@change="streamNetworkChange"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.network" :style="{ width: '75%' }" @change="streamNetworkChange"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="tcp">TCP (RAW)</a-select-option>
<a-select-option value="kcp">mKCP</a-select-option>
<a-select-option value="ws">WebSocket</a-select-option>
@@ -25,42 +17,42 @@
<!-- tcp -->
<template v-if="inbound.stream.network === 'tcp'">
{{template "form/streamTCP"}}
{{template "form/streamTCP" .}}
</template>
<!-- kcp -->
<template v-if="inbound.stream.network === 'kcp'">
{{template "form/streamKCP"}}
{{template "form/streamKCP" .}}
</template>
<!-- ws -->
<template v-if="inbound.stream.network === 'ws'">
{{template "form/streamWS"}}
{{template "form/streamWS" .}}
</template>
<!-- grpc -->
<template v-if="inbound.stream.network === 'grpc'">
{{template "form/streamGRPC"}}
{{template "form/streamGRPC" .}}
</template>
<!-- hysteria -->
<template v-if="inbound.stream.network === 'hysteria'">
{{template "form/streamHysteria"}}
{{template "form/streamHysteria" .}}
</template>
<!-- httpupgrade -->
<template v-if="inbound.stream.network === 'httpupgrade'">
{{template "form/streamHTTPUpgrade"}}
{{template "form/streamHTTPUpgrade" .}}
</template>
<!-- xhttp -->
<template v-if="inbound.stream.network === 'xhttp'">
{{template "form/streamXHTTP"}}
{{template "form/streamXHTTP" .}}
</template>
<!-- sockopt -->
<template> {{template "form/streamSockopt"}} </template>
<template> {{template "form/streamSockopt" .}} </template>
<!-- finalmask -->
<template> {{template "form/streamFinalMask"}} </template>
{{end}}
<template> {{template "form/streamFinalMask" .}} </template>
{{end}}

View File

@@ -1,49 +1,27 @@
{{define "form/streamSockopt"}}
<a-divider :style="{ margin: '5px 0 0' }"></a-divider>
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Sockopt">
<a-switch v-model="inbound.stream.sockoptSwitch"></a-switch>
</a-form-item>
<template v-if="inbound.stream.sockoptSwitch">
<a-form-item label="Route Mark">
<a-input-number
v-model.number="inbound.stream.sockopt.mark"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.mark" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="TCP Keep Alive Interval">
<a-input-number
v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveInterval" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="TCP Keep Alive Idle">
<a-input-number
v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.tcpKeepAliveIdle" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="TCP Max Seg">
<a-input-number
v-model.number="inbound.stream.sockopt.tcpMaxSeg"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.tcpMaxSeg" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="TCP User Timeout">
<a-input-number
v-model.number="inbound.stream.sockopt.tcpUserTimeout"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.tcpUserTimeout" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="TCP Window Clamp">
<a-input-number
v-model.number="inbound.stream.sockopt.tcpWindowClamp"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.sockopt.tcpWindowClamp" :min="0"></a-input-number>
</a-form-item>
<a-form-item label="Proxy Protocol">
<a-switch v-model="inbound.stream.sockopt.acceptProxyProtocol"></a-switch>
@@ -61,33 +39,20 @@
<a-switch v-model.trim="inbound.stream.sockopt.V6Only"></a-switch>
</a-form-item>
<a-form-item label="Domain Strategy">
<a-select
v-model="inbound.stream.sockopt.domainStrategy"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key"
>[[ key ]]</a-select-option
>
<a-select v-model="inbound.stream.sockopt.domainStrategy" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in DOMAIN_STRATEGY_OPTION" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="TCP Congestion">
<a-select
v-model="inbound.stream.sockopt.tcpcongestion"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key"
>[[ key ]]</a-select-option
>
<a-select v-model="inbound.stream.sockopt.tcpcongestion" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TCP_CONGESTION_OPTION" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="TProxy">
<a-select
v-model="inbound.stream.sockopt.tproxy"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.sockopt.tproxy" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="off">Off</a-select-option>
<a-select-option value="redirect">Redirect</a-select-option>
<a-select-option value="tproxy">TProxy</a-select-option>
@@ -100,15 +65,9 @@
<a-input v-model="inbound.stream.sockopt.interfaceName"></a-input>
</a-form-item>
<a-form-item label="Trusted X-Forwarded-For">
<a-select
mode="tags"
v-model="inbound.stream.sockopt.trustedXForwardedFor"
:style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option value="CF-Connecting-IP"
>CF-Connecting-IP</a-select-option
>
<a-select mode="tags" v-model="inbound.stream.sockopt.trustedXForwardedFor" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="CF-Connecting-IP">CF-Connecting-IP</a-select-option>
<a-select-option value="X-Real-IP">X-Real-IP</a-select-option>
<a-select-option value="True-Client-IP">True-Client-IP</a-select-option>
<a-select-option value="X-Client-IP">X-Client-IP</a-select-option>
@@ -116,4 +75,4 @@
</a-form-item>
</template>
</a-form>
{{end}}
{{end}}

View File

@@ -1,31 +1,19 @@
{{define "form/streamTCP"}}
<!-- tcp type -->
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Proxy Protocol" v-if="inbound.canEnableTls()">
<a-switch v-model="inbound.stream.tcp.acceptProxyProtocol"></a-switch>
</a-form-item>
<a-form-item label='HTTP {{ i18n "camouflage" }}'>
<a-switch
:checked="inbound.stream.tcp.type === 'http'"
@change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"
></a-switch>
<a-switch :checked="inbound.stream.tcp.type === 'http'"
@change="checked => inbound.stream.tcp.type = checked ? 'http' : 'none'"></a-switch>
</a-form-item>
</a-form>
<a-form
v-if="inbound.stream.tcp.type === 'http'"
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form v-if="inbound.stream.tcp.type === 'http'" :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<!-- tcp request -->
<a-divider :style="{ margin: '0' }"
>{{ i18n "pages.inbounds.stream.general.request" }}</a-divider
>
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.request" }}</a-divider>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
<a-input v-model.trim="inbound.stream.tcp.request.version"></a-input>
</a-form-item>
@@ -33,66 +21,35 @@
<a-input v-model.trim="inbound.stream.tcp.request.method"></a-input>
</a-form-item>
<a-form-item>
<template slot="label"
>{{ i18n "pages.inbounds.stream.tcp.path" }}
<a-button
icon="plus"
size="small"
@click="inbound.stream.tcp.request.addPath('/')"
></a-button>
<template slot="label">{{ i18n "pages.inbounds.stream.tcp.path" }}
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addPath('/')"></a-button>
</template>
<template v-for="(path, index) in inbound.stream.tcp.request.path">
<a-input v-model.trim="inbound.stream.tcp.request.path[index]">
<a-button
icon="minus"
size="small"
slot="addonAfter"
@click="inbound.stream.tcp.request.removePath(index)"
v-if="inbound.stream.tcp.request.path.length>1"
></a-button>
<a-button icon="minus" size="small" slot="addonAfter" @click="inbound.stream.tcp.request.removePath(index)"
v-if="inbound.stream.tcp.request.path.length>1"></a-button>
</a-input>
</template>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button
icon="plus"
size="small"
@click="inbound.stream.tcp.request.addHeader('Host', '')"
></a-button>
<a-button icon="plus" size="small" @click="inbound.stream.tcp.request.addHeader('Host', '')"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group
compact
v-for="(header, index) in inbound.stream.tcp.request.headers"
>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.request.headers">
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-button
icon="minus"
slot="addonAfter"
size="small"
@click="inbound.stream.tcp.request.removeHeader(index)"
></a-button>
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small"
@click="inbound.stream.tcp.request.removeHeader(index)"></a-button>
</a-input>
</a-input-group>
</a-form-item>
<!-- tcp response -->
<a-divider :style="{ margin: '0' }"
>{{ i18n "pages.inbounds.stream.general.response" }}</a-divider
>
<a-divider :style="{ margin: '0' }">{{ i18n "pages.inbounds.stream.general.response" }}</a-divider>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.version" }}'>
<a-input v-model.trim="inbound.stream.tcp.response.version"></a-input>
</a-form-item>
@@ -103,40 +60,22 @@
<a-input v-model.trim="inbound.stream.tcp.response.reason"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.responseHeader" }}'>
<a-button
icon="plus"
size="small"
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"
></a-button>
<a-button icon="plus" size="small"
@click="inbound.stream.tcp.response.addHeader('Content-Type', 'application/octet-stream')"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group
compact
v-for="(header, index) in inbound.stream.tcp.response.headers"
>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(header, index) in inbound.stream.tcp.response.headers">
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<template slot="addonAfter">
<a-button
icon="minus"
size="small"
@click="inbound.stream.tcp.response.removeHeader(index)"
></a-button>
<a-button icon="minus" size="small" @click="inbound.stream.tcp.response.removeHeader(index)"></a-button>
</template>
</a-input>
</a-input-group>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,9 +1,5 @@
{{define "form/streamWS"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label="Proxy Protocol">
<a-switch v-model="inbound.stream.ws.acceptProxyProtocol"></a-switch>
</a-form-item>
@@ -14,42 +10,22 @@
<a-input v-model.trim="inbound.stream.ws.path"></a-input>
</a-form-item>
<a-form-item label="Heartbeat Period">
<a-input-number
v-model.number="inbound.stream.ws.heartbeatPeriod"
:min="0"
></a-input-number>
<a-input-number v-model.number="inbound.stream.ws.heartbeatPeriod" :min="0"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button
icon="plus"
size="small"
@click="inbound.stream.ws.addHeader('', '')"
></a-button>
<a-button icon="plus" size="small" @click="inbound.stream.ws.addHeader('', '')"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group compact v-for="(header, index) in inbound.stream.ws.headers">
<a-input
:style="{ width: '50%' }"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-button
icon="minus"
slot="addonAfter"
size="small"
@click="inbound.stream.ws.removeHeader(index)"
></a-button>
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small" @click="inbound.stream.ws.removeHeader(index)"></a-button>
</a-input>
</a-input-group>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,9 +1,5 @@
{{define "form/streamXHTTP"}}
<a-form
:colon="false"
:label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }"
>
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "host" }}'>
<a-input v-model.trim="inbound.stream.xhttp.host"></a-input>
</a-form-item>
@@ -11,69 +7,34 @@
<a-input v-model.trim="inbound.stream.xhttp.path"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
<a-button
icon="plus"
size="small"
@click="inbound.stream.xhttp.addHeader('', '')"
></a-button>
<a-button icon="plus" size="small" @click="inbound.stream.xhttp.addHeader('', '')"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span:24}">
<a-input-group
compact
v-for="(header, index) in inbound.stream.xhttp.headers"
>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'
>
<template slot="addonBefore" :style="{ margin: '0' }"
>[[ index+1 ]]</template
>
<a-input-group compact v-for="(header, index) in inbound.stream.xhttp.headers">
<a-input :style="{ width: '50%' }" v-model.trim="header.name"
placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input
:style="{ width: '50%' }"
v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'
>
<a-button
icon="minus"
slot="addonAfter"
size="small"
@click="inbound.stream.xhttp.removeHeader(index)"
></a-button>
<a-input :style="{ width: '50%' }" v-model.trim="header.value"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small"
@click="inbound.stream.xhttp.removeHeader(index)"></a-button>
</a-input>
</a-input-group>
</a-form-item>
<a-form-item label="Mode">
<a-select
v-model="inbound.stream.xhttp.mode"
:style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select-option v-for="key in MODE_OPTION" :value="key"
>[[ key ]]</a-select-option
>
<a-select v-model="inbound.stream.xhttp.mode" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Max Buffered Upload"
v-if="inbound.stream.xhttp.mode === 'packet-up'"
>
<a-input-number
v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"
></a-input-number>
<a-form-item label="Max Buffered Upload" v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-input-number v-model.number="inbound.stream.xhttp.scMaxBufferedPosts"></a-input-number>
</a-form-item>
<a-form-item
label="Max Upload Size (Byte)"
v-if="inbound.stream.xhttp.mode === 'packet-up'"
>
<a-form-item label="Max Upload Size (Byte)" v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-input v-model.trim="inbound.stream.xhttp.scMaxEachPostBytes"></a-input>
</a-form-item>
<a-form-item
label="Stream-Up Server"
v-if="inbound.stream.xhttp.mode === 'stream-up'"
>
<a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'">
<a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
</a-form-item>
<a-form-item label="Padding Bytes">
@@ -84,22 +45,13 @@
</a-form-item>
<template v-if="inbound.stream.xhttp.xPaddingObfsMode">
<a-form-item label="Padding Key">
<a-input
v-model.trim="inbound.stream.xhttp.xPaddingKey"
placeholder="x_padding"
></a-input>
<a-input v-model.trim="inbound.stream.xhttp.xPaddingKey" placeholder="x_padding"></a-input>
</a-form-item>
<a-form-item label="Padding Header">
<a-input
v-model.trim="inbound.stream.xhttp.xPaddingHeader"
placeholder="X-Padding"
></a-input>
<a-input v-model.trim="inbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding"></a-input>
</a-form-item>
<a-form-item label="Padding Placement">
<a-select
v-model="inbound.stream.xhttp.xPaddingPlacement"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.xhttp.xPaddingPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (queryInHeader)</a-select-option>
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
<a-select-option value="header">header</a-select-option>
@@ -108,10 +60,7 @@
</a-select>
</a-form-item>
<a-form-item label="Padding Method">
<a-select
v-model="inbound.stream.xhttp.xPaddingMethod"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.xhttp.xPaddingMethod" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (repeat-x)</a-select-option>
<a-select-option value="repeat-x">repeat-x</a-select-option>
<a-select-option value="tokenish">tokenish</a-select-option>
@@ -119,10 +68,7 @@
</a-form-item>
</template>
<a-form-item label="Uplink HTTP Method">
<a-select
v-model="inbound.stream.xhttp.uplinkHTTPMethod"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (POST)</a-select-option>
<a-select-option value="POST">POST</a-select-option>
<a-select-option value="PUT">PUT</a-select-option>
@@ -130,10 +76,7 @@
</a-select>
</a-form-item>
<a-form-item label="Session Placement">
<a-select
v-model="inbound.stream.xhttp.sessionPlacement"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
@@ -141,20 +84,12 @@
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Session Key"
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'"
>
<a-input
v-model.trim="inbound.stream.xhttp.sessionKey"
placeholder="x_session"
></a-input>
<a-form-item label="Session Key"
v-if="inbound.stream.xhttp.sessionPlacement && inbound.stream.xhttp.sessionPlacement !== 'path'">
<a-input v-model.trim="inbound.stream.xhttp.sessionKey" placeholder="x_session"></a-input>
</a-form-item>
<a-form-item label="Sequence Placement">
<a-select
v-model="inbound.stream.xhttp.seqPlacement"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-select v-model="inbound.stream.xhttp.seqPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
@@ -162,23 +97,12 @@
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Sequence Key"
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'"
>
<a-input
v-model.trim="inbound.stream.xhttp.seqKey"
placeholder="x_seq"
></a-input>
<a-form-item label="Sequence Key"
v-if="inbound.stream.xhttp.seqPlacement && inbound.stream.xhttp.seqPlacement !== 'path'">
<a-input v-model.trim="inbound.stream.xhttp.seqKey" placeholder="x_seq"></a-input>
</a-form-item>
<a-form-item
label="Uplink Data Placement"
v-if="inbound.stream.xhttp.mode === 'packet-up'"
>
<a-select
v-model="inbound.stream.xhttp.uplinkDataPlacement"
:dropdown-class-name="themeSwitcher.currentTheme"
>
<a-form-item label="Uplink Data Placement" v-if="inbound.stream.xhttp.mode === 'packet-up'">
<a-select v-model="inbound.stream.xhttp.uplinkDataPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Default (body)</a-select-option>
<a-select-option value="body">body</a-select-option>
<a-select-option value="header">header</a-select-option>
@@ -186,27 +110,17 @@
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item
label="Uplink Data Key"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
>
<a-input
v-model.trim="inbound.stream.xhttp.uplinkDataKey"
placeholder="x_data"
></a-input>
<a-form-item label="Uplink Data Key"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
<a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
</a-form-item>
<a-form-item
label="Uplink Chunk Size"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'"
>
<a-input-number
v-model.number="inbound.stream.xhttp.uplinkChunkSize"
:min="0"
placeholder="0 (unlimited)"
></a-input-number>
<a-form-item label="Uplink Chunk Size"
v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
<a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize" :min="0"
placeholder="0 (unlimited)"></a-input-number>
</a-form-item>
<a-form-item label="No SSE Header">
<a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
</a-form-item>
</a-form>
{{end}}
{{end}}

View File

@@ -1,14 +1,12 @@
{{define "form/tlsSettings"}}
<!-- tls enable -->
<a-form v-if="inbound.canEnableTls()" :colon="false"
:label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form v-if="inbound.canEnableTls()" :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<template v-if="inbound.protocol !== Protocols.HYSTERIA">
<a-divider :style="{ margin: '3px 0' }"></a-divider>
<a-form-item label='{{ i18n "security" }}'>
<a-radio-group v-model="inbound.stream.security" button-style="solid">
<a-radio-button value="none">{{ i18n "none" }}</a-radio-button>
<a-radio-button v-if="inbound.canEnableReality()"
value="reality">Reality</a-radio-button>
<a-radio-button v-if="inbound.canEnableReality()" value="reality">Reality</a-radio-button>
<a-radio-button value="tls">TLS</a-radio-button>
</a-radio-group>
</a-form-item>
@@ -21,8 +19,7 @@
<a-input v-model.trim="inbound.stream.tls.sni"></a-input>
</a-form-item>
<a-form-item label="Cipher Suites">
<a-select v-model="inbound.stream.tls.cipherSuites"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select v-model="inbound.stream.tls.cipherSuites" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>Auto</a-select-option>
<a-select-option v-for="key,value in TLS_CIPHER_OPTION" :value="key">[[
value ]]</a-select-option>
@@ -30,14 +27,12 @@
</a-form-item>
<a-form-item label="Min/Max Version">
<a-input-group compact>
<a-select v-model="inbound.stream.tls.minVersion"
:style="{ width: '50%' }"
<a-select v-model="inbound.stream.tls.minVersion" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select>
<a-select v-model="inbound.stream.tls.maxVersion"
:style="{ width: '50%' }"
<a-select v-model="inbound.stream.tls.maxVersion" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in TLS_VERSION_OPTION" :value="key">[[ key
]]</a-select-option>
@@ -45,8 +40,7 @@
</a-input-group>
</a-form-item>
<a-form-item label="uTLS">
<a-select v-model="inbound.stream.tls.settings.fingerprint"
:style="{ width: '100%' }"
<a-select v-model="inbound.stream.tls.settings.fingerprint" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value>None</a-select-option>
<a-select-option v-for="key in UTLS_FINGERPRINT" :value="key">[[ key
@@ -54,9 +48,7 @@
</a-select>
</a-form-item>
<a-form-item label="ALPN">
<a-select mode="multiple"
:dropdown-class-name="themeSwitcher.currentTheme"
v-model="inbound.stream.tls.alpn">
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" v-model="inbound.stream.tls.alpn">
<a-select-option v-for="alpn in ALPN_OPTION" :value="alpn">[[ alpn
]]</a-select-option>
</a-select>
@@ -75,8 +67,7 @@
<a-form-item label='{{ i18n "certificate" }}'>
<a-radio-group v-model="cert.useFile" button-style="solid"
:style="{ display: 'inline-flex', whiteSpace: 'nowrap', maxWidth: '100%' }">
<a-radio-button :value="true"
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
<a-radio-button :value="true" :style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
i18n "pages.inbounds.certificatePath" }}</a-radio-button>
<a-radio-button :value="false"
:style="{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }">{{
@@ -87,8 +78,7 @@
<a-space>
<a-button icon="plus" v-if="index === 0" type="primary" size="small"
@click="inbound.stream.tls.addCert()"></a-button>
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1"
type="primary" size="small"
<a-button icon="minus" v-if="inbound.stream.tls.certs.length>1" type="primary" size="small"
@click="inbound.stream.tls.removeCert(index)"></a-button>
</a-space>
</a-form-item>
@@ -100,8 +90,7 @@
<a-input v-model.trim="cert.keyFile"></a-input>
</a-form-item>
<a-form-item label=" ">
<a-button type="primary" icon="import"
@click="setDefaultCertData(index)">
<a-button type="primary" icon="import" @click="setDefaultCertData(index)">
{{ i18n "pages.inbounds.setDefaultCert" }}</a-button>
</a-form-item>
</template>
@@ -117,8 +106,7 @@
<a-switch v-model="cert.oneTimeLoading"></a-switch>
</a-form-item>
<a-form-item label='Usage Option'>
<a-select v-model="cert.usage" :style="{ width: '50%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select v-model="cert.usage" :style="{ width: '50%' }" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in USAGE_OPTION" :value="key">[[ key
]]</a-select-option>
</a-select>
@@ -133,13 +121,6 @@
<a-form-item label='ECH config'>
<a-input v-model="inbound.stream.tls.settings.echConfigList"></a-input>
</a-form-item>
<a-form-item label='ECH force query'>
<a-select v-model="inbound.stream.tls.echForceQuery"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in ['none', 'half', 'full']" :value="key">[[
key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label=" ">
<a-space>
<a-button type="primary" icon="import" @click="getNewEchCert">Get New
@@ -151,7 +132,7 @@
<!-- reality settings -->
<template v-if="inbound.stream.isReality">
{{template "form/realitySettings"}}
{{template "form/realitySettings" .}}
</template>
</a-form>
{{end}}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@
padding: 2px 6px;
border-radius: 3px;
}
html[data-theme="ultra-dark"] body.dark .custom-geo-section code.custom-geo-ext-code {
color: var(--dark-color-text-primary, rgba(255, 255, 255, 0.88));
background: var(--dark-color-surface-700, #111929);
@@ -119,7 +120,8 @@
</a-row>
</span>
<template slot="content">
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line ]]</span>
<span class="max-w-400" v-for="line in (status.xray.errorMsg || '').split('\n')">[[ line
]]</span>
</template>
<a-badge :text="status.xray.stateMsg" :color="status.xray.color"
:class="status.xray.color === 'red' ? 'xray-error-animation' : ''" />
@@ -139,7 +141,7 @@
<a-icon type="reload"></a-icon>
<span v-if="!isMobile">{{ i18n "pages.index.restartXray" }}</span>
</a-space>
<a-space direction="horizontal" @click="openSelectV2rayVersion" class="jc-center">
<a-space direction="horizontal" @click="openSelectV2rayVersion()" class="jc-center">
<a-icon type="tool"></a-icon>
<span v-if="!isMobile">
[[ status.xray.version != 'Unknown' ? `v${status.xray.version}` : '{{ i18n
@@ -169,6 +171,15 @@
</a-col>
<a-col :sm="24" :lg="12">
<a-card title='3X-UI' hoverable>
<template v-if="panelUpdateModal.info.updateAvailable" #extra>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme"
:title='`{{ i18n "pages.index.updatePanel" }}: ${panelUpdateModal.info.latestVersion}`'>
<a-tag color="orange" style="cursor:pointer;margin:0" @click="openPanelUpdate">
<a-icon type="cloud-download"></a-icon>[[ panelUpdateModal.info.latestVersion ]]
<span v-if="!isMobile">{{ i18n "pages.index.updatePanel" }}</span>
</a-tag>
</a-tooltip>
</template>
<a rel="noopener" href="https://github.com/MHSanaei/3x-ui/releases" target="_blank">
<a-tag color="green">
<span>v{{ .cur_ver }}</span>
@@ -317,9 +328,35 @@
</a-spin>
</a-layout-content>
</a-layout>
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xraySwitch" }}'
<a-modal id="panel-update-modal" v-model="panelUpdateModal.visible" title='{{ i18n "pages.index.updatePanel" }}'
:closable="true" @ok="() => panelUpdateModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.panelUpdateDesc" }}' show-icon></a-alert>
<a-list class="ant-version-list w-100" bordered>
<a-list-item class="ant-version-list-item">
<span>{{ i18n "pages.index.currentPanelVersion" }}</span>
<a-tag color="green">v[[ panelUpdateModal.info.currentVersion || '{{ .cur_ver }}' ]]</a-tag>
</a-list-item>
<a-list-item class="ant-version-list-item" v-if="panelUpdateModal.info.updateAvailable">
<span>{{ i18n "pages.index.latestPanelVersion" }}</span>
<a-tag color="purple">
[[ panelUpdateModal.info.latestVersion || '-' ]]
</a-tag>
</a-list-item>
<a-list-item class="ant-version-list-item" v-else>
<span>{{ i18n "pages.index.panelUpToDate" }}</span>
<a-tag color="green">{{ i18n "pages.index.upToDate" }}</a-tag>
</a-list-item>
</a-list>
<div class="mt-5 d-flex justify-end">
<a-button type="primary" icon="cloud-download" :disabled="!panelUpdateModal.info.updateAvailable"
@click="updatePanel">
{{ i18n "pages.index.updatePanel" }}
</a-button>
</div>
</a-modal>
<a-modal id="version-modal" v-model="versionModal.visible" title='{{ i18n "pages.index.xrayUpdates" }}'
:closable="true" @ok="() => versionModal.visible = false" :class="themeSwitcher.currentTheme" footer="">
<a-collapse default-active-key="1">
<a-collapse accordion :active-key="versionModal.activeKey" @change="key => versionModal.activeKey = key">
<a-collapse-panel key="1" header='Xray'>
<a-alert type="warning" class="mb-12 w-100" message='{{ i18n "pages.index.xraySwitchClickDesk" }}'
show-icon></a-alert>
@@ -344,57 +381,62 @@
</a-collapse-panel>
<a-collapse-panel key="3" header='{{ i18n "pages.index.customGeoTitle" }}'>
<div class="custom-geo-section">
<a-alert type="info" show-icon class="mb-10"
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
<div class="mb-10">
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
{{ i18n "pages.index.customGeoAdd" }}
</a-button>
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
<a-alert type="info" show-icon class="mb-10"
message='{{ i18n "pages.index.customGeoRoutingHint" }}'></a-alert>
<div class="mb-10">
<a-button type="primary" icon="plus" @click="openCustomGeoModal(null)" :loading="customGeoLoading">
{{ i18n "pages.index.customGeoAdd" }}
</a-button>
<a-button class="ml-8" icon="reload" @click="updateAllCustomGeo" :loading="customGeoUpdatingAll">{{ i18n
"pages.index.geofilesUpdateAll" }}</a-button>
</div>
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
<template slot="extDat" slot-scope="text, record">
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
</template>
<template slot="lastUpdatedAt" slot-scope="text, record">
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
<span v-else></span>
</template>
<template slot="action" slot-scope="text, record">
<a-space size="small">
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)" :loading="customGeoActionId === record.id"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
</a-tooltip>
</a-space>
</template>
</a-table>
</div>
<a-table :columns="customGeoColumns" :data-source="customGeoList" :pagination="false" :row-key="r => r.id"
:loading="customGeoLoading" size="small" :scroll="{ x: 520 }">
<template slot="extDat" slot-scope="text, record">
<code class="custom-geo-ext-code">[[ customGeoExtDisplay(record) ]]</code>
</template>
<template slot="lastUpdatedAt" slot-scope="text, record">
<span v-if="record.lastUpdatedAt">[[ customGeoFormatTime(record.lastUpdatedAt) ]]</span>
<span v-else></span>
</template>
<template slot="action" slot-scope="text, record">
<a-space size="small">
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoEdit" }}</template>
<a-button type="link" size="small" icon="edit" @click="openCustomGeoModal(record)"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDownload" }}</template>
<a-button type="link" size="small" icon="reload" @click="downloadCustomGeo(record.id)"
:loading="customGeoActionId === record.id"></a-button>
</a-tooltip>
<a-tooltip :overlay-class-name="themeSwitcher.currentTheme">
<template slot="title">{{ i18n "pages.index.customGeoDelete" }}</template>
<a-button type="link" size="small" icon="delete" @click="confirmDeleteCustomGeo(record)"></a-button>
</a-tooltip>
</a-space>
</template>
</a-table>
</div>
</a-collapse-panel>
</a-collapse>
</a-modal>
<a-modal v-model="customGeoModal.visible" :title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo" :ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
<a-modal v-model="customGeoModal.visible"
:title="customGeoModal.editId ? '{{ i18n "pages.index.customGeoModalEdit" }}' : '{{ i18n "pages.index.customGeoModalAdd" }}'"
:confirm-loading="customGeoModal.saving" @ok="submitCustomGeo"
:ok-text="'{{ i18n "pages.index.customGeoModalSave" }}'" :cancel-text="'{{ i18n "close" }}'"
:class="themeSwitcher.currentTheme">
<a-form layout="vertical">
<a-form-item label='{{ i18n "pages.index.customGeoType" }}'>
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select v-model="customGeoModal.form.type" :disabled="!!customGeoModal.editId"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value="geosite">geosite</a-select-option>
<a-select-option value="geoip">geoip</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.index.customGeoAlias" }}'>
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId" placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
<a-input v-model.trim="customGeoModal.form.alias" :disabled="!!customGeoModal.editId"
placeholder='{{ i18n "pages.index.customGeoAliasPlaceholder" }}'></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.index.customGeoUrl" }}'>
<a-input v-model.trim="customGeoModal.form.url" placeholder="https://"></a-input>
@@ -434,7 +476,8 @@
<a-checkbox v-model="logModal.syslog" @change="openLogs()">SysLog</a-checkbox>
</a-form-item>
<a-form-item style="float: right;">
<a-button type="primary" icon="download" @click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
<a-button type="primary" icon="download"
@click="FileManager.downloadTextFile(logModal.logs?.join('\n'), 'x-ui.log')"></a-button>
</a-form-item>
</a-form>
<div class="ant-input log-container" v-html="logModal.formattedLogs"></div>
@@ -512,7 +555,8 @@
<sparkline :data="cpuHistoryLong" :labels="cpuHistoryLabels" :vb-width="840" :height="220"
:stroke="status.cpu.color" :stroke-width="2.2" :show-grid="true" :show-axes="true" :tick-count-x="5"
:max-points="cpuHistoryLong.length" :fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true" />
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point (total [[ cpuHistoryLong.length ]] points)</div>
<div style="margin-top:4px;font-size:11px;opacity:0.65">Timeframe: [[ cpuHistoryModal.bucket ]] sec per point
(total [[ cpuHistoryLong.length ]] points)</div>
</div>
</a-modal>
</a-layout>
@@ -520,33 +564,93 @@
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aCustomStatistic" .}}
{{template "modals/textModal"}}
{{template "modals/textModal" .}}
<script>
// Tiny Sparkline component using an inline SVG polyline
Vue.component('sparkline', {
props: {
data: { type: Array, required: true },
data: {
type: Array,
required: true
},
// viewBox width for drawing space; SVG width will be 100% of container
vbWidth: { type: Number, default: 320 },
height: { type: Number, default: 80 },
stroke: { type: String, default: '#008771' },
strokeWidth: { type: Number, default: 2 },
maxPoints: { type: Number, default: 120 },
showGrid: { type: Boolean, default: true },
gridColor: { type: String, default: 'rgba(0,0,0,0.1)' },
fillOpacity: { type: Number, default: 0.15 },
showMarker: { type: Boolean, default: true },
markerRadius: { type: Number, default: 2.8 },
vbWidth: {
type: Number,
default: 320
},
height: {
type: Number,
default: 80
},
stroke: {
type: String,
default: '#008771'
},
strokeWidth: {
type: Number,
default: 2
},
maxPoints: {
type: Number,
default: 120
},
showGrid: {
type: Boolean,
default: true
},
gridColor: {
type: String,
default: 'rgba(0,0,0,0.1)'
},
fillOpacity: {
type: Number,
default: 0.15
},
showMarker: {
type: Boolean,
default: true
},
markerRadius: {
type: Number,
default: 2.8
},
// New opts for axes/labels/tooltip
labels: { type: Array, default: () => [] }, // same length as data for x labels (e.g., timestamps)
showAxes: { type: Boolean, default: false },
yTickStep: { type: Number, default: 25 }, // percent ticks
tickCountX: { type: Number, default: 4 },
paddingLeft: { type: Number, default: 32 },
paddingRight: { type: Number, default: 6 },
paddingTop: { type: Number, default: 6 },
paddingBottom: { type: Number, default: 20 },
showTooltip: { type: Boolean, default: false },
labels: {
type: Array,
default: () => []
}, // same length as data for x labels (e.g., timestamps)
showAxes: {
type: Boolean,
default: false
},
yTickStep: {
type: Number,
default: 25
}, // percent ticks
tickCountX: {
type: Number,
default: 4
},
paddingLeft: {
type: Number,
default: 32
},
paddingRight: {
type: Number,
default: 6
},
paddingTop: {
type: Number,
default: 6
},
paddingBottom: {
type: Number,
default: 20
},
showTooltip: {
type: Boolean,
default: false
},
},
data() {
return {
@@ -609,7 +713,12 @@
// draw at 25%, 50%, 75%
return [0, 0.25, 0.5, 0.75, 1]
.map(r => Math.round(this.paddingTop + h * r))
.map(y => ({ x1: this.paddingLeft, y1: y, x2: this.paddingLeft + w, y2: y }))
.map(y => ({
x1: this.paddingLeft,
y1: y,
x2: this.paddingLeft + w,
y2: y
}))
},
lastPoint() {
if (this.pointsArr.length === 0) return null
@@ -621,7 +730,10 @@
const ticks = []
for (let p = 0; p <= 100; p += step) {
const y = Math.round(this.paddingTop + (this.drawHeight - (p / 100) * this.drawHeight))
ticks.push({ y, label: `${p}%` })
ticks.push({
y,
label: `${p}%`
})
}
return ticks
},
@@ -642,7 +754,10 @@
positions.forEach(idx => {
const label = labels[idx] != null ? String(labels[idx]) : String(idx)
const x = Math.round(this.paddingLeft + idx * dx)
ticks.push({ x, label })
ticks.push({
x,
label
})
})
return ticks
},
@@ -743,17 +858,36 @@
this.disk = new CurTotal(0, 0);
this.loads = [0, 0, 0];
this.mem = new CurTotal(0, 0);
this.netIO = { up: 0, down: 0 };
this.netTraffic = { sent: 0, recv: 0 };
this.publicIP = { ipv4: 0, ipv6: 0 };
this.netIO = {
up: 0,
down: 0
};
this.netTraffic = {
sent: 0,
recv: 0
};
this.publicIP = {
ipv4: 0,
ipv6: 0
};
this.swap = new CurTotal(0, 0);
this.tcpCount = 0;
this.udpCount = 0;
this.uptime = 0;
this.appUptime = 0;
this.appStats = { threads: 0, mem: 0, uptime: 0 };
this.appStats = {
threads: 0,
mem: 0,
uptime: 0
};
this.xray = { state: 'stop', stateMsg: "", errorMsg: "", version: "", color: "" };
this.xray = {
state: 'stop',
stateMsg: "",
errorMsg: "",
version: "",
color: ""
};
if (data == null) {
return;
@@ -799,9 +933,11 @@
const versionModal = {
visible: false,
activeKey: '1',
versions: [],
show(versions) {
show(versions, activeKey = '1') {
this.visible = true;
this.activeKey = activeKey;
this.versions = versions;
},
hide() {
@@ -809,6 +945,36 @@
},
};
const panelUpdateModal = {
visible: false,
info: {
currentVersion: '{{ .cur_ver }}',
latestVersion: '',
updateAvailable: false,
},
show(info) {
this.visible = true;
if (info) {
this.info = info;
}
},
hide() {
this.visible = false;
},
};
const escapeHtml = (value) => {
if (value === null || value === undefined) {
return '';
}
return String(value)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
const logModal = {
visible: false,
logs: [],
@@ -832,24 +998,28 @@
if (index > 0) formattedLogs += '<br>';
if (parts.length === 3) {
const d = parts[0];
const t = parts[1];
const level = parts[2];
const levelIndex = levels.indexOf(level, levels) || 5;
const d = escapeHtml(parts[0]);
const t = escapeHtml(parts[1]);
const levelRaw = parts[2];
const level = escapeHtml(levelRaw);
const idx = levels.indexOf(levelRaw);
const levelIndex = idx >= 0 ? idx : 5;
//formattedLogs += `<span style="color: gray;">${index + 1}.</span>`;
formattedLogs += `<span style="color: ${levelColors[0]};">${d} ${t}</span> `;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${level}</span>`;
} else {
const levelIndex = levels.indexOf(data, levels) || 5;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${data}</span>`;
const idx = levels.indexOf(data);
const levelIndex = idx >= 0 ? idx : 5;
formattedLogs += `<span style="color: ${levelColors[levelIndex]}">${escapeHtml(data)}</span>`;
}
if (message) {
if (message.startsWith("XRAY:"))
message = "<b>XRAY: </b>" + message.substring(5);
else
message = "<b>X-UI: </b>" + message;
if (message.startsWith("XRAY:")) {
message = "<b>XRAY: </b>" + escapeHtml(message.substring(5));
} else {
message = "<b>X-UI: </b>" + escapeHtml(message);
}
}
formattedLogs += message ? ' - ' + message : '';
@@ -863,20 +1033,20 @@
};
const xraylogModal = {
visible: false,
logs: [],
rows: 20,
showDirect: true,
showBlocked: true,
showProxy: true,
loading: false,
show(logs) {
this.visible = true;
this.logs = logs;
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
},
formatLogs(logs) {
let formattedLogs = `
visible: false,
logs: [],
rows: 20,
showDirect: true,
showBlocked: true,
showProxy: true,
loading: false,
show(logs) {
this.visible = true;
this.logs = logs;
this.formattedLogs = this.logs?.length > 0 ? this.formatLogs(this.logs) : "No Record...";
},
formatLogs(logs) {
let formattedLogs = `
<style>
table {
border-collapse: collapse;
@@ -899,38 +1069,37 @@
</tr>
`;
logs.reverse().forEach((log, index) => {
let outboundColor = '';
if (log.Event === 1) {
outboundColor = ' style="color: #e04141;"'; //red for blocked
}
else if (log.Event === 2) {
outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
}
logs.reverse().forEach((log, index) => {
let outboundColor = '';
if (log.Event === 1) {
outboundColor = ' style="color: #e04141;"'; //red for blocked
} else if (log.Event === 2) {
outboundColor = ' style="color: #3c89e8;"'; //blue for proxies
}
let text = ``;
if (log.Email !== "") {
text = `<td>${log.Email}</td>`;
}
let text = ``;
if (log.Email !== "") {
text = `<td>${escapeHtml(log.Email)}</td>`;
}
formattedLogs += `
formattedLogs += `
<tr ${outboundColor}>
<td><b>${IntlUtil.formatDate(log.DateTime)}</b></td>
<td>${log.FromAddress}</td>
<td>${log.ToAddress}</td>
<td>${log.Inbound}</td>
<td>${log.Outbound}</td>
<td><b>${escapeHtml(IntlUtil.formatDate(log.DateTime))}</b></td>
<td>${escapeHtml(log.FromAddress)}</td>
<td>${escapeHtml(log.ToAddress)}</td>
<td>${escapeHtml(log.Inbound)}</td>
<td>${escapeHtml(log.Outbound)}</td>
${text}
</tr>
`;
});
});
return formattedLogs += "</table>";
},
hide() {
this.visible = false;
},
};
return formattedLogs += "</table>";
},
hide() {
this.visible = false;
},
};
const backupModal = {
visible: false,
show() {
@@ -941,10 +1110,31 @@
},
};
const customGeoColumns = [
{ title: '{{ i18n "pages.index.customGeoExtColumn" }}', key: 'extDat', scopedSlots: { customRender: 'extDat' }, ellipsis: true },
{ title: '{{ i18n "pages.index.customGeoLastUpdated" }}', key: 'lastUpdatedAt', scopedSlots: { customRender: 'lastUpdatedAt' }, width: 160 },
{ title: '{{ i18n "pages.index.customGeoActions" }}', key: 'action', scopedSlots: { customRender: 'action' }, width: 120, fixed: 'right' },
const customGeoColumns = [{
title: '{{ i18n "pages.index.customGeoExtColumn" }}',
key: 'extDat',
scopedSlots: {
customRender: 'extDat'
},
ellipsis: true
},
{
title: '{{ i18n "pages.index.customGeoLastUpdated" }}',
key: 'lastUpdatedAt',
scopedSlots: {
customRender: 'lastUpdatedAt'
},
width: 160
},
{
title: '{{ i18n "pages.index.customGeoActions" }}',
key: 'action',
scopedSlots: {
customRender: 'action'
},
width: 120,
fixed: 'right'
},
];
const app = new Vue({
@@ -958,11 +1148,15 @@
spinning: false
},
status: new Status(),
cpuHistory: [], // small live widget history
cpuHistoryLong: [], // aggregated points from backend
cpuHistoryLabels: [],
cpuHistoryModal: { visible: false, bucket: 2 },
cpuHistory: [], // small live widget history
cpuHistoryLong: [], // aggregated points from backend
cpuHistoryLabels: [],
cpuHistoryModal: {
visible: false,
bucket: 2
},
versionModal,
panelUpdateModal,
logModal,
xraylogModal,
backupModal,
@@ -1036,37 +1230,46 @@
const labels = []
for (const p of msg.obj) {
const d = new Date(p.t * 1000)
const hh = String(d.getHours()).padStart(2,'0')
const mm = String(d.getMinutes()).padStart(2,'0')
const ss = String(d.getSeconds()).padStart(2,'0')
labels.push(bucket>=60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
const ss = String(d.getSeconds()).padStart(2, '0')
labels.push(bucket >= 60 ? `${hh}:${mm}` : `${hh}:${mm}:${ss}`)
vals.push(Math.max(0, Math.min(100, p.cpu)))
}
this.cpuHistoryLabels = labels
this.cpuHistoryLong = vals
}
} catch(e) {
} catch (e) {
console.error('Failed to fetch bucketed cpu history', e)
}
},
async openSelectV2rayVersion() {
async openSelectV2rayVersion(activeKey = '1') {
this.loading(true);
const msg = await HttpUtil.get('/panel/api/server/getXrayVersion');
this.loading(false);
if (!msg.success) {
return;
}
versionModal.show(msg.obj);
versionModal.show(msg.obj, activeKey);
this.loadCustomGeo();
},
async openPanelUpdate() {
this.loading(true);
const msg = await HttpUtil.get('/panel/api/server/getPanelUpdateInfo');
this.loading(false);
if (!msg.success) {
return;
}
panelUpdateModal.show(msg.obj);
},
customGeoFormatTime(ts) {
if (!ts) return '';
return typeof moment !== 'undefined' ? moment(ts * 1000).format('YYYY-MM-DD HH:mm') : String(ts);
},
customGeoExtDisplay(record) {
const fn = record.type === 'geoip'
? `geoip_${record.alias}.dat`
: `geosite_${record.alias}.dat`;
const fn = record.type === 'geoip' ?
`geoip_${record.alias}.dat` :
`geosite_${record.alias}.dat`;
return `ext:${fn}:tag`;
},
async loadCustomGeo() {
@@ -1195,22 +1398,43 @@
},
});
},
updatePanel() {
this.$confirm({
title: '{{ i18n "pages.index.panelUpdateDialog" }}',
content: '{{ i18n "pages.index.panelUpdateDialogDesc" }}'
.replace('#version#', panelUpdateModal.info.latestVersion || ''),
okText: '{{ i18n "confirm"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
panelUpdateModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
if (!msg.success) {
this.loading(false);
return;
}
await PromiseUtil.sleep(15000);
window.location.reload();
},
});
},
updateGeofile(fileName) {
const isSingleFile = !!fileName;
this.$confirm({
title: '{{ i18n "pages.index.geofileUpdateDialog" }}',
content: isSingleFile
? '{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName)
: '{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
content: isSingleFile ?
'{{ i18n "pages.index.geofileUpdateDialogDesc" }}'.replace("#filename#", fileName) :
'{{ i18n "pages.index.geofilesUpdateDialogDesc" }}',
okText: '{{ i18n "confirm"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
versionModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
const url = isSingleFile
? `/panel/api/server/updateGeofile/${fileName}`
: `/panel/api/server/updateGeofile`;
const url = isSingleFile ?
`/panel/api/server/updateGeofile/${fileName}` :
`/panel/api/server/updateGeofile`;
await HttpUtil.post(url);
this.loading(false);
},
@@ -1234,7 +1458,10 @@
},
async openLogs() {
logModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, { level: logModal.level, syslog: logModal.syslog });
const msg = await HttpUtil.post('/panel/api/server/logs/' + logModal.rows, {
level: logModal.level,
syslog: logModal.syslog
});
if (!msg.success) {
return;
}
@@ -1244,7 +1471,12 @@
},
async openXrayLogs() {
xraylogModal.loading = true;
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, { filter: xraylogModal.filter, showDirect: xraylogModal.showDirect, showBlocked: xraylogModal.showBlocked, showProxy: xraylogModal.showProxy });
const msg = await HttpUtil.post('/panel/api/server/xraylogs/' + xraylogModal.rows, {
filter: xraylogModal.filter,
showDirect: xraylogModal.showDirect,
showBlocked: xraylogModal.showBlocked,
showProxy: xraylogModal.showProxy
});
if (!msg.success) {
return;
}
@@ -1261,10 +1493,15 @@
try {
const dt = l.DateTime ? new Date(l.DateTime) : null;
const dateStr = dt && !isNaN(dt.getTime()) ? dt.toISOString() : '';
const eventMap = { 0: 'DIRECT', 1: 'BLOCKED', 2: 'PROXY' };
const eventMap = {
0: 'DIRECT',
1: 'BLOCKED',
2: 'PROXY'
};
const eventText = eventMap[l.Event] || String(l.Event ?? '');
const emailPart = l.Email ? ` Email=${l.Email}` : '';
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`.trim();
return `${dateStr} FROM=${l.FromAddress || ''} TO=${l.ToAddress || ''} INBOUND=${l.Inbound || ''} OUTBOUND=${l.Outbound || ''}${emailPart} EVENT=${eventText}`
.trim();
} catch (e) {
return JSON.stringify(l);
}
@@ -1346,10 +1583,17 @@
// Initial status fetch
await this.getStatus();
// Silently check for panel updates so the indicator shows on load
HttpUtil.get('/panel/api/server/getPanelUpdateInfo').then(msg => {
if (msg && msg.success && msg.obj) {
panelUpdateModal.info = msg.obj;
}
});
// Setup WebSocket for real-time updates
if (window.wsClient) {
window.wsClient.connect();
// Listen for status updates
window.wsClient.on('status', (payload) => {
this.setStatus(payload);
@@ -1398,4 +1642,4 @@
},
});
</script>
{{ template "page/body_end" .}}
{{ template "page/body_end" .}}

View File

@@ -108,8 +108,15 @@
el: '#app',
data: {
themeSwitcher,
loadingStates: { fetched: false, spinning: false },
user: { username: "", password: "", twoFactorCode: "" },
loadingStates: {
fetched: false,
spinning: false
},
user: {
username: "",
password: "",
twoFactorCode: ""
},
twoFactorEnable: false,
lang: "",
animationStarted: false
@@ -143,7 +150,11 @@
},
initHeadline() {
const animationDelay = 2000;
const headlines = this.$el.querySelectorAll('.headline');
const rootEl = this.$el instanceof Element ? this.$el : document.getElementById('app');
if (!rootEl || typeof rootEl.querySelectorAll !== 'function') {
return;
}
const headlines = rootEl.querySelectorAll('.headline');
headlines.forEach((headline) => {
const first = headline.querySelector('.is-visible');
if (!first) return;
@@ -233,13 +244,18 @@
}
}
});
pm_wait_for_forms.observe(pm_host, { childList: true, subtree: true });
pm_wait_for_forms.observe(pm_host, {
childList: true,
subtree: true
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', pm_init, { once: true });
document.addEventListener('DOMContentLoaded', pm_init, {
once: true
});
} else {
pm_init();
}
</script>
{{ template "page/body_end" .}}
{{ template "page/body_end" .}}

View File

@@ -1,58 +1,41 @@
{{define "modals/clientsBulkModal"}}
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible"
:title="clientsBulkModal.title"
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading"
:closable="true" :mask-closable="false"
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}'
:class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }"
:wrapper-col="{ md: {span:14} }">
<a-modal id="client-bulk-modal" v-model="clientsBulkModal.visible" :title="clientsBulkModal.title"
@ok="clientsBulkModal.ok" :confirm-loading="clientsBulkModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-text="clientsBulkModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.client.method" }}'>
<a-select v-model="clientsBulkModal.emailMethod" buttonStyle="solid"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="0">Random</a-select-option>
<a-select-option :value="1">Random+Prefix</a-select-option>
<a-select-option :value="2">Random+Prefix+Num</a-select-option>
<a-select-option
:value="3">Random+Prefix+Num+Postfix</a-select-option>
<a-select-option :value="3">Random+Prefix+Num+Postfix</a-select-option>
<a-select-option :value="4">Prefix+Num+Postfix</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.first" }}'
v-if="clientsBulkModal.emailMethod>1">
<a-input-number v-model.number="clientsBulkModal.firstNum"
:min="1"></a-input-number>
<a-form-item label='{{ i18n "pages.client.first" }}' v-if="clientsBulkModal.emailMethod>1">
<a-input-number v-model.number="clientsBulkModal.firstNum" :min="1"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.last" }}'
v-if="clientsBulkModal.emailMethod>1">
<a-input-number v-model.number="clientsBulkModal.lastNum"
:min="clientsBulkModal.firstNum"></a-input-number>
<a-form-item label='{{ i18n "pages.client.last" }}' v-if="clientsBulkModal.emailMethod>1">
<a-input-number v-model.number="clientsBulkModal.lastNum" :min="clientsBulkModal.firstNum"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.prefix" }}'
v-if="clientsBulkModal.emailMethod>0">
<a-form-item label='{{ i18n "pages.client.prefix" }}' v-if="clientsBulkModal.emailMethod>0">
<a-input v-model.trim="clientsBulkModal.emailPrefix"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.postfix" }}'
v-if="clientsBulkModal.emailMethod>2">
<a-form-item label='{{ i18n "pages.client.postfix" }}' v-if="clientsBulkModal.emailMethod>2">
<a-input v-model.trim="clientsBulkModal.emailPostfix"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.clientCount" }}'
v-if="clientsBulkModal.emailMethod < 2">
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1"
:max="500"></a-input-number>
<a-form-item label='{{ i18n "pages.client.clientCount" }}' v-if="clientsBulkModal.emailMethod < 2">
<a-input-number v-model.number="clientsBulkModal.quantity" :min="1" :max="500"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "security" }}'
v-if="inbound.protocol === Protocols.VMESS">
<a-select v-model="clientsBulkModal.security"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-form-item label='{{ i18n "security" }}' v-if="inbound.protocol === Protocols.VMESS">
<a-select v-model="clientsBulkModal.security" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="key in USERS_SECURITY" :value="key">[[
key ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='Flow'
v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
<a-select v-model="clientsBulkModal.flow"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-form-item label='Flow' v-if="clientsBulkModal.inbound.canEnableTlsFlow()">
<a-select v-model="clientsBulkModal.flow" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option value selected>{{ i18n "none"
}}</a-select-option>
<a-select-option v-for="key in TLS_FLOW_CONTROL" :value="key">[[
@@ -67,9 +50,7 @@
}}</span>
</template>
Subscription
<a-icon
@click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)"
type="sync"></a-icon>
<a-icon @click="clientsBulkModal.subId = RandomUtil.randomLowerAndNum(16)" type="sync"></a-icon>
</a-tooltip>
</template>
<a-input v-model.trim="clientsBulkModal.subId"></a-input>
@@ -84,8 +65,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number :style="{ width: '50%' }"
v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
<a-input-number :style="{ width: '50%' }" v-model.number="clientsBulkModal.tgId" min="0"></a-input-number>
</a-form-item>
<a-form-item v-if="app.ipLimitEnable">
<template slot="label">
@@ -97,8 +77,7 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="clientsBulkModal.limitIp"
min="0"></a-input-number>
<a-input-number v-model.number="clientsBulkModal.limitIp" min="0"></a-input-number>
</a-form-item>
<a-form-item>
<template slot="label">
@@ -110,17 +89,13 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="clientsBulkModal.totalGB"
:min="0"></a-input-number>
<a-input-number v-model.number="clientsBulkModal.totalGB" :min="0"></a-input-number>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.delayedStart" }}'>
<a-switch v-model="clientsBulkModal.delayedStart"
@click="clientsBulkModal.expiryTime=0"></a-switch>
<a-switch v-model="clientsBulkModal.delayedStart" @click="clientsBulkModal.expiryTime=0"></a-switch>
</a-form-item>
<a-form-item label='{{ i18n "pages.client.expireDays" }}'
v-if="clientsBulkModal.delayedStart">
<a-input-number v-model.number="delayedExpireDays"
:min="0"></a-input-number>
<a-form-item label='{{ i18n "pages.client.expireDays" }}' v-if="clientsBulkModal.delayedStart">
<a-input-number v-model.number="delayedExpireDays" :min="0"></a-input-number>
</a-form-item>
<a-form-item v-else>
<template slot="label">
@@ -133,15 +108,11 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-date-picker v-if="datepicker == 'gregorian'"
:show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-date-picker v-if="datepicker == 'gregorian'" :show-time="{ format: 'HH:mm:ss' }"
format="YYYY-MM-DD HH:mm:ss" :dropdown-class-name="themeSwitcher.currentTheme"
v-model="clientsBulkModal.expiryTime"></a-date-picker>
<a-persian-datepicker v-else
placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
value="clientsBulkModal.expiryTime"
v-model="clientsBulkModal.expiryTime">
<a-persian-datepicker v-else placeholder='{{ i18n "pages.settings.datepickerPlaceholder" }}'
value="clientsBulkModal.expiryTime" v-model="clientsBulkModal.expiryTime">
</a-persian-datepicker>
</a-form-item>
<a-form-item v-if="clientsBulkModal.expiryTime != 0">
@@ -154,13 +125,11 @@
<a-icon type="question-circle"></a-icon>
</a-tooltip>
</template>
<a-input-number v-model.number="clientsBulkModal.reset"
:min="0"></a-input-number>
<a-input-number v-model.number="clientsBulkModal.reset" :min="0"></a-input-number>
</a-form-item>
</a-form>
</a-modal>
<script>
const clientsBulkModal = {
visible: false,
confirmLoading: false,
@@ -219,7 +188,7 @@
title = '',
okText = '{{ i18n "sure" }}',
dbInbound = null,
confirm = (inbound, dbInbound) => { }
confirm = (inbound, dbInbound) => {}
}) {
this.visible = true;
this.title = title;
@@ -245,12 +214,19 @@
},
newClient(protocol) {
switch (protocol) {
case Protocols.VMESS: return new Inbound.VmessSettings.VMESS();
case Protocols.VLESS: return new Inbound.VLESSSettings.VLESS();
case Protocols.TROJAN: return new Inbound.TrojanSettings.Trojan();
case Protocols.SHADOWSOCKS: return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings.shadowsockses[0].method);
case Protocols.HYSTERIA: return new Inbound.HysteriaSettings.Hysteria();
default: return null;
case Protocols.VMESS:
return new Inbound.VmessSettings.VMESS();
case Protocols.VLESS:
return new Inbound.VLESSSettings.VLESS();
case Protocols.TROJAN:
return new Inbound.TrojanSettings.Trojan();
case Protocols.SHADOWSOCKS:
return new Inbound.ShadowsocksSettings.Shadowsocks(clientsBulkModal.inbound.settings
.shadowsockses[0].method);
case Protocols.HYSTERIA:
return new Inbound.HysteriaSettings.Hysteria();
default:
return null;
}
},
close() {
@@ -271,7 +247,8 @@
return this.clientsBulkModal.inbound;
},
get delayedExpireDays() {
return this.clientsBulkModal.expiryTime < 0 ? this.clientsBulkModal.expiryTime / -86400000 : 0;
return this.clientsBulkModal.expiryTime < 0 ? this.clientsBulkModal.expiryTime / -86400000 :
0;
},
get datepicker() {
return app.datepicker;
@@ -281,6 +258,5 @@
},
},
});
</script>
{{end}}
{{end}}

View File

@@ -1,19 +1,15 @@
{{define "modals/clientsModal"}}
<a-modal id="client-modal" v-model="clientModal.visible"
:title="clientModal.title" @ok="clientModal.ok"
:confirm-loading="clientModal.confirmLoading" :closable="true"
:mask-closable="false"
:class="themeSwitcher.currentTheme"
:ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
<a-modal id="client-modal" v-model="clientModal.visible" :title="clientModal.title" @ok="clientModal.ok"
:confirm-loading="clientModal.confirmLoading" :closable="true" :mask-closable="false"
:class="themeSwitcher.currentTheme" :ok-text="clientModal.okText" cancel-text='{{ i18n "close" }}'>
<template v-if="isEdit">
<a-tag v-if="isExpiry || isTrafficExhausted" color="red"
:style="{ marginBottom: '10px', display: 'block', textAlign: 'center' }">Account
is (Expired|Traffic Ended) And Disabled</a-tag>
</template>
{{template "form/client"}}
{{template "form/client" .}}
</a-modal>
<script>
const clientModal = {
visible: false,
confirmLoading: false,
@@ -30,12 +26,20 @@
delayedStart: false,
ok() {
if (clientModal.isEdit) {
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal.oldClientId);
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id, clientModal
.oldClientId);
} else {
ObjectUtil.execute(clientModal.confirm, clientModalApp.client, clientModal.dbInbound.id);
}
},
show({ title = '', okText = '{{ i18n "sure" }}', index = null, dbInbound = null, confirm = () => { }, isEdit = false }) {
show({
title = '',
okText = '{{ i18n "sure" }}',
index = null,
dbInbound = null,
confirm = () => {},
isEdit = false
}) {
this.visible = true;
this.title = title;
this.okText = okText;
@@ -55,30 +59,41 @@
}
this.clientStats = this.dbInbound.clientStats.find(row => row.email === this.clients[this.index].email);
this.confirm = confirm;
},
},
getClientId(protocol, client) {
switch (protocol) {
case Protocols.TROJAN: return client.password;
case Protocols.SHADOWSOCKS: return client.email;
case Protocols.HYSTERIA: return client.auth;
default: return client.id;
case Protocols.TROJAN:
return client.password;
case Protocols.SHADOWSOCKS:
return client.email;
case Protocols.HYSTERIA:
return client.auth;
default:
return client.id;
}
},
addClient(inbound, clients) {
switch (inbound.protocol) {
case Protocols.VMESS: return clients.push(new Inbound.VmessSettings.VMESS());
case Protocols.VLESS: return clients.push(new Inbound.VLESSSettings.VLESS());
case Protocols.TROJAN: return clients.push(new Inbound.TrojanSettings.Trojan());
case Protocols.SHADOWSOCKS: return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil.randomShadowsocksPassword(inbound.settings.method)));
case Protocols.HYSTERIA: return clients.push(new Inbound.HysteriaSettings.Hysteria());
default: return null;
case Protocols.VMESS:
return clients.push(new Inbound.VmessSettings.VMESS());
case Protocols.VLESS:
return clients.push(new Inbound.VLESSSettings.VLESS());
case Protocols.TROJAN:
return clients.push(new Inbound.TrojanSettings.Trojan());
case Protocols.SHADOWSOCKS:
return clients.push(new Inbound.ShadowsocksSettings.Shadowsocks(clients[0].method, RandomUtil
.randomShadowsocksPassword(inbound.settings.method)));
case Protocols.HYSTERIA:
return clients.push(new Inbound.HysteriaSettings.Hysteria());
default:
return null;
}
},
close() {
clientModal.visible = false;
clientModal.loading(false);
},
loading(loading=true) {
loading(loading = true) {
clientModal.confirmLoading = loading;
},
};
@@ -110,7 +125,8 @@
return true
},
get isExpiry() {
return this.clientModal.isEdit && this.client.expiryTime >0 ? (this.client.expiryTime < new Date().getTime()) : false;
return this.clientModal.isEdit && this.client.expiryTime > 0 ? (this.client.expiryTime <
new Date().getTime()) : false;
},
get delayedStart() {
return this.clientModal.delayedStart;
@@ -150,8 +166,7 @@
return;
}
document.getElementById("clientIPs").value = "";
} catch (error) {
}
} catch (error) {}
},
resetClientTraffic(email, dbInboundId, iconElement) {
this.$confirm({
@@ -162,7 +177,8 @@
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
iconElement.disabled = true;
const msg = await HttpUtil.postWithModal('/panel/api/inbounds/' + dbInboundId + '/resetClientTraffic/' + email);
const msg = await HttpUtil.postWithModal('/panel/api/inbounds/' +
dbInboundId + '/resetClientTraffic/' + email);
if (msg.success) {
this.clientModal.clientStats.up = 0;
this.clientModal.clientStats.down = 0;
@@ -173,6 +189,5 @@
},
},
});
</script>
{{end}}
{{end}}

View File

@@ -5,10 +5,12 @@
<a-list-item v-for="dns in dnsPresetsDatabase" :style="{ padding: '12px 16px' }">
<div class="ant-dns-presets-line">
<a-space direction="horizontal" size="small" align="center">
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}' : 'DNS' ]]</a-tag>
<a-tag :color="dns.family ? 'purple' : 'green'">[[ dns.family ? '{{ i18n "pages.xray.dns.dnsPresetFamily" }}'
: 'DNS' ]]</a-tag>
<span class="ant-dns-presets-list-name">[[ dns.name ]]</span>
</a-space>
<a-button class="ant-dns-presets-install" type="primary" @click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
<a-button class="ant-dns-presets-install" type="primary"
@click="dnsPresetsModal.install(dns.data)">{{ i18n "install" }}</a-button>
</div>
</a-list-item>
</a-list>
@@ -36,8 +38,7 @@
</style>
<script>
const dnsPresetsDatabase = [
{
const dnsPresetsDatabase = [{
name: 'Google DNS',
family: false,
data: [
@@ -96,7 +97,11 @@
install(selectedPreset) {
return ObjectUtil.execute(dnsPresetsModal.selected, selectedPreset);
},
show({ title = '', selected = (selectedPreset) => { }, isEdit = false }) {
show({
title = '',
selected = (selectedPreset) => {},
isEdit = false
}) {
this.title = title;
this.selected = selected;
this.visible = true;

View File

@@ -513,9 +513,20 @@
<div v-html="infoModal.links[index].replaceAll(`\n`,`<br />`)"
:style="{ borderRadius: '1rem', padding: '0.5rem' }" class="client-table-odd-row">
</div>
<a-divider orientation="center">Link</a-divider>
<tr-info-title class="tr-info-title">
<a-tag color="green">Link</a-tag>
<a-tooltip title='{{ i18n "copy" }}'>
<a-button :style="{ minWidth: '24px' }" size="small" icon="snippets"
@click="copy(infoModal.wireguardLinks[index])"></a-button>
</a-tooltip>
</tr-info-title>
<code :style="{ display: 'block', whiteSpace: 'normal', wordBreak: 'break-all' }">[[
infoModal.wireguardLinks[index] ]]</code>
</tr-info-row>
</td>
</tr>
</template>
</table>
</template>
</template>
@@ -524,7 +535,10 @@
function refreshIPs(email) {
return HttpUtil.post(`/panel/api/inbounds/clientIps/${email}`).then((msg) => {
if (!msg.success) {
return { text: 'No IP Record', array: [] };
return {
text: 'No IP Record',
array: []
};
}
const formatIpRecord = (record) => {
@@ -564,7 +578,10 @@
try {
ips = JSON.parse(ips);
} catch (e) {
return { text: String(ips), array: [String(ips)] };
return {
text: String(ips),
array: [String(ips)]
};
}
}
@@ -576,20 +593,32 @@
// New format or object array
if (Array.isArray(ips) && ips.length > 0 && typeof ips[0] === 'object') {
const result = ips.map((item) => formatIpRecord(item)).filter(Boolean);
return { text: result.join(' | '), array: result };
return {
text: result.join(' | '),
array: result
};
}
// Old format - simple array of IPs
if (Array.isArray(ips) && ips.length > 0) {
const result = ips.map((ip) => String(ip));
return { text: result.join(', '), array: result };
return {
text: result.join(', '),
array: result
};
}
// Fallback for any other format
return { text: String(ips), array: [String(ips)] };
return {
text: String(ips),
array: [String(ips)]
};
} catch (e) {
return { text: 'Error loading IPs', array: [] };
return {
text: 'Error loading IPs',
array: []
};
}
});
}
@@ -603,6 +632,7 @@
upStats: 0,
downStats: 0,
links: [],
wireguardLinks: [],
index: null,
isExpired: false,
subLink: '',
@@ -615,7 +645,8 @@
this.dbInbound = new DBInbound(dbInbound);
this.clientSettings = this.inbound.clients ? this.inbound.clients[index] : null;
this.isExpired = this.inbound.clients ? this.inbound.isExpiry(index) : this.dbInbound.isExpiry;
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this.clientSettings.email) || null) : null;
this.clientStats = this.inbound.clients ? (this.dbInbound.clientStats.find(row => row.email === this
.clientSettings.email) || null) : null;
if (
[
@@ -633,9 +664,11 @@
}
}
if (this.inbound.protocol == Protocols.WIREGUARD) {
this.links = this.inbound.genInboundLinks(dbInbound.remark).split('\r\n')
this.links = this.inbound.genWireguardConfigs(dbInbound.remark).split('\r\n')
this.wireguardLinks = this.inbound.genWireguardLinks(dbInbound.remark).split('\r\n')
} else {
this.links = this.inbound.genAllLinks(this.dbInbound.remark, app.remarkModel, this.clientSettings);
this.wireguardLinks = [];
}
if (this.clientSettings) {
if (this.clientSettings.subId) {
@@ -739,7 +772,8 @@
return ColorUtils.usageColor(stats.up + stats.down, app.trafficDiff, stats.total);
},
getRemStats() {
remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats.down;
remained = this.infoModal.clientStats.total - this.infoModal.clientStats.up - this.infoModal.clientStats
.down;
return remained > 0 ? SizeFormatter.sizeFormat(remained) : '-';
},
refreshIPs() {
@@ -762,7 +796,7 @@
this.infoModal.clientIps = 'No IP Record';
this.infoModal.clientIpsArray = [];
})
.catch(() => { });
.catch(() => {});
},
},
});

View File

@@ -2,7 +2,7 @@
<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"}}
{{template "form/inbound" .}}
</a-modal>
<script>
// Make inModal globally available to ensure it works with any base path
@@ -23,7 +23,7 @@
okText = '{{ i18n "sure" }}',
inbound = null,
dbInbound = null,
confirm = (inbound, dbInbound) => { },
confirm = (inbound, dbInbound) => {},
isEdit = false,
}) {
this.title = title;
@@ -127,17 +127,17 @@
get client() {
return inModal.inbound &&
inModal.inbound.clients &&
inModal.inbound.clients.length > 0
? inModal.inbound.clients[0]
: null;
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;
return this.client && this.client.expiryTime < 0 ?
this.client.expiryTime / -86400000 :
0;
},
set delayedExpireDays(days) {
this.client.expiryTime = -86400000 * days;
@@ -147,14 +147,12 @@
},
set externalProxy(value) {
if (value) {
inModal.inbound.stream.externalProxy = [
{
forceTls: "same",
dest: window.location.hostname,
port: inModal.inbound.port,
remark: "",
},
];
inModal.inbound.stream.externalProxy = [{
forceTls: "same",
dest: window.location.hostname,
port: inModal.inbound.port,
remark: "",
}, ];
} else {
inModal.inbound.stream.externalProxy = [];
}
@@ -182,8 +180,8 @@
) {
const hasVisionFlow = inModal.inbound.settings.vlesses.some(
(c) =>
c.flow === "xtls-rprx-vision" ||
c.flow === "xtls-rprx-vision-udp443",
c.flow === "xtls-rprx-vision" ||
c.flow === "xtls-rprx-vision-udp443",
);
if (
hasVisionFlow &&
@@ -307,6 +305,12 @@
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);
@@ -316,7 +320,6 @@
}
const auths = msg.obj.auths || [];
const selected = inModal.inbound.settings.selectedAuth;
const block = auths.find((a) => a.label === selected);
if (!block) {

View File

@@ -1,25 +1,31 @@
{{define "modals/nordModal"}}
<a-modal id="nord-modal" v-model="nordModal.visible" title="NordVPN NordLynx"
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true"
:footer="null" :class="themeSwitcher.currentTheme">
:confirm-loading="nordModal.confirmLoading" :closable="true" :mask-closable="true" :footer="null"
:class="themeSwitcher.currentTheme">
<template v-if="nordModal.nordData == null">
<a-tabs default-active-key="token" :class="themeSwitcher.currentTheme">
<a-tab-pane key="token" tab='{{ i18n "pages.xray.outbound.accessToken" }}'>
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
:style="{ marginTop: '20px' }">
<a-form-item label='{{ i18n "pages.xray.outbound.accessToken" }}'>
<a-input v-model="nordModal.token" placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
<a-input v-model="nordModal.token"
placeholder='{{ i18n "pages.xray.outbound.accessToken" }}'></a-input>
<div :style="{ marginTop: '10px' }">
<a-button type="primary" icon="login" @click="login()" :loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
<a-button type="primary" icon="login" @click="login()"
:loading="nordModal.confirmLoading">{{ i18n "login" }}</a-button>
</div>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="key" tab='{{ i18n "pages.xray.outbound.privateKey" }}'>
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '20px' }">
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
:style="{ marginTop: '20px' }">
<a-form-item label='{{ i18n "pages.xray.outbound.privateKey" }}'>
<a-input v-model="nordModal.manualKey" placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
<a-input v-model="nordModal.manualKey"
placeholder='{{ i18n "pages.xray.outbound.privateKey" }}'></a-input>
<div :style="{ marginTop: '10px' }">
<a-button type="primary" icon="save" @click="saveKey()" :loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
<a-button type="primary" icon="save" @click="saveKey()"
:loading="nordModal.confirmLoading">{{ i18n "save" }}</a-button>
</div>
</a-form-item>
</a-form>
@@ -39,7 +45,8 @@
</table>
<a-button @click="logout" :loading="nordModal.confirmLoading" type="danger">{{ i18n "logout" }}</a-button>
<a-divider :style="{ margin: '0' }">{{ i18n "pages.xray.outbound.settings" }}</a-divider>
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }" :style="{ marginTop: '10px' }">
<a-form :colon="false" :label-col="{ md: {span:6} }" :wrapper-col="{ md: {span:18} }"
:style="{ marginTop: '10px' }">
<a-form-item label='{{ i18n "pages.xray.outbound.country" }}'>
<a-select v-model="nordModal.countryId" @change="fetchServers" show-search option-filter-prop="label">
<a-select-option v-for="c in nordModal.countries" :key="c.id" :value="c.id" :label="c.name">
@@ -69,11 +76,13 @@
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<template v-if="nordOutboundIndex>=0">
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
<a-button @click="resetOutbound" :loading="nordModal.confirmLoading"
type="danger">{{ i18n "reset" }}</a-button>
</template>
<template v-else>
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
<a-button @click="addOutbound" :disabled="!nordModal.serverId" :loading="nordModal.confirmLoading"
type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
</template>
</a-form>
</template>
@@ -115,7 +124,9 @@
},
async login() {
this.loading(true);
const msg = await HttpUtil.post('/panel/xray/nord/reg', { token: this.token });
const msg = await HttpUtil.post('/panel/xray/nord/reg', {
token: this.token
});
if (msg.success) {
this.nordData = JSON.parse(msg.obj);
await this.fetchCountries();
@@ -124,7 +135,9 @@
},
async saveKey() {
this.loading(true);
const msg = await HttpUtil.post('/panel/xray/nord/setKey', { key: this.manualKey });
const msg = await HttpUtil.post('/panel/xray/nord/setKey', {
key: this.manualKey
});
if (msg.success) {
this.nordData = JSON.parse(msg.obj);
await this.fetchCountries();
@@ -160,7 +173,9 @@
this.cities = [];
this.serverId = null;
this.cityId = null;
const msg = await HttpUtil.post('/panel/xray/nord/servers', { countryId: this.countryId });
const msg = await HttpUtil.post('/panel/xray/nord/servers', {
countryId: this.countryId
});
if (msg.success) {
const data = JSON.parse(msg.obj);
const locations = data.locations || [];
@@ -173,7 +188,7 @@
}
});
this.cities = Array.from(citiesMap.values()).sort((a, b) => a.name.localeCompare(b.name));
this.servers = (data.servers || []).map(s => {
const firstLocId = (s.location_ids || [])[0];
const city = locToCity[firstLocId];
@@ -195,7 +210,7 @@
addOutbound() {
const server = this.servers.find(s => s.id === this.serverId);
if (!server) return;
const tech = server.technologies.find(t => t.id === 35);
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
@@ -221,7 +236,7 @@
resetOutbound(index) {
const server = this.servers.find(s => s.id === this.serverId);
if (!server || index === -1) return;
const tech = server.technologies.find(t => t.id === 35);
const publicKey = tech.metadata.find(m => m.name === 'public_key').value;
@@ -242,7 +257,7 @@
}
};
app.templateSettings.outbounds[index] = outbound;
// Sync routing rules
app.templateSettings.routing.rules.forEach(r => {
if (r.outboundTag === oldTag) {
@@ -262,7 +277,8 @@
},
delRouting() {
if (app.templateSettings && app.templateSettings.routing) {
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag.startsWith("nord-"));
app.templateSettings.routing.rules = app.templateSettings.routing.rules.filter(r => !r.outboundTag
.startsWith("nord-"));
}
}
};
@@ -276,10 +292,14 @@
methods: {
login: () => nordModal.login(),
saveKey: () => nordModal.saveKey(),
logout() { nordModal.logout(this.nordOutboundIndex) },
logout() {
nordModal.logout(this.nordOutboundIndex)
},
fetchServers: () => nordModal.fetchServers(),
addOutbound: () => nordModal.addOutbound(),
resetOutbound() { nordModal.resetOutbound(this.nordOutboundIndex) },
resetOutbound() {
nordModal.resetOutbound(this.nordOutboundIndex)
},
onCityChange() {
if (this.filteredServers.length > 0) {
this.nordModal.serverId = this.filteredServers[0].id;
@@ -290,8 +310,9 @@
},
computed: {
nordOutboundIndex: {
get: function () {
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag.startsWith("nord-")) : -1;
get: function() {
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag
.startsWith("nord-")) : -1;
}
},
filteredServers: function() {
@@ -303,4 +324,4 @@
}
});
</script>
{{end}}
{{end}}

View File

@@ -1,17 +1,13 @@
{{define "modals/promptModal"}}
<a-modal id="prompt-modal" v-model="promptModal.visible" :title="promptModal.title"
:closable="true" @ok="promptModal.ok" :mask-closable="false"
:confirm-loading="promptModal.confirmLoading"
:ok-text="promptModal.okText" cancel-text='{{ i18n "cancel" }}' :class="themeSwitcher.currentTheme">
<a-input id="prompt-modal-input" :type="promptModal.type"
v-model="promptModal.value"
:autosize="{minRows: 10, maxRows: 20}"
@keydown.enter.native="promptModal.keyEnter"
@keydown.ctrl.83="promptModal.ctrlS"></a-input>
<a-modal id="prompt-modal" v-model="promptModal.visible" :title="promptModal.title" :closable="true"
@ok="promptModal.ok" :mask-closable="false" :confirm-loading="promptModal.confirmLoading"
:ok-text="promptModal.okText" cancel-text='{{ i18n "cancel" }}' :class="themeSwitcher.currentTheme">
<a-input id="prompt-modal-input" :type="promptModal.type" v-model="promptModal.value"
:autosize="{minRows: 10, maxRows: 20}" @keydown.enter.native="promptModal.keyEnter"
@keydown.ctrl.83="promptModal.ctrlS"></a-input>
</a-modal>
<script>
const promptModal = {
title: '',
type: '',
@@ -55,7 +51,7 @@
close() {
this.visible = false;
},
loading(loading=true) {
loading(loading = true) {
this.confirmLoading = loading;
},
};
@@ -66,6 +62,5 @@
promptModal: promptModal,
},
});
</script>
{{end}}

View File

@@ -57,39 +57,44 @@
border-radius: 1rem;
overflow-x: hidden;
}
/* QR code transition effects */
.qr-cv {
transition: all 0.3s ease-in-out;
}
.qr-transition-enter-active, .qr-transition-leave-active {
.qr-transition-enter-active,
.qr-transition-leave-active {
transition: opacity 0.3s, transform 0.3s;
}
.qr-transition-enter, .qr-transition-leave-to {
.qr-transition-enter,
.qr-transition-leave-to {
opacity: 0;
transform: scale(0.9);
}
.qr-transition-enter-to, .qr-transition-leave {
.qr-transition-enter-to,
.qr-transition-leave {
opacity: 1;
transform: scale(1);
}
.qr-flash {
animation: qr-flash-animation 0.6s;
}
@keyframes qr-flash-animation {
0% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
@@ -105,7 +110,7 @@
qrcodes: [],
visible: false,
subId: '',
show: function (title = '', dbInbound, client) {
show: function(title = '', dbInbound, client) {
this.title = title;
this.dbInbound = dbInbound;
this.inbound = dbInbound.toInbound();
@@ -135,7 +140,7 @@
}
this.visible = true;
},
close: function () {
close: function() {
this.visible = false;
},
};
@@ -159,7 +164,7 @@
console.error("Failed to get status:", e);
}
},
toggleIPv4(index) {
const row = qrModal.qrcodes[index];
row.useIPv4 = !row.useIPv4;
@@ -170,13 +175,13 @@
if (!this.serverStatus || !this.serverStatus.publicIP) {
return;
}
if (row.useIPv4 && this.serverStatus.publicIP.ipv4) {
// Replace the hostname or IP in the link with the IPv4 address
const originalLink = row.originalLink;
const url = new URL(originalLink);
const ipv4 = this.serverStatus.publicIP.ipv4;
if (qrModal.inbound.protocol == Protocols.WIREGUARD) {
// Special handling for WireGuard config
const endpointRegex = /Endpoint = ([^:]+):(\d+)/;
@@ -196,19 +201,19 @@
// Restore original link
row.link = row.originalLink;
}
// Update QR code with transition effect
const canvasElement = document.querySelector('#qrCode-' + index);
if (canvasElement) {
// Add flash animation class
canvasElement.classList.add('qr-flash');
// Remove the class after animation completes
setTimeout(() => {
canvasElement.classList.remove('qr-flash');
}, 600);
}
this.setQrCode("qrCode-" + index, row.link);
},
copy(content) {

View File

@@ -21,13 +21,13 @@
fileName: '',
qrcode: null,
visible: false,
show: function (title = '', content = '', fileName = '') {
show: function(title = '', content = '', fileName = '') {
this.title = title;
this.content = content;
this.fileName = fileName;
this.visible = true;
},
copy: function (content = '') {
copy: function(content = '') {
ClipboardManager
.copyText(content)
.then(() => {
@@ -35,7 +35,7 @@
this.close();
})
},
close: function () {
close: function() {
this.visible = false;
},
};

View File

@@ -55,12 +55,12 @@
twoFactorModal.close()
},
show: function ({
show: function({
title = '',
description = '',
token = '',
type = 'set',
confirm = (success) => { }
confirm = (success) => {}
}) {
this.title = title;
this.description = description;
@@ -78,7 +78,7 @@
secret: twoFactorModal.token,
});
},
close: function () {
close: function() {
twoFactorModal.enteredCode = "";
twoFactorModal.visible = false;
},
@@ -91,34 +91,34 @@
twoFactorModal: twoFactorModal,
},
updated() {
if (
this.twoFactorModal.visible &&
this.twoFactorModal.type === 'set' &&
document.getElementById('twofactor-qrcode')
) {
this.setQrCode('twofactor-qrcode', this.twoFactorModal.totpObject.toString());
}
if (
this.twoFactorModal.visible &&
this.twoFactorModal.type === 'set' &&
document.getElementById('twofactor-qrcode')
) {
this.setQrCode('twofactor-qrcode', this.twoFactorModal.totpObject.toString());
}
},
methods: {
setQrCode(elementId, content) {
new QRious({
element: document.getElementById(elementId),
size: 200,
value: content,
background: 'white',
backgroundAlpha: 0,
foreground: 'black',
padding: 2,
level: 'L'
});
},
copy(content) {
ClipboardManager
.copyText(content)
.then(() => {
app.$message.success('{{ i18n "copied" }}')
})
},
setQrCode(elementId, content) {
new QRious({
element: document.getElementById(elementId),
size: 200,
value: content,
background: 'white',
backgroundAlpha: 0,
foreground: 'black',
padding: 2,
level: 'L'
});
},
copy(content) {
ClipboardManager
.copyText(content)
.then(() => {
app.$message.success('{{ i18n "copied" }}')
})
},
}
});
</script>

View File

@@ -1,7 +1,6 @@
{{define "modals/warpModal"}}
<a-modal id="warp-modal" v-model="warpModal.visible" title="Cloudflare WARP"
:confirm-loading="warpModal.confirmLoading" :closable="true" :mask-closable="true"
:footer="null" :class="themeSwitcher.currentTheme">
<a-modal id="warp-modal" v-model="warpModal.visible" title="Cloudflare WARP" :confirm-loading="warpModal.confirmLoading"
:closable="true" :mask-closable="true" :footer="null" :class="themeSwitcher.currentTheme">
<template v-if="ObjectUtil.isEmpty(warpModal.warpData)">
<a-button icon="api" @click="register" :loading="warpModal.confirmLoading">{{ i18n "create" }}</a-button>
</template>
@@ -81,11 +80,13 @@
<a-form :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<template v-if="warpOutboundIndex>=0">
<a-tag color="green" :style="{ lineHeight: '31px' }">{{ i18n "enabled" }}</a-tag>
<a-button @click="resetOutbound" :loading="warpModal.confirmLoading" type="danger">{{ i18n "reset" }}</a-button>
<a-button @click="resetOutbound" :loading="warpModal.confirmLoading"
type="danger">{{ i18n "reset" }}</a-button>
</template>
<template v-else>
<a-tag color="orange" :style="{ lineHeight: '31px' }">{{ i18n "disabled" }}</a-tag>
<a-button @click="addOutbound" :loading="warpModal.confirmLoading" type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
<a-button @click="addOutbound" :loading="warpModal.confirmLoading"
type="primary">{{ i18n "pages.xray.outbound.addOutbound" }}</a-button>
</template>
</a-form-item>
</a-form>
@@ -93,7 +94,6 @@
</template>
</a-modal>
<script>
const warpModal = {
visible: false,
confirmLoading: false,
@@ -188,7 +188,9 @@
},
async updateLicense(l) {
warpModal.loading(true);
const msg = await HttpUtil.post('/panel/xray/warp/license', { license: l });
const msg = await HttpUtil.post('/panel/xray/warp/license', {
license: l
});
if (msg.success) {
warpModal.warpData = JSON.parse(msg.obj);
warpModal.warpConfig = null;
@@ -235,12 +237,12 @@
},
computed: {
warpOutboundIndex: {
get: function () {
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag == 'warp') : -1;
get: function() {
return app.templateSettings ? app.templateSettings.outbounds.findIndex((o) => o.tag ==
'warp') : -1;
}
}
}
});
</script>
{{end}}

View File

@@ -1,15 +1,7 @@
{{define "modals/balancerModal"}}
<a-modal
id="balancer-modal"
v-model="balancerModal.visible"
:title="balancerModal.title"
@ok="balancerModal.ok"
:confirm-loading="balancerModal.confirmLoading"
:ok-button-props="{ props: { disabled: !balancerModal.isValid } }"
:closable="true"
:mask-closable="false"
:ok-text="balancerModal.okText"
cancel-text='{{ i18n "close" }}'
<a-modal id="balancer-modal" v-model="balancerModal.visible" :title="balancerModal.title" @ok="balancerModal.ok"
:confirm-loading="balancerModal.confirmLoading" :ok-button-props="{ props: { disabled: !balancerModal.isValid } }"
:closable="true" :mask-closable="false" :ok-text="balancerModal.okText" cancel-text='{{ i18n "close" }}'
:class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.xray.balancer.tag" }}' has-feedback
@@ -35,7 +27,8 @@
<a-form-item label="Fallback">
<a-select v-model="balancerModal.balancer.fallbackTag" clearable
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="tag in [ '', ...balancerModal.outboundTags]" :value="tag">[[ tag ]]</a-select-option>
<a-select-option v-for="tag in [ '', ...balancerModal.outboundTags]" :value="tag">[[ tag
]]</a-select-option>
</a-select>
</a-form-item>
</table>
@@ -58,7 +51,7 @@
fallbackTag: ''
},
outboundTags: [],
balancerTags:[],
balancerTags: [],
ok() {
if (balancerModal.balancer.selector.length == 0) {
balancerModal.emptySelector = true;
@@ -67,7 +60,14 @@
balancerModal.emptySelector = false;
ObjectUtil.execute(balancerModal.confirm, balancerModal.balancer);
},
show({ title = '', okText = '{{ i18n "sure" }}', balancerTags = [], balancer, confirm = (balancer) => { }, isEdit = false }) {
show({
title = '',
okText = '{{ i18n "sure" }}',
balancerTags = [],
balancer,
confirm = (balancer) => {},
isEdit = false
}) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
@@ -83,7 +83,8 @@
};
}
this.balancerTags = balancerTags.filter((tag) => tag != balancer.tag);
this.outboundTags = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag);
this.outboundTags = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
obj.tag);
this.isEdit = isEdit;
this.check();
this.checkSelector();
@@ -92,7 +93,7 @@
this.visible = false;
this.loading(false);
},
loading(loading=true) {
loading(loading = true) {
this.confirmLoading = loading;
},
check() {
@@ -115,9 +116,7 @@
data: {
balancerModal: balancerModal
},
methods: {
}
methods: {}
});
</script>
{{end}}

View File

@@ -75,15 +75,19 @@
okText: '{{ i18n "confirm" }}',
isEdit: false,
confirm: null,
dnsServer: { ...defaultDnsObject },
dnsServer: {
...defaultDnsObject
},
ok() {
ObjectUtil.execute(dnsModal.confirm, { ...dnsModal.dnsServer });
ObjectUtil.execute(dnsModal.confirm, {
...dnsModal.dnsServer
});
},
show({
title = '',
okText = '{{ i18n "confirm" }}',
dnsServer,
confirm = (dnsServer) => { },
confirm = (dnsServer) => {},
isEdit = false
}) {
this.title = title;
@@ -95,7 +99,9 @@
if (isEdit) {
switch (typeof dnsServer) {
case 'string':
const dnsObj = { ...defaultDnsObject };
const dnsObj = {
...defaultDnsObject
};
dnsObj.address = dnsServer;
@@ -106,7 +112,9 @@
break;
}
} else {
this.dnsServer = { ...defaultDnsObject };
this.dnsServer = {
...defaultDnsObject
};
this.dnsServer.domains = [];
this.dnsServer.expectIPs = [];

View File

@@ -23,11 +23,19 @@
okText: '{{ i18n "confirm" }}',
isEdit: false,
confirm: null,
fakeDns: { ...fakednsDefaultData },
fakeDns: {
...fakednsDefaultData
},
ok() {
ObjectUtil.execute(fakednsModal.confirm, fakednsModal.fakeDns);
},
show({ title = '', okText = '{{ i18n "confirm" }}', fakeDns, confirm = (fakeDns) => { }, isEdit = false }) {
show({
title = '',
okText = '{{ i18n "confirm" }}',
fakeDns,
confirm = (fakeDns) => {},
isEdit = false
}) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
@@ -35,7 +43,9 @@
if (isEdit) {
this.fakeDns = fakeDns;
} else {
this.fakeDns = { ...fakednsDefaultData }
this.fakeDns = {
...fakednsDefaultData
}
}
this.isEdit = isEdit;
},
@@ -51,6 +61,5 @@
fakednsModal: fakednsModal,
}
});
</script>
{{end}}

View File

@@ -1,12 +1,11 @@
{{define "modals/outModal"}}
<a-modal id="out-modal" v-model="outModal.visible" :title="outModal.title" @ok="outModal.ok"
:confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-button-props="{ props: { disabled: !outModal.isValid } }" :style="{ overflow: 'hidden' }"
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
{{template "form/outbound"}}
:confirm-loading="outModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-button-props="{ props: { disabled: !outModal.isValid } }" :style="{ overflow: 'hidden' }"
:ok-text="outModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
{{template "form/outbound" .}}
</a-modal>
<script>
const outModal = {
title: '',
visible: false,
@@ -25,7 +24,14 @@
ok() {
ObjectUtil.execute(outModal.confirm, outModal.outbound.toJson());
},
show({ title='', okText='{{ i18n "sure" }}', outbound, confirm=(outbound)=>{}, isEdit=false, tags=[] }) {
show({
title = '',
okText = '{{ i18n "sure" }}',
outbound,
confirm = (outbound) => {},
isEdit = false,
tags = []
}) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
@@ -42,11 +48,11 @@
outModal.visible = false;
outModal.loading(false);
},
loading(loading=true) {
loading(loading = true) {
outModal.confirmLoading = loading;
},
check(){
if(outModal.outbound.tag == '' || outModal.tags.includes(outModal.outbound.tag)){
check() {
if (outModal.outbound.tag == '' || outModal.tags.includes(outModal.outbound.tag)) {
this.duplicateTag = true;
this.isValid = false;
} else {
@@ -56,25 +62,25 @@
},
toggleJson(jsonTab) {
textAreaObj = document.getElementById('outboundJson');
if(jsonTab){
if(this.cm != null) {
this.cm.toTextArea();
this.cm=null;
if (jsonTab) {
if (this.cm != null) {
this.cm.toTextArea();
this.cm = null;
}
textAreaObj.value = JSON.stringify(this.outbound.toJson(), null, 2);
this.cm = CodeMirror.fromTextArea(textAreaObj, app.cmOptions);
this.cm.on('change',editor => {
this.cm.on('change', editor => {
value = editor.getValue();
if(this.isJsonString(value)){
if (this.isJsonString(value)) {
this.outbound = Outbound.fromJson(JSON.parse(value));
this.check();
}
});
this.activeKey = '2';
} else {
if(this.cm != null) {
this.cm.toTextArea();
this.cm=null;
if (this.cm != null) {
this.cm.toTextArea();
this.cm = null;
}
this.activeKey = '1';
}
@@ -100,20 +106,21 @@
},
methods: {
streamNetworkChange() {
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound.canEnableTlsFlow()) {
if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound
.canEnableTlsFlow()) {
delete this.outModal.outbound.settings.flow;
}
},
canEnableTls() {
return this.outModal.outbound.canEnableTls();
},
convertLink(){
convertLink() {
newOutbound = Outbound.fromLink(outModal.link);
if(newOutbound){
if (newOutbound) {
this.outModal.outbound = newOutbound;
this.outModal.toggleJson(true);
this.outModal.check();
this.$message.success('Link imported successfully...');
this.$message.success('Link imported successfully...');
outModal.link = '';
} else {
this.$message.error('Wrong Link!');
@@ -122,6 +129,5 @@
},
},
});
</script>
{{end}}
{{end}}

View File

@@ -1,138 +0,0 @@
{{define "modals/reverseModal"}}
<a-modal id="reverse-modal" v-model="reverseModal.visible" :title="reverseModal.title" @ok="reverseModal.ok"
:confirm-loading="reverseModal.confirmLoading" :closable="true" :mask-closable="false"
:ok-text="reverseModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item label='{{ i18n "pages.xray.outbound.type" }}'>
<a-select v-model="reverseModal.reverse.type" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="x,y in reverseTypes" :value="y">[[ x ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.tag" }}'>
<a-input v-model.trim="reverseModal.reverse.tag"></a-input>
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.outbound.domain" }}'>
<a-input v-model.trim="reverseModal.reverse.domain"></a-input>
</a-form-item>
<template v-if="reverseModal.reverse.type=='bridge'">
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
<a-select v-model="reverseModal.rules[0].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
</a-select>
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.rules.outbound" }}'>
<a-select v-model="reverseModal.rules[1].outboundTag" :dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option v-for="x in reverseModal.outboundTags" :value="x">[[ x ]]</a-select-option>
</a-select>
</a-form-item>
</template>
<template v-else>
<a-form-item label='{{ i18n "pages.xray.outbound.intercon" }}'>
<a-checkbox-group
v-model="reverseModal.rules[0].inboundTag"
:options="reverseModal.inboundTags"></a-checkbox-group>
</a-form-item>
<a-form-item label='{{ i18n "pages.xray.rules.inbound" }}'>
<a-checkbox-group
v-model="reverseModal.rules[1].inboundTag"
:options="reverseModal.inboundTags"></a-checkbox-group>
</a-form-item>
</template>
</a-form>
</a-modal>
<script>
const reverseModal = {
title: '',
visible: false,
confirmLoading: false,
okText: '{{ i18n "sure" }}',
isEdit: false,
confirm: null,
reverse: {
tag: "",
type: "",
domain: ""
},
rules: [
{ outboundTag: '', inboundTag: []},
{ outboundTag: '', inboundTag: []}
],
inboundTags: [],
outboundTags: [],
ok() {
reverseModal.rules[0].domain = ["full:" + reverseModal.reverse.domain];
reverseModal.rules[0].type = 'field';
reverseModal.rules[1].type = 'field';
if(reverseModal.reverse.type == 'bridge'){
reverseModal.rules[0].inboundTag = [reverseModal.reverse.tag];
reverseModal.rules[1].inboundTag = [reverseModal.reverse.tag];
} else {
reverseModal.rules[0].outboundTag = reverseModal.reverse.tag;
reverseModal.rules[1].outboundTag = reverseModal.reverse.tag;
}
ObjectUtil.execute(reverseModal.confirm, reverseModal.reverse, reverseModal.rules);
},
show({ title='', okText='{{ i18n "sure" }}', reverse, rules, confirm=(reverse, rules)=>{}, isEdit=false }) {
this.title = title;
this.okText = okText;
this.confirm = confirm;
this.visible = true;
if(isEdit) {
this.reverse = {
tag: reverse.tag,
type: reverse.type,
domain: reverse.domain,
};
reverse;
rules0 = rules.filter(r => r.domain != null);
if(rules0.length == 0) rules0 = [{ outboundTag: '', domain: ["full:" + this.reverse.domain], inboundTag: []}];
rules1 = rules.filter(r => r.domain == null);
if(rules1.length == 0) rules1 = [{ outboundTag: '', inboundTag: []}];
this.rules = [];
this.rules.push({
domain: rules0[0].domain,
outboundTag: rules0[0].outboundTag,
inboundTag: rules0.map(r => r.inboundTag).flat()
});
this.rules.push({
outboundTag: rules1[0].outboundTag,
inboundTag: rules1.map(r => r.inboundTag).flat()
});
} else {
this.reverse = {
tag: "reverse-" + app.reverseData.length,
type: "bridge",
domain: "reverse.xui"
}
this.rules = [
{ outboundTag: '', inboundTag: []},
{ outboundTag: '', inboundTag: []}
]
}
this.isEdit = isEdit;
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj => obj.tag);
this.inboundTags.push(...app.inboundTags);
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
this.outboundTags = app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag);
},
close() {
reverseModal.visible = false;
reverseModal.loading(false);
},
loading(loading=true) {
reverseModal.confirmLoading = loading;
},
};
new Vue({
delimiters: ['[[', ']]'],
el: '#reverse-modal',
data: {
reverseModal: reverseModal,
reverseTypes: { bridge: '{{ i18n "pages.xray.outbound.bridge" }}', portal:'{{ i18n "pages.xray.outbound.portal" }}'},
},
});
</script>
{{end}}

View File

@@ -1,5 +1,7 @@
{{define "modals/ruleModal"}}
<a-modal id="rule-modal" v-model="ruleModal.visible" :title="ruleModal.title" @ok="ruleModal.ok" :confirm-loading="ruleModal.confirmLoading" :closable="true" :mask-closable="false" :ok-text="ruleModal.okText" cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
<a-modal id="rule-modal" v-model="ruleModal.visible" :title="ruleModal.title" @ok="ruleModal.ok"
:confirm-loading="ruleModal.confirmLoading" :closable="true" :mask-closable="false" :ok-text="ruleModal.okText"
cancel-text='{{ i18n "close" }}' :class="themeSwitcher.currentTheme">
<a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
<a-form-item>
<template slot="label">
@@ -42,15 +44,19 @@
</a-select>
</a-form-item>
<a-form-item label='Attributes'>
<a-button icon="plus" size="small" :style="{ marginLeft: '10px' }" @click="ruleModal.rule.attrs.push(['', ''])"></a-button>
<a-button icon="plus" size="small" :style="{ marginLeft: '10px' }"
@click="ruleModal.rule.attrs.push(['', ''])"></a-button>
</a-form-item>
<a-form-item :wrapper-col="{span: 24}">
<a-input-group compact v-for="(attr,index) in ruleModal.rule.attrs">
<a-input :style="{ width: '50%' }" v-model="attr[0]" placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
<a-input :style="{ width: '50%' }" v-model="attr[0]"
placeholder='{{ i18n "pages.inbounds.stream.general.name" }}'>
<template slot="addonBefore" :style="{ margin: '0' }">[[ index+1 ]]</template>
</a-input>
<a-input :style="{ width: '50%' }" v-model="attr[1]" placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small" @click="ruleModal.rule.attrs.splice(index,1)"></a-button>
<a-input :style="{ width: '50%' }" v-model="attr[1]"
placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
<a-button icon="minus" slot="addonAfter" size="small"
@click="ruleModal.rule.attrs.splice(index,1)"></a-button>
</a-input>
</a-input-group>
</a-form-item>
@@ -196,15 +202,20 @@
this.inboundTags = app.templateSettings.inbounds.filter((i) => !ObjectUtil.isEmpty(i.tag)).map(obj => obj.tag);
this.inboundTags.push(...app.inboundTags);
if (app.enableDNS && !ObjectUtil.isEmpty(app.dnsTag)) this.inboundTags.push(app.dnsTag)
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
if (app.templateSettings.reverse) {
if (app.templateSettings.reverse.bridges) {
this.inboundTags.push(...app.templateSettings.reverse.bridges.map(b => b.tag));
}
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag));
this.outboundTags = ["", ...app.templateSettings.outbounds.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj =>
obj.tag)];
if (app.clientReverseTags) {
app.clientReverseTags.forEach(tag => {
if (tag && !this.outboundTags.includes(tag)) {
this.outboundTags.push(tag);
}
});
}
this.balancerTags = [""];
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag))
.map(obj => obj.tag)
];
}
},
close() {
@@ -233,7 +244,8 @@
rule.outboundTag = value.outboundTag == "" ? undefined : value.outboundTag;
rule.balancerTag = value.balancerTag == "" ? undefined : value.balancerTag;
for (const [key, value] of Object.entries(rule)) {
if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0) && !(typeof value === 'object' && Object.keys(value).length === 0) && value !== '') {
if (value !== null && value !== undefined && !(Array.isArray(value) && value.length === 0) && !(
typeof value === 'object' && Object.keys(value).length === 0) && value !== '') {
newRule[key] = value;
}
}
@@ -248,4 +260,4 @@
}
});
</script>
{{end}}
{{end}}

View File

@@ -79,7 +79,8 @@
</template>
{{ template "settings/panel/subscription/general" . }}
</a-tab-pane>
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable" :style="{ paddingTop: '20px' }">
<a-tab-pane key="5" v-if="allSetting.subJsonEnable || allSetting.subClashEnable"
:style="{ paddingTop: '20px' }">
<template #tab>
<a-icon type="code"></a-icon>
<span>{{ i18n "pages.settings.subSettings" }} (Formats)</span>
@@ -102,7 +103,7 @@
{{template "component/aSidebar" .}}
{{template "component/aThemeSwitch" .}}
{{template "component/aSettingListItem" .}}
{{template "modals/twoFactorModal"}}
{{template "modals/twoFactorModal" .}}
<script>
const app = new Vue({
delimiters: ['[[', ']]'],
@@ -124,48 +125,39 @@
user: {},
lang: LanguageManager.getLanguage(),
inboundOptions: [],
remarkModels: { i: 'Inbound', e: 'Email', o: 'Other' },
remarkModels: {
i: 'Inbound',
e: 'Email',
o: 'Other'
},
remarkSeparators: [' ', '-', '_', '@', ':', '~', '|', ',', '.', '/'],
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
datepickerList: [{
name: 'Gregorian (Standard)',
value: 'gregorian'
}, {
name: 'Jalalian (شمسی)',
value: 'jalalian'
}],
remarkSample: '',
defaultFragment: {
tag: "fragment",
protocol: "freedom",
settings: {
domainStrategy: "AsIs",
fragment: {
packets: "tlshello",
length: "100-200",
interval: "10-20",
maxSplit: "300-400"
}
},
streamSettings: {
sockopt: {
tcpKeepAliveIdle: 100,
tcpMptcp: true,
penetrate: true
}
}
},
defaultNoises: {
tag: "noises",
protocol: "freedom",
settings: {
domainStrategy: "AsIs",
noises: [
{ type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" },
],
},
packets: "tlshello",
length: "100-200",
interval: "10-20",
maxSplit: "300-400"
},
defaultNoises: [{
type: "rand",
packet: "10-20",
delay: "10-16",
applyTo: "ip"
}],
defaultMux: {
enabled: true,
concurrency: 8,
xudpConcurrency: 16,
xudpProxyUDP443: "reject"
},
defaultRules: [
{
defaultRules: [{
type: "field",
outboundTag: "direct",
domain: [
@@ -181,26 +173,75 @@
]
},
],
directIPsOptions: [
{ label: 'Private IP', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'geoip:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'geoip:ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
directIPsOptions: [{
label: 'Private IP',
value: 'geoip:private'
},
{
label: '🇮🇷 Iran',
value: 'geoip:ir'
},
{
label: '🇨🇳 China',
value: 'geoip:cn'
},
{
label: '🇷🇺 Russia',
value: 'geoip:ru'
},
{
label: '🇻🇳 Vietnam',
value: 'geoip:vn'
},
{
label: '🇪🇸 Spain',
value: 'geoip:es'
},
{
label: '🇮🇩 Indonesia',
value: 'geoip:id'
},
{
label: '🇺🇦 Ukraine',
value: 'geoip:ua'
},
{
label: '🇹🇷 Türkiye',
value: 'geoip:tr'
},
{
label: '🇧🇷 Brazil',
value: 'geoip:br'
},
],
diretDomainsOptions: [
{ label: 'Private DNS', value: 'geosite:private' },
{ label: '🇮🇷 Iran', value: 'geosite:category-ir' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'geosite:category-ru' },
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Google', value: 'geosite:google' },
diretDomainsOptions: [{
label: 'Private DNS',
value: 'geosite:private'
},
{
label: '🇮🇷 Iran',
value: 'geosite:category-ir'
},
{
label: '🇨🇳 China',
value: 'geosite:cn'
},
{
label: '🇷🇺 Russia',
value: 'geosite:category-ru'
},
{
label: 'Apple',
value: 'geosite:apple'
},
{
label: 'Meta',
value: 'geosite:meta'
},
{
label: 'Google',
value: 'geosite:google'
},
],
get remarkModel() {
rm = this.allSetting.remarkModel;
@@ -338,7 +379,13 @@
this.loading(true);
await PromiseUtil.sleep(5000);
const { webDomain, webPort, webBasePath, webCertFile, webKeyFile } = this.allSetting;
const {
webDomain,
webPort,
webBasePath,
webCertFile,
webKeyFile
} = this.allSetting;
const newProtocol = (webCertFile || webKeyFile) ? "https:" : "http:";
let base = webBasePath ? webBasePath.replace(/^\//, "") : "";
@@ -379,7 +426,8 @@
type: 'set',
confirm: (success) => {
if (success) {
Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalSetSuccess" }}')
Vue.prototype.$message['success'](
'{{ i18n "pages.settings.security.twoFactorModalSetSuccess" }}')
this.allSetting.twoFactorToken = newTwoFactorToken
}
@@ -395,7 +443,8 @@
type: 'confirm',
confirm: (success) => {
if (success) {
Vue.prototype.$message['success']('{{ i18n "pages.settings.security.twoFactorModalDeleteSuccess" }}')
Vue.prototype.$message['success'](
'{{ i18n "pages.settings.security.twoFactorModalDeleteSuccess" }}')
this.allSetting.twoFactorEnable = false
this.allSetting.twoFactorToken = ""
@@ -405,7 +454,12 @@
}
},
addNoise() {
const newNoise = { type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" };
const newNoise = {
type: "rand",
packet: "10-20",
delay: "10-16",
applyTo: "ip"
};
this.noisesArray = [...this.noisesArray, newNoise];
},
removeNoise(index) {
@@ -415,77 +469,99 @@
},
updateNoiseType(index, value) {
const updatedNoises = [...this.noisesArray];
updatedNoises[index] = { ...updatedNoises[index], type: value };
updatedNoises[index] = {
...updatedNoises[index],
type: value
};
this.noisesArray = updatedNoises;
},
updateNoisePacket(index, value) {
const updatedNoises = [...this.noisesArray];
updatedNoises[index] = { ...updatedNoises[index], packet: value };
updatedNoises[index] = {
...updatedNoises[index],
packet: value
};
this.noisesArray = updatedNoises;
},
updateNoiseDelay(index, value) {
const updatedNoises = [...this.noisesArray];
updatedNoises[index] = { ...updatedNoises[index], delay: value };
updatedNoises[index] = {
...updatedNoises[index],
delay: value
};
this.noisesArray = updatedNoises;
},
updateNoiseApplyTo(index, value) {
const updatedNoises = [...this.noisesArray];
updatedNoises[index] = { ...updatedNoises[index], applyTo: value };
updatedNoises[index] = {
...updatedNoises[index],
applyTo: value
};
this.noisesArray = updatedNoises;
},
},
computed: {
ldapInboundTagList: {
get: function () {
get: function() {
const csv = this.allSetting.ldapInboundTags || "";
return csv.length ? csv.split(',').map(s => s.trim()).filter(Boolean) : [];
},
set: function (list) {
set: function(list) {
this.allSetting.ldapInboundTags = Array.isArray(list) ? list.join(',') : '';
}
},
fragment: {
get: function () { return this.allSetting?.subJsonFragment != ""; },
set: function (v) {
get: function() {
return this.allSetting?.subJsonFragment != "";
},
set: function(v) {
this.allSetting.subJsonFragment = v ? JSON.stringify(this.defaultFragment) : "";
}
},
fragmentPackets: {
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.packets : ""; },
set: function (v) {
get: function() {
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).packets : "";
},
set: function(v) {
if (v != "") {
newFragment = JSON.parse(this.allSetting.subJsonFragment);
newFragment.settings.fragment.packets = v;
newFragment.packets = v;
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
}
}
},
fragmentLength: {
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.length : ""; },
set: function (v) {
get: function() {
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).length : "";
},
set: function(v) {
if (v != "") {
newFragment = JSON.parse(this.allSetting.subJsonFragment);
newFragment.settings.fragment.length = v;
newFragment.length = v;
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
}
}
},
fragmentInterval: {
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.interval : ""; },
set: function (v) {
get: function() {
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).interval : "";
},
set: function(v) {
if (v != "") {
newFragment = JSON.parse(this.allSetting.subJsonFragment);
newFragment.settings.fragment.interval = v;
newFragment.interval = v;
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
}
}
},
fragmentMaxSplit: {
get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.maxSplit : ""; },
set: function (v) {
get: function() {
return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).maxSplit : "";
},
set: function(v) {
if (v != "") {
newFragment = JSON.parse(this.allSetting.subJsonFragment);
newFragment.settings.fragment.maxSplit = v;
newFragment.maxSplit = v;
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
}
}
@@ -504,61 +580,69 @@
},
noisesArray: {
get() {
return this.noises ? JSON.parse(this.allSetting.subJsonNoises).settings.noises : [];
return this.noises ? JSON.parse(this.allSetting.subJsonNoises) : [];
},
set(value) {
if (this.noises) {
const newNoises = JSON.parse(this.allSetting.subJsonNoises);
newNoises.settings.noises = value;
this.allSetting.subJsonNoises = JSON.stringify(newNoises);
this.allSetting.subJsonNoises = JSON.stringify(value);
}
}
},
enableMux: {
get: function () { return this.allSetting?.subJsonMux != ""; },
set: function (v) {
get: function() {
return this.allSetting?.subJsonMux != "";
},
set: function(v) {
this.allSetting.subJsonMux = v ? JSON.stringify(this.defaultMux) : "";
}
},
muxConcurrency: {
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).concurrency : -1; },
set: function (v) {
get: function() {
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).concurrency : -1;
},
set: function(v) {
newMux = JSON.parse(this.allSetting.subJsonMux);
newMux.concurrency = v;
this.allSetting.subJsonMux = JSON.stringify(newMux);
}
},
muxXudpConcurrency: {
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpConcurrency : -1; },
set: function (v) {
get: function() {
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpConcurrency : -1;
},
set: function(v) {
newMux = JSON.parse(this.allSetting.subJsonMux);
newMux.xudpConcurrency = v;
this.allSetting.subJsonMux = JSON.stringify(newMux);
}
},
muxXudpProxyUDP443: {
get: function () { return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpProxyUDP443 : "reject"; },
set: function (v) {
get: function() {
return this.enableMux ? JSON.parse(this.allSetting.subJsonMux).xudpProxyUDP443 : "reject";
},
set: function(v) {
newMux = JSON.parse(this.allSetting.subJsonMux);
newMux.xudpProxyUDP443 = v;
this.allSetting.subJsonMux = JSON.stringify(newMux);
}
},
enableDirect: {
get: function () { return this.allSetting?.subJsonRules != ""; },
set: function (v) {
get: function() {
return this.allSetting?.subJsonRules != "";
},
set: function(v) {
this.allSetting.subJsonRules = v ? JSON.stringify(this.defaultRules) : "";
}
},
directIPs: {
get: function () {
get: function() {
if (!this.enableDirect) return [];
const rules = JSON.parse(this.allSetting.subJsonRules);
if (!Array.isArray(rules)) return [];
const ipRule = rules.find(r => r.ip);
return ipRule?.ip ?? [];
},
set: function (v) {
set: function(v) {
let rules = JSON.parse(this.allSetting.subJsonRules);
if (!Array.isArray(rules)) return;
@@ -577,14 +661,14 @@
}
},
directDomains: {
get: function () {
get: function() {
if (!this.enableDirect) return [];
const rules = JSON.parse(this.allSetting.subJsonRules);
if (!Array.isArray(rules)) return [];
const domainRule = rules.find(r => r.domain);
return domainRule?.domain ?? [];
},
set: function (v) {
set: function(v) {
let rules = JSON.parse(this.allSetting.subJsonRules);
if (!Array.isArray(rules)) return;
if (v.length == 0) {
@@ -599,7 +683,7 @@
}
},
confAlerts: {
get: function () {
get: function() {
if (!this.allSetting) return [];
var alerts = []
if (window.location.protocol !== "https:") alerts.push('{{ i18n "secAlertSSL" }}');
@@ -607,11 +691,13 @@
panelPath = window.location.pathname.split('/').length < 4
if (panelPath && this.allSetting.webBasePath == '/') alerts.push('{{ i18n "secAlertPanelURI" }}');
if (this.allSetting.subEnable) {
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this.allSetting.subPath;
subPath = this.allSetting.subURI.length > 0 ? new URL(this.allSetting.subURI).pathname : this
.allSetting.subPath;
if (subPath == '/sub/') alerts.push('{{ i18n "secAlertSubURI" }}');
}
if (this.allSetting.subJsonEnable) {
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname : this.allSetting.subJsonPath;
subJsonPath = this.allSetting.subJsonURI.length > 0 ? new URL(this.allSetting.subJsonURI).pathname :
this.allSetting.subJsonPath;
if (subJsonPath == '/json/') alerts.push('{{ i18n "secAlertSubJsonURI" }}');
}
return alerts

View File

@@ -124,6 +124,13 @@
v-model="allSetting.externalTrafficInformURI"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.settings.restartXrayOnClientDisable"}}</template>
<template #description>{{ i18n "pages.settings.restartXrayOnClientDisableDesc"}}</template>
<template #control>
<a-switch v-model="allSetting.restartXrayOnClientDisable"></a-switch>
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="5" header='{{ i18n "pages.settings.dateAndTime" }}'>
<a-setting-list-item paddings="small">
@@ -162,7 +169,8 @@
<a-setting-list-item paddings="small">
<template #title>LDAP Port</template>
<template #control>
<a-input-number :min="1" :max="65535" v-model="allSetting.ldapPort" :style="{ width: '100%' }"></a-input-number>
<a-input-number :min="1" :max="65535" v-model="allSetting.ldapPort"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
@@ -239,10 +247,13 @@
<template #title>Inbound tags</template>
<template #description>Select inbounds to manage (auto create/delete)</template>
<template #control>
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }" v-model="ldapInboundTagList">
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">[[ opt.label ]]</a-select-option>
<a-select mode="multiple" :dropdown-class-name="themeSwitcher.currentTheme" :style="{ width: '100%' }"
v-model="ldapInboundTagList">
<a-select-option v-for="opt in inboundOptions" :key="opt.value" :value="opt.value">[[ opt.label
]]</a-select-option>
</a-select>
<div v-if="inboundOptions.length==0" style="margin-top:6px;color:#999">No inbounds found. Please create one in Inbounds.</div>
<div v-if="inboundOptions.length==0" style="margin-top:6px;color:#999">No inbounds found. Please create
one in Inbounds.</div>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
@@ -260,19 +271,22 @@
<a-setting-list-item paddings="small">
<template #title>Default total (GB)</template>
<template #control>
<a-input-number :min="0" v-model="allSetting.ldapDefaultTotalGB" :style="{ width: '100%' }"></a-input-number>
<a-input-number :min="0" v-model="allSetting.ldapDefaultTotalGB"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>Default expiry (days)</template>
<template #control>
<a-input-number :min="0" v-model="allSetting.ldapDefaultExpiryDays" :style="{ width: '100%' }"></a-input-number>
<a-input-number :min="0" v-model="allSetting.ldapDefaultExpiryDays"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
<template #title>Default Limit IP</template>
<template #control>
<a-input-number :min="0" v-model="allSetting.ldapDefaultLimitIP" :style="{ width: '100%' }"></a-input-number>
<a-input-number :min="0" v-model="allSetting.ldapDefaultLimitIP"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
</a-collapse-panel>

View File

@@ -46,8 +46,7 @@
<template #description>{{ i18n
"pages.settings.subPortDesc"}}</template>
<template #control>
<a-input-number v-model="allSetting.subPort" :min="1"
:min="65535"
<a-input-number v-model="allSetting.subPort" :min="1" :min="65535"
:style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
@@ -67,8 +66,7 @@
<template #description>{{ i18n
"pages.settings.subURIDesc"}}</template>
<template #control>
<a-input type="text"
placeholder="(http|https)://domain[:port]/path/"
<a-input type="text" placeholder="(http|https)://domain[:port]/path/"
v-model="allSetting.subURI"></a-input>
</template>
</a-setting-list-item>
@@ -104,8 +102,7 @@
<template #description>{{ i18n
"pages.settings.subSupportUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subSupportUrl"
placeholder="https://example.com"></a-input>
<a-input type="text" v-model="allSetting.subSupportUrl" placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
@@ -113,8 +110,7 @@
<template #description>{{ i18n
"pages.settings.subProfileUrlDesc"}}</template>
<template #control>
<a-input type="text" v-model="allSetting.subProfileUrl"
placeholder="https://example.com"></a-input>
<a-input type="text" v-model="allSetting.subProfileUrl" placeholder="https://example.com"></a-input>
</template>
</a-setting-list-item>
<a-setting-list-item paddings="small">
@@ -141,8 +137,7 @@
<template #description>{{ i18n
"pages.settings.subRoutingRulesDesc"}}</template>
<template #control>
<a-textarea v-model="allSetting.subRoutingRules"
placeholder="happ://routing/add/..."></a-textarea>
<a-textarea v-model="allSetting.subRoutingRules" placeholder="happ://routing/add/..."></a-textarea>
</template>
</a-setting-list-item>
</a-collapse-panel>
@@ -170,8 +165,7 @@
<template #description>{{ i18n
"pages.settings.subUpdatesDesc"}}</template>
<template #control>
<a-input-number :min="1" v-model="allSetting.subUpdates"
:style="{ width: '100%' }"></a-input-number>
<a-input-number :min="1" v-model="allSetting.subUpdates" :style="{ width: '100%' }"></a-input-number>
</template>
</a-setting-list-item>
</a-collapse-panel>

View File

@@ -6,6 +6,23 @@
<script src="{{ .base_path }}assets/js/util/index.js?{{ .cur_ver }}"></script>
<script src="{{ .base_path }}assets/qrcode/qrious2.min.js?{{ .cur_ver }}"></script>
<style>
.subscription-page tr-qr-box.qr-box {
display: inline-flex;
flex-direction: column;
align-items: center;
width: 220px;
}
.subscription-page tr-qr-box.qr-box .qr-tag {
width: 100%;
justify-content: center;
}
.subscription-page tr-qr-box.qr-box .qr-bg,
.subscription-page tr-qr-box.qr-box .qr-bg-sub {
margin-inline: auto;
}
.subscription-page .subscription-link-box {
cursor: pointer;
border-radius: 12px;
@@ -83,7 +100,8 @@
<a-form-item>
<a-space direction="vertical" align="center">
<a-row type="flex" :gutter="[8,8]" justify="center" style="width:100%">
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24" style="text-align:center;">
<a-col :xs="24" :sm="app.subJsonUrl || app.subClashUrl ? 12 : 24"
style="text-align:center;">
<tr-qr-box class="qr-box">
<a-tag color="purple" class="qr-tag">
<span>{{ i18n
@@ -135,7 +153,10 @@
app.sId
]]</a-descriptions-item>
<a-descriptions-item label='{{ i18n "subscription.status" }}'>
<template v-if="isUnlimited">
<template v-if="!app.enabled">
<a-tag color="red">{{ i18n "subscription.inactive" }}</a-tag>
</template>
<template v-else-if="isUnlimited">
<a-tag color="purple">{{ i18n
"subscription.unlimited" }}</a-tag>
</template>
@@ -193,8 +214,6 @@
</div>
</div>
</div>
</div>
<br />
<a-form layout="vertical">
@@ -255,11 +274,11 @@
</a-layout>
<!-- Bootstrap data for external JS -->
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}" data-subclash-url="{{ .subClashUrl }}"
data-download="{{ .download }}" data-upload="{{ .upload }}" data-used="{{ .used }}" data-total="{{ .total }}"
data-remained="{{ .remained }}" data-expire="{{ .expire }}" data-lastonline="{{ .lastOnline }}"
data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}" data-totalbyte="{{ .totalByte }}"
data-datepicker="{{ .datepicker }}"></template>
<template id="subscription-data" data-sid="{{ .sId }}" data-sub-url="{{ .subUrl }}" data-subjson-url="{{ .subJsonUrl }}"
data-subclash-url="{{ .subClashUrl }}" data-download="{{ .download }}" data-upload="{{ .upload }}"
data-used="{{ .used }}" data-total="{{ .total }}" data-remained="{{ .remained }}" data-expire="{{ .expire }}"
data-lastonline="{{ .lastOnline }}" data-downloadbyte="{{ .downloadByte }}" data-uploadbyte="{{ .uploadByte }}"
data-totalbyte="{{ .totalByte }}" data-datepicker="{{ .datepicker }}" data-enabled="{{ .enabled }}"></template>
<textarea id="subscription-links" style="display:none">{{ range .result }}{{ . }}
{{ end }}</textarea>

View File

@@ -5,7 +5,8 @@
<span>{{ i18n "pages.xray.balancer.addBalancer"}}</span>
</a-button>
<a-table :columns="balancerColumns" bordered :row-key="r => r.key" :data-source="balancersData"
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0" :locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, balancer, index">
<span>[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
@@ -18,7 +19,7 @@
</a-menu-item>
<a-menu-item @click="deleteBalancer(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
</span>
</a-menu-item>
@@ -32,7 +33,8 @@
<a-tag :style="{ margin: '0' }" v-if="balancer.strategy=='leastPing'" color="green">Least Ping</a-tag>
</template>
<template slot="selector" slot-scope="text, balancer, index">
<a-tag class="info-large-tag" :style="{ margin: '1' }" v-for="sel in balancer.selector">[[ sel ]]</a-tag>
<a-tag class="info-large-tag" :style="{ margin: '1' }" v-for="sel in balancer.selector">[[ sel
]]</a-tag>
</template>
</a-table>
<a-radio-group v-if="observatoryEnable || burstObservatoryEnable" v-model="obsSettings" @change="changeObsCode"

View File

@@ -4,8 +4,7 @@
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.generalConfigsDesc" }}</span>
</template>
</a-alert>
@@ -15,11 +14,9 @@
<template #description>{{ i18n "pages.xray.FreedomStrategyDesc"
}}</template>
<template #control>
<a-select v-model="freedomStrategy"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="freedomStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option v-for="s in OutboundDomainStrategies"
:value="s">
<a-select-option v-for="s in OutboundDomainStrategies" :value="s">
<span>[[ s ]]</span>
</a-select-option>
</a-select>
@@ -30,11 +27,9 @@
<template #description>{{ i18n "pages.xray.RoutingStrategyDesc"
}}</template>
<template #control>
<a-select v-model="routingStrategy"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="routingStrategy" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option v-for="s in routingDomainStrategies"
:value="s">
<a-select-option v-for="s in routingDomainStrategies" :value="s">
<span>[[ s ]]</span>
</a-select-option>
</a-select>
@@ -45,8 +40,7 @@
<template #description>{{ i18n "pages.xray.outboundTestUrlDesc"
}}</template>
<template #control>
<a-input v-model="outboundTestUrl"
:placeholder="'https://www.google.com/generate_204'"
<a-input v-model="outboundTestUrl" :placeholder="'https://www.google.com/generate_204'"
:style="{ width: '100%' }"></a-input>
</template>
</a-setting-list-item>
@@ -93,8 +87,7 @@
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.logConfigsDesc" }}</span>
</template>
</a-alert>
@@ -104,8 +97,7 @@
<template #description>{{ i18n "pages.xray.logLevelDesc"
}}</template>
<template #control>
<a-select v-model="logLevel"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="logLevel" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option v-for="s in log.loglevel" :value="s">
<span>[[ s ]]</span>
@@ -118,8 +110,7 @@
<template #description>{{ i18n "pages.xray.accessLogDesc"
}}</template>
<template #control>
<a-select v-model="accessLog"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="accessLog" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option value>
<span>Empty</span>
@@ -135,8 +126,7 @@
<template #description>{{ i18n "pages.xray.errorLogDesc"
}}</template>
<template #control>
<a-select v-model="errorLog"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="errorLog" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option value>
<span>Empty</span>
@@ -152,8 +142,7 @@
<template #description>{{ i18n "pages.xray.maskAddressDesc"
}}</template>
<template #control>
<a-select v-model="maskAddressLog"
:dropdown-class-name="themeSwitcher.currentTheme"
<a-select v-model="maskAddressLog" :dropdown-class-name="themeSwitcher.currentTheme"
:style="{ width: '100%' }">
<a-select-option value>
<span>Empty</span>
@@ -176,8 +165,7 @@
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.blockConfigsDesc" }}</span>
</template>
</a-alert>
@@ -191,8 +179,7 @@
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning" :style="{ textAlign: 'center' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.blockConnectionsConfigsDesc"
}}</span>
</template>
@@ -201,11 +188,9 @@
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.blockips" }}</template>
<template #control>
<a-select mode="tags" v-model="blockedIPs"
:style="{ width: '100%' }"
<a-select mode="tags" v-model="blockedIPs" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.IPsOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
@@ -214,22 +199,18 @@
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.blockdomains" }}</template>
<template #control>
<a-select mode="tags" v-model="blockedDomains"
:style="{ width: '100%' }"
<a-select mode="tags" v-model="blockedDomains" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.BlockDomainsOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.BlockDomainsOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
</template>
</a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.directConnectionsConfigsDesc"
}}</span>
</template>
@@ -238,11 +219,9 @@
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.directips" }}</template>
<template #control>
<a-select mode="tags" :style="{ width: '100%' }"
v-model="directIPs"
<a-select mode="tags" :style="{ width: '100%' }" v-model="directIPs"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.IPsOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.IPsOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
@@ -251,22 +230,18 @@
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.directdomains" }}</template>
<template #control>
<a-select mode="tags" :style="{ width: '100%' }"
v-model="directDomains"
<a-select mode="tags" :style="{ width: '100%' }" v-model="directDomains"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.DomainsOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.DomainsOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
</template>
</a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
<span>{{ i18n "pages.xray.ipv4RoutingDesc" }}</span>
</template>
</a-alert>
@@ -274,22 +249,18 @@
<a-setting-list-item paddings="small">
<template #title>{{ i18n "pages.xray.ipv4Routing" }}</template>
<template #control>
<a-select mode="tags" :style="{ width: '100%' }"
v-model="ipv4Domains"
<a-select mode="tags" :style="{ width: '100%' }" v-model="ipv4Domains"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.ServicesOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
</template>
</a-setting-list-item>
<a-row :xs="24" :sm="24" :lg="12">
<a-alert type="warning"
:style="{ textAlign: 'center', marginTop: '20px' }">
<a-alert type="warning" :style="{ textAlign: 'center', marginTop: '20px' }">
<template slot="message">
<a-icon type="exclamation-circle" theme="filled"
:style="{ color: '#FFA031' }"></a-icon>
<a-icon type="exclamation-circle" theme="filled" :style="{ color: '#FFA031' }"></a-icon>
{{ i18n "pages.xray.warpRoutingDesc" }}
</template>
</a-alert>
@@ -298,18 +269,15 @@
<template #title>{{ i18n "pages.xray.warpRouting" }}</template>
<template #control>
<template v-if="WarpExist">
<a-select mode="tags" :style="{ width: '100%' }"
v-model="warpDomains"
<a-select mode="tags" :style="{ width: '100%' }" v-model="warpDomains"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.ServicesOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
</template>
<template v-else>
<a-button type="primary" icon="cloud"
@click="showWarp()">WARP</a-button>
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
</template>
</template>
</a-setting-list-item>
@@ -317,11 +285,9 @@
<template #title>{{ i18n "pages.xray.nordRouting" }}</template>
<template #control>
<template v-if="NordExist">
<a-select mode="tags" :style="{ width: '100%' }"
v-model="nordDomains"
<a-select mode="tags" :style="{ width: '100%' }" v-model="nordDomains"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="p.value" :label="p.label"
v-for="p in settingsData.ServicesOptions">
<a-select-option :value="p.value" :label="p.label" v-for="p in settingsData.ServicesOptions">
<span>[[ p.label ]]</span>
</a-select-option>
</a-select>
@@ -333,8 +299,7 @@
</template>
</a-setting-list-item>
</a-collapse-panel>
<a-collapse-panel key="6"
header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
<a-collapse-panel key="6" header='{{ i18n "pages.settings.resetDefaultConfig"}}'>
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button type="danger" @click="resetXrayConfigToDefault">
<span>{{ i18n "pages.settings.resetDefaultConfig" }}</span>

View File

@@ -29,7 +29,8 @@
<template #control>
<a-select v-model="dnsStrategy" :style="{ width: '100%' }"
:dropdown-class-name="themeSwitcher.currentTheme">
<a-select-option :value="l" :label="l" v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']">
<a-select-option :value="l" :label="l"
v-for="l in ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6']">
<span>[[ l ]]</span>
</a-select-option>
</a-select>

View File

@@ -1,129 +1,159 @@
{{define "settings/xray/outbounds"}}
<a-space direction="vertical" size="middle">
<a-row>
<a-col :xs="12" :sm="12" :lg="12">
<a-space direction="horizontal" size="small">
<a-space direction="vertical" size="middle" class="outbounds-modern">
<a-row :gutter="[12, 12]" align="middle" justify="space-between">
<a-col :xs="24" :sm="14" :lg="14">
<a-space direction="horizontal" size="small" class="outbounds-toolbar">
<a-button type="primary" icon="plus" @click="addOutbound">
<span v-if="!isMobile">{{ i18n
"pages.xray.outbound.addOutbound" }}</span>
</a-button>
<a-button type="primary" icon="cloud"
@click="showWarp()">WARP</a-button>
<a-button type="primary" icon="api"
@click="showNord()">NordVPN</a-button>
<a-button type="primary" icon="cloud" @click="showWarp()">WARP</a-button>
<a-button type="primary" icon="api" @click="showNord()">NordVPN</a-button>
</a-space>
</a-col>
<a-col :xs="12" :sm="12" :lg="12" :style="{ textAlign: 'right' }">
<a-col :xs="24" :sm="10" :lg="10" class="outbounds-toolbar-right">
<a-button-group>
<a-button icon="sync" @click="refreshOutboundTraffic()"
:loading="refreshing"></a-button>
<a-popconfirm placement="topRight"
@confirm="resetOutboundTraffic(-1)"
<a-button icon="sync" @click="refreshOutboundTraffic()" :loading="refreshing"></a-button>
<a-popconfirm placement="topRight" @confirm="resetOutboundTraffic(-1)"
title='{{ i18n "pages.inbounds.resetTrafficContent"}}'
:overlay-class-name="themeSwitcher.currentTheme"
ok-text='{{ i18n "reset"}}'
:overlay-class-name="themeSwitcher.currentTheme" ok-text='{{ i18n "reset"}}'
cancel-text='{{ i18n "cancel"}}'>
<a-icon slot="icon" type="question-circle-o"
:style="{ color: themeSwitcher.isDarkTheme ? '#008771' : '#008771' }"></a-icon>
:style="{ color: '#008771' }"></a-icon>
<a-button icon="retweet"></a-button>
</a-popconfirm>
</a-button-group>
</a-col>
</a-row>
<a-table :columns="outboundColumns" bordered :row-key="r => r.key"
<a-table :columns="outboundColumns" :row-key="r => r.key"
:data-source="outboundData"
:scroll="isMobile ? {} : { x: 800 }" :pagination="false"
:scroll="isMobile ? { x: 720 } : {}"
:pagination="false"
:indent-size="0"
class="outbounds-table"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, outbound, index">
<span>[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0"
@click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item>
<a-menu-item @click="editOutbound(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="resetOutboundTraffic(index)">
<span>
<div class="outbound-action-cell">
<span class="outbound-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="outbound-action-btn"
@click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0"
@click="setFirstOutbound(index)">
<a-icon type="vertical-align-top"></a-icon>
<span>{{ i18n "pages.xray.rules.first"}}</span>
</a-menu-item>
<a-menu-item @click="editOutbound(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="resetOutboundTraffic(index)">
<a-icon type="retweet"></a-icon>
<span>{{ i18n "pages.inbounds.resetTraffic"}}</span>
</a-menu-item>
<a-menu-item @click="deleteOutbound(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<template slot="identity" slot-scope="text, outbound">
<div class="outbound-identity-cell">
<a-tooltip :title="outbound.tag" :overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-tag">[[ outbound.tag ]]</span>
</a-tooltip>
<div class="outbound-protocol-cell">
<span class="outbound-pill"
:class="outboundProtocolTone(outbound.protocol)">
[[ outbound.protocol ]]
</span>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<span class="outbound-pill"
:class="outboundNetworkTone(outbound.streamSettings.network)">
[[ outbound.streamSettings.network ]]
</span>
</a-menu-item>
<a-menu-item @click="deleteOutbound(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
<span class="outbound-pill"
:class="outboundSecurityTone(outbound.streamSettings.security)"
v-if="isOutboundSecurityVisible(outbound.streamSettings.security)">
[[ outbound.streamSettings.security ]]
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
</div>
</div>
</template>
<template slot="address" slot-scope="text, outbound, index">
<p :style="{ margin: '0 5px' }"
v-for="addr in findOutboundAddress(outbound)">[[ addr ]]</p>
<template slot="address" slot-scope="text, outbound">
<div class="outbound-address-list">
<a-tooltip
v-for="addr in outboundAddresses(outbound)"
:key="addr"
:title="addr"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-address-pill">[[ addr ]]</span>
</a-tooltip>
<span class="outbound-address-empty"
v-if="outboundAddresses(outbound).length === 0"></span>
</div>
</template>
<template slot="protocol" slot-scope="text, outbound, index">
<a-tag :style="{ margin: '0' }" color="purple">[[ outbound.protocol
]]</a-tag>
<template
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(outbound.protocol)">
<a-tag :style="{ margin: '0' }" color="blue">[[
outbound.streamSettings.network ]]</a-tag>
<a-tag :style="{ margin: '0' }"
v-if="outbound.streamSettings.security=='tls'"
color="green">tls</a-tag>
<a-tag :style="{ margin: '0' }"
v-if="outbound.streamSettings.security=='reality'"
color="green">reality</a-tag>
</template>
</template>
<template slot="traffic" slot-scope="text, outbound, index">
<a-tag color="green">[[ findOutboundTraffic(outbound) ]]</a-tag>
<template slot="traffic" slot-scope="text, outbound">
<div class="outbound-traffic-cell">
<span class="outbound-traffic-up" :title='`{{ i18n "pages.index.upload" }}`'>
<a-icon type="arrow-up"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundUp(outbound)) ]]
</span>
<span class="outbound-traffic-sep" aria-hidden="true"></span>
<span class="outbound-traffic-down" :title='`{{ i18n "pages.index.download" }}`'>
<a-icon type="arrow-down"></a-icon>
[[ SizeFormatter.sizeFormat(findOutboundDown(outbound)) ]]
</span>
</div>
</template>
<template slot="test" slot-scope="text, outbound, index">
<a-tooltip>
<template slot="title">{{ i18n "pages.xray.outbound.test"
}}</template>
<template slot="title">{{ i18n "pages.xray.outbound.test" }}</template>
<a-button
type="primary"
shape="circle"
icon="thunderbolt"
:loading="outboundTestStates[index] && outboundTestStates[index].testing"
class="outbound-test-btn"
:loading="isOutboundTesting(index)"
@click="testOutbound(index)"
:disabled="(outbound.protocol === 'blackhole' || outbound.tag === 'blocked') || (outboundTestStates[index] && outboundTestStates[index].testing)">
:disabled="isOutboundUntestable(outbound) || isOutboundTesting(index)">
</a-button>
</a-tooltip>
</template>
<template slot="testResult" slot-scope="text, outbound, index">
<div
v-if="outboundTestStates[index] && outboundTestStates[index].result">
<a-tag v-if="outboundTestStates[index].result.success"
color="green">
[[ outboundTestStates[index].result.delay ]]ms
<span v-if="outboundTestStates[index].result.statusCode">
([[ outboundTestStates[index].result.statusCode
]])</span>
</a-tag>
<div class="outbound-result-cell" v-if="outboundTestResult(index)">
<span v-if="outboundTestResult(index).success"
class="outbound-result-pill outbound-result-ok">
<a-icon type="check-circle" theme="filled"></a-icon>
[[ outboundTestResult(index).delay ]]&nbsp;ms
<span class="outbound-result-status"
v-if="outboundTestResult(index).statusCode">
· [[ outboundTestResult(index).statusCode ]]
</span>
</span>
<a-tooltip v-else
:title="outboundTestStates[index].result.error">
<a-tag color="red">
Failed
</a-tag>
:title="outboundTestResult(index).error"
:overlay-class-name="themeSwitcher.currentTheme">
<span class="outbound-result-pill outbound-result-fail">
<a-icon type="close-circle" theme="filled"></a-icon>
{{ i18n "pages.xray.outbound.testFailed" }}
</span>
</a-tooltip>
</div>
<span
v-else-if="outboundTestStates[index] && outboundTestStates[index].testing">
<a-icon type="loading" />
<span class="outbound-result-loading" v-else-if="isOutboundTesting(index)">
<a-icon type="loading"></a-icon>
</span>
<span v-else>-</span>
<span class="outbound-result-idle" v-else></span>
</template>
</a-table>
</a-space>
{{end}}
{{end}}

View File

@@ -1,39 +0,0 @@
{{define "settings/xray/reverse"}}
<template v-if="reverseData.length > 0">
<a-space direction="vertical" size="middle">
<a-button type="primary" icon="plus" @click="addReverse()">
<span>{{ i18n "pages.xray.outbound.addReverse" }}</span>
</a-button>
<a-table :columns="reverseColumns" bordered :row-key="r => r.key" :data-source="reverseData"
:scroll="isMobile ? {} : { x: 200 }" :pagination="false" :indent-size="0"
:locale='{ filterConfirm: `{{ i18n "confirm" }}`, filterReset: `{{ i18n "reset" }}` }'>
<template slot="action" slot-scope="text, reverse, index">
<span>[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item @click="editReverse(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="deleteReverse(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete"}}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</template>
</a-table>
</a-space>
</template>
<template v-else>
<a-empty description='{{ i18n "emptyReverseDesc" }}' :style="{ margin: '10px' }">
<a-button type="primary" icon="plus" @click="addReverse()" :style="{ marginTop: '10px' }">
{{ i18n "pages.xray.outbound.addReverse" }}
</a-button>
</a-empty>
</template>
{{end}}

View File

@@ -1,123 +1,193 @@
{{define "settings/xray/routing"}}
<a-space direction="vertical" size="middle">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n "pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns" bordered :row-key="r => r.key"
:data-source="routingRuleData" :scroll="isMobile ? {} : { x: 1000 }" :pagination="false" :indent-size="0"
<a-space direction="vertical" size="middle" class="routing-modern">
<a-button type="primary" icon="plus" @click="addRule">{{ i18n
"pages.xray.rules.add" }}</a-button>
<a-table-sortable :columns="isMobile ? rulesMobileColumns : rulesColumns"
:row-key="r => r.key"
:data-source="routingRuleData"
:scroll="{}"
:pagination="false"
:indent-size="0"
class="routing-table"
v-on:onSort="replaceRule">
<template slot="action" slot-scope="text, rule, index">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="ant-table-row-index"> [[ index+1 ]] </span>
<a-dropdown :trigger="['click']">
<a-icon @click="e => e.preventDefault()" type="more"
:style="{ fontSize: '16px', textDecoration: 'bold' }"></a-icon>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item v-if="index>0" @click="replaceRule(index,0)">
<a-icon type="vertical-align-top"></a-icon>
{{ i18n "pages.xray.rules.first"}}
</a-menu-item>
<a-menu-item v-if="index>0" @click="replaceRule(index,index-1)">
<a-icon type="arrow-up"></a-icon>
{{ i18n "pages.xray.rules.up"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1" @click="replaceRule(index,index+1)">
<a-icon type="arrow-down"></a-icon>
{{ i18n "pages.xray.rules.down"}}
</a-menu-item>
<a-menu-item v-if="index<routingRuleData.length-1"
@click="replaceRule(index,routingRuleData.length-1)">
<a-icon type="vertical-align-bottom"></a-icon>
{{ i18n "pages.xray.rules.last"}}
</a-menu-item>
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
{{ i18n "edit" }}
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon> {{ i18n "delete"}}
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
<div class="routing-action-cell">
<a-table-sort-trigger :item-index="index"></a-table-sort-trigger>
<span class="routing-index">[[ index+1 ]]</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small" class="routing-action-btn"
@click="e => e.preventDefault()">
<a-icon type="more"></a-icon>
</a-button>
<a-menu slot="overlay" :theme="themeSwitcher.currentTheme">
<a-menu-item @click="editRule(index)">
<a-icon type="edit"></a-icon>
<span>{{ i18n "edit" }}</span>
</a-menu-item>
<a-menu-item @click="deleteRule(index)">
<span :style="{ color: '#FF4D4F' }">
<a-icon type="delete"></a-icon>
<span>{{ i18n "delete" }}</span>
</span>
</a-menu-item>
</a-menu>
</a-dropdown>
</div>
</template>
<template slot="inbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.inboundTag">Inbound Tag: [[ rule.inboundTag ]]</p>
<p v-if="rule.user">User email: [[ rule.user ]]</p>
</template>
[[ [rule.inboundTag,rule.user].join('\n') ]]
</a-popover>
<template slot="source" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.sourceIP"
:title="'Source IP: ' + joinCsv(rule.sourceIP)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.sourceIP)[0] ]]</span>
<span v-if="csv(rule.sourceIP).length > 1" class="criterion-more">+[[ csv(rule.sourceIP).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.sourcePort"
:title="'Source Port: ' + joinCsv(rule.sourcePort)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.sourcePort)[0] ]]</span>
<span v-if="csv(rule.sourcePort).length > 1" class="criterion-more">+[[ csv(rule.sourcePort).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.vlessRoute"
:title="'VLESS Route: ' + joinCsv(rule.vlessRoute)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">VLESS</span>
<span class="criterion-value">[[ csv(rule.vlessRoute)[0] ]]</span>
<span v-if="csv(rule.vlessRoute).length > 1" class="criterion-more">+[[ csv(rule.vlessRoute).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.sourceIP && !rule.sourcePort && !rule.vlessRoute"></span>
</div>
</template>
<template slot="outbound" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.outboundTag">Outbound Tag: [[ rule.outboundTag ]]</p>
</template>
[[ rule.outboundTag ]]
</a-popover>
<template slot="network" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.network"
:title="'L4: ' + joinCsv(rule.network)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">L4</span>
<span class="criterion-value">[[ csv(rule.network)[0] ]]</span>
<span v-if="csv(rule.network).length > 1" class="criterion-more">+[[ csv(rule.network).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.protocol"
:title="'Protocol: ' + joinCsv(rule.protocol)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Protocol</span>
<span class="criterion-value">[[ csv(rule.protocol)[0] ]]</span>
<span v-if="csv(rule.protocol).length > 1" class="criterion-more">+[[ csv(rule.protocol).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.attrs"
:title="'Attrs: ' + joinCsv(rule.attrs)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Attrs</span>
<span class="criterion-value">[[ csv(rule.attrs)[0] ]]</span>
<span v-if="csv(rule.attrs).length > 1" class="criterion-more">+[[ csv(rule.attrs).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.network && !rule.protocol && !rule.attrs"></span>
</div>
</template>
<template slot="balancer" slot-scope="text, rule, index">
<a-popover :overlay-class-name="themeSwitcher.currentTheme">
<template slot="content">
<p v-if="rule.balancerTag">Balancer Tag: [[ rule.balancerTag ]]</p>
</template>
[[ rule.balancerTag ]]
</a-popover>
<template slot="destination" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.ip"
:title="'Destination IP: ' + joinCsv(rule.ip)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">IP</span>
<span class="criterion-value">[[ csv(rule.ip)[0] ]]</span>
<span v-if="csv(rule.ip).length > 1" class="criterion-more">+[[ csv(rule.ip).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.domain"
:title="'Domain: ' + joinCsv(rule.domain)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Domain</span>
<span class="criterion-value">[[ csv(rule.domain)[0] ]]</span>
<span v-if="csv(rule.domain).length > 1" class="criterion-more">+[[ csv(rule.domain).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.port"
:title="'Destination Port: ' + joinCsv(rule.port)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Port</span>
<span class="criterion-value">[[ csv(rule.port)[0] ]]</span>
<span v-if="csv(rule.port).length > 1" class="criterion-more">+[[ csv(rule.port).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.ip && !rule.domain && !rule.port"></span>
</div>
</template>
<template slot="info" slot-scope="text, rule, index">
<a-popover placement="bottomRight"
v-if="(rule.sourceIP+rule.sourcePort+rule.vlessRoute+rule.network+rule.protocol+rule.attrs+rule.ip+rule.domain+rule.port).length>0"
:overlay-class-name="themeSwitcher.currentTheme" trigger="click">
<template slot="content">
<table cellpadding="2" :style="{ maxWidth: '300px' }">
<tr v-if="rule.sourceIP">
<td>Source IP</td>
<td><a-tag color="blue" v-for="r in rule.sourceIP.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.sourcePort">
<td>Source Port</td>
<td><a-tag color="green" v-for="r in rule.sourcePort.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.vlessRoute">
<td>VLESS Route</td>
<td><a-tag color="geekblue" v-for="r in rule.vlessRoute.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.network">
<td>Network</td>
<td><a-tag color="blue" v-for="r in rule.network.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.protocol">
<td>Protocol</td>
<td><a-tag color="green" v-for="r in rule.protocol.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.attrs">
<td>Attrs</td>
<td><a-tag color="blue" v-for="r in rule.attrs.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.ip">
<td>IP</td>
<td><a-tag color="green" v-for="r in rule.ip.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.domain">
<td>Domain</td>
<td><a-tag color="blue" v-for="r in rule.domain.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.port">
<td>Port</td>
<td><a-tag color="green" v-for="r in rule.port.split(',')">[[ r ]]</a-tag></td>
</tr>
<tr v-if="rule.balancerTag">
<td>Balancer Tag</td>
<td><a-tag color="blue">[[ rule.balancerTag ]]</a-tag></td>
</tr>
</table>
</template>
<a-button shape="round" size="small" :style="{ fontSize: '14px', padding: '0 10px' }">
<a-icon type="info"></a-icon>
</a-button>
</a-popover>
<template slot="inbound" slot-scope="text, rule">
<div class="criterion-flow">
<a-tooltip v-if="rule.inboundTag"
:title="'Inbound Tag: ' + joinCsv(rule.inboundTag)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">Tag</span>
<span class="criterion-value">[[ csv(rule.inboundTag)[0] ]]</span>
<span v-if="csv(rule.inboundTag).length > 1" class="criterion-more">+[[ csv(rule.inboundTag).length - 1 ]]</span>
</span>
</a-tooltip>
<a-tooltip v-if="rule.user"
:title="'Client: ' + joinCsv(rule.user)"
:overlay-class-name="themeSwitcher.currentTheme"
:trigger="['hover', 'click']">
<span class="criterion-row">
<span class="criterion-label">User</span>
<span class="criterion-value">[[ csv(rule.user)[0] ]]</span>
<span v-if="csv(rule.user).length > 1" class="criterion-more">+[[ csv(rule.user).length - 1 ]]</span>
</span>
</a-tooltip>
<span class="routing-criteria-empty"
v-if="!rule.inboundTag && !rule.user"></span>
</div>
</template>
<template slot="target" slot-scope="text, rule">
<div class="routing-target-cell">
<div class="routing-target-row" v-if="rule.outboundTag">
<a-icon type="export" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-emerald">[[ rule.outboundTag ]]</span>
</div>
<div class="routing-target-row" v-if="rule.balancerTag">
<a-icon type="cluster" class="routing-target-icon"></a-icon>
<span class="outbound-pill tone-violet">[[ rule.balancerTag ]]</span>
</div>
<span class="routing-criteria-empty"
v-if="!rule.outboundTag && !rule.balancerTag"></span>
</div>
</template>
</a-table-sortable>
</a-space>
{{end}}
{{end}}

Some files were not shown because too many files have changed in this diff Show More