diff --git a/.env.example b/.env.example index e5c012a..b67accc 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,7 @@ TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 TPROXY_CHAIN=VPN_PROXY_TPROXY +GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD +GATEWAY_NAT_CHAIN=VPN_PROXY_NAT +GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile index 6ef34f3..e1811a7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,13 +7,12 @@ COPY dist /app/dist RUN if [ "${INSTALL_RUNTIME_DEPS}" = "true" ]; then \ apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \ + && apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \ && rm -rf /var/lib/apt/lists/*; \ else \ command -v dumb-init >/dev/null \ && command -v node >/dev/null \ - && command -v iptables >/dev/null \ - && command -v ipset >/dev/null; \ + && command -v iptables >/dev/null; \ fi RUN if [ "${INSTALL_SINGBOX}" = "true" ]; then \ @@ -45,8 +44,6 @@ ENV PORT=3456 \ PROXY_PORT=8080 \ PROXY_BIND_IP=0.0.0.0 \ TPROXY_PORT=7895 \ - DIRECT_BYPASS_CACHE=false \ - RULE_SET_DOWNLOAD_DETOUR=vpn \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CACHE=/var/lib/sing-box/cache.db diff --git a/README.md b/README.md index 9e84162..14fc8c5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Один компактный VPN-клиент в двух режимах: -- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или HTTP/SOCKS5 Proxy; +- `gateway` — отдельная Linux-машина принимает трафик устройств как системный Gateway или Local HTTP/SOCKS5 Proxy; - `client` — локальный proxy-клиент для macOS. В обоих режимах пользователь добавляет подписку, выбирает сервер и включает VPN на одном экране. @@ -19,9 +19,9 @@ docker compose -f docker-compose.gateway.yml up -d --build После подключения экран показывает: - `Gateway` — адрес, который можно назначить устройству как основной шлюз; -- `Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`. +- `Local Proxy` — один адрес на порту `8080`, доступный как `HTTP` и `SOCKS5`. -Весь перехваченный публичный TCP/UDP и весь proxy-трафик идут через выбранный VPN. Приватные и локальные сети не перехватываются, чтобы сохранить доступ к Gateway и LAN. +Когда VPN включён, публичный трафик Gateway и Local Proxy идёт через выбранный sing-box outbound. Когда VPN выключен, TProxy-перехват снимается и Gateway продолжает работать напрямую через kernel forwarding/NAT без прохода через sing-box. Proxy по умолчанию разрешён только из приватных сетей. Диапазоны задаются через `PROXY_ALLOWED_CIDRS`. diff --git a/entrypoint.sh b/entrypoint.sh index 30ade8c..0789fc5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -5,6 +5,9 @@ TPROXY_PORT="${TPROXY_PORT:-7895}" TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" +GATEWAY_FORWARD_CHAIN="${GATEWAY_FORWARD_CHAIN:-VPN_PROXY_FORWARD}" +GATEWAY_NAT_CHAIN="${GATEWAY_NAT_CHAIN:-VPN_PROXY_NAT}" +GATEWAY_CLIENT_CIDRS="${GATEWAY_CLIENT_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" PROXY_PORT="${PROXY_PORT:-8080}" PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}" @@ -35,6 +38,15 @@ cleanup_tproxy() { ip route flush table "$TPROXY_TABLE" 2>/dev/null || true } +cleanup_gateway_forwarding() { + ipt -D FORWARD -j "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true + ipt -t nat -D POSTROUTING -j "$GATEWAY_NAT_CHAIN" 2>/dev/null || true + ipt -F "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true + ipt -X "$GATEWAY_FORWARD_CHAIN" 2>/dev/null || true + ipt -t nat -F "$GATEWAY_NAT_CHAIN" 2>/dev/null || true + ipt -t nat -X "$GATEWAY_NAT_CHAIN" 2>/dev/null || true +} + enable_ip_forwarding() { if [[ -w /proc/sys/net/ipv4/ip_forward ]]; then printf '1' > /proc/sys/net/ipv4/ip_forward || true @@ -58,6 +70,22 @@ setup_proxy_firewall() { ipt -I INPUT -p udp --dport "$PROXY_PORT" -j "$PROXY_INPUT_CHAIN" } +setup_gateway_forwarding() { + log "setup direct gateway forwarding" + cleanup_gateway_forwarding + enable_ip_forwarding + + ipt -N "$GATEWAY_FORWARD_CHAIN" + ipt -t nat -N "$GATEWAY_NAT_CHAIN" + for cidr in $GATEWAY_CLIENT_CIDRS; do + ipt -A "$GATEWAY_FORWARD_CHAIN" -s "$cidr" -j ACCEPT + ipt -A "$GATEWAY_FORWARD_CHAIN" -d "$cidr" -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT + ipt -t nat -A "$GATEWAY_NAT_CHAIN" -s "$cidr" -m addrtype ! --dst-type LOCAL -j MASQUERADE + done + ipt -I FORWARD 1 -j "$GATEWAY_FORWARD_CHAIN" + ipt -t nat -I POSTROUTING 1 -j "$GATEWAY_NAT_CHAIN" +} + setup_tproxy() { log "setup tproxy on port ${TPROXY_PORT}" cleanup_tproxy @@ -76,9 +104,9 @@ setup_tproxy() { ipt -t mangle -A "$TPROXY_CHAIN" -p tcp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" ipt -t mangle -A "$TPROXY_CHAIN" -p udp -j TPROXY --on-port "$TPROXY_PORT" --tproxy-mark "$TPROXY_MARK/$TPROXY_MARK" - ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN" } +setup_gateway_forwarding setup_tproxy setup_proxy_firewall @@ -90,6 +118,7 @@ shutdown() { wait "$APP_PID" 2>/dev/null || true cleanup_proxy_firewall cleanup_tproxy + cleanup_gateway_forwarding } trap 'shutdown; exit 0' SIGTERM SIGINT @@ -97,4 +126,5 @@ wait "$APP_PID" STATUS=$? cleanup_proxy_firewall cleanup_tproxy +cleanup_gateway_forwarding exit "$STATUS" diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index abf7a4d..5449754 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -47,11 +47,9 @@ TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 TPROXY_CHAIN=VPN_PROXY_TPROXY -TPROXY_SOURCE_BYPASS_CHAIN=VPN_PROXY_SRC_BYPASS -TPROXY_SOURCE_FORWARD_CHAIN=VPN_PROXY_FWD_BYPASS -TPROXY_SOURCE_NAT_CHAIN=VPN_PROXY_NAT_BYPASS -TPROXY_BYPASS_SOURCE_CIDRS= -ROUTING_RU_DIRECT=true +GATEWAY_FORWARD_CHAIN=VPN_PROXY_FORWARD +GATEWAY_NAT_CHAIN=VPN_PROXY_NAT +GATEWAY_CLIENT_CIDRS=10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 LOG_LEVEL=info EOF echo "Created default .env. Edit ${DEPLOY_PATH}/.env if this server needs different ports." diff --git a/src/server/config.js b/src/server/config.js index e648d3c..ffe49fd 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -15,6 +15,7 @@ export const settings = { port: parsePort(process.env.PORT, 3456), proxyPort, tproxyPort: parsePort(process.env.TPROXY_PORT, 7895), + tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY", bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, distDir: process.env.DIST_DIR || "/app/dist", diff --git a/src/server/gatewayRouting.js b/src/server/gatewayRouting.js new file mode 100644 index 0000000..5568c35 --- /dev/null +++ b/src/server/gatewayRouting.js @@ -0,0 +1,23 @@ +import { spawnSync } from 'node:child_process'; + +const options = { encoding: 'utf8' }; + +export function setGatewayInterception(enabled, chain, run = spawnSync) { + const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain]; + const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0; + + if (!enabled) { + if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options); + return; + } + if (exists) return; + + const result = run( + 'iptables', + [...rule.slice(0, 3), '-I', 'PREROUTING', '1', '-j', chain], + options, + ); + if (result.status !== 0) { + throw new Error((result.stderr || 'Не удалось включить Gateway VPN').trim()); + } +} diff --git a/src/server/index.js b/src/server/index.js index 1d1dcc7..deb7cf3 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -3,6 +3,7 @@ import http from 'node:http'; import path from 'node:path'; import { spawn, spawnSync } from 'node:child_process'; import { settings } from './config.js'; +import { setGatewayInterception } from './gatewayRouting.js'; import { tcpPing } from './ping.js'; import { buildSharedProxyInfo } from './sharedProxy.js'; import { @@ -90,6 +91,9 @@ function checkSingboxConfig() { function stopSingbox() { return new Promise((resolve) => { + if (settings.appMode === 'gateway') { + setGatewayInterception(false, settings.tproxyChain); + } if (!singboxProcess) { singboxStartedAt = null; return resolve(); @@ -112,7 +116,12 @@ function stopSingbox() { } async function startSingbox() { - if (!fs.existsSync(settings.configPath)) return false; + if (!fs.existsSync(settings.configPath)) { + if (settings.appMode === 'gateway') { + setGatewayInterception(false, settings.tproxyChain); + } + return false; + } checkSingboxConfig(); await stopSingbox(); @@ -121,10 +130,23 @@ async function startSingbox() { }); singboxProcess = child; singboxStartedAt = new Date().toISOString(); + try { + if (settings.appMode === 'gateway') { + setGatewayInterception(true, settings.tproxyChain); + } + } catch (error) { + child.kill('SIGTERM'); + singboxProcess = null; + singboxStartedAt = null; + throw error; + } child.once('exit', () => { if (singboxProcess === child) { singboxProcess = null; singboxStartedAt = null; + if (settings.appMode === 'gateway') { + setGatewayInterception(false, settings.tproxyChain); + } } }); return true; diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 5c1a4bf..6d9d49f 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -2,6 +2,7 @@ import React, { useEffect, useRef, useState } from 'react'; import { api } from '../api.js'; import { connectionAction, + copyText, formatConnectionDuration, localProxyUrls, subscriptionDomain, @@ -35,13 +36,15 @@ export function ClientOverviewPage({ const [now, setNow] = useState(Date.now()); const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); const [pings, setPings] = useState({}); - const [copiedProxy, setCopiedProxy] = useState(''); + const [accessTab, setAccessTab] = useState('gateway'); + const [copyFeedback, setCopyFeedback] = useState(null); const [refreshingInfo, setRefreshingInfo] = useState(false); const [usageUpdated, setUsageUpdated] = useState(false); const [serverRevealVersion, setServerRevealVersion] = useState(0); const [serversLeaving, setServersLeaving] = useState(false); const subscriptionInputRef = useRef(null); const subscriptionRef = useRef(null); + const copyTimerRef = useRef(null); const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1'; const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress); @@ -133,6 +136,8 @@ export function ClientOverviewPage({ return () => cancelAnimationFrame(frame); }, [usage.used]); + useEffect(() => () => clearTimeout(copyTimerRef.current), []); + async function toggleConnection() { const action = connectionAction({ connected, selectedTag, configExists: state?.configExists }); if (action?.type === 'stop') return onStop(); @@ -154,13 +159,14 @@ export function ClientOverviewPage({ } async function copyProxy(kind) { + const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind]; + clearTimeout(copyTimerRef.current); + setCopyFeedback({ kind, failed: false }); + copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800); try { - await navigator.clipboard.writeText(kind === 'gateway' ? gatewayAddress : proxyUrls[kind]); - setCopiedProxy(kind); - setTimeout(() => setCopiedProxy(''), 800); + await copyText(value); } catch { - setCopiedProxy('error'); - setTimeout(() => setCopiedProxy(''), 800); + setCopyFeedback({ kind, failed: true }); } } @@ -194,7 +200,7 @@ export function ClientOverviewPage({ type="button" role="switch" aria-checked={connected} - aria-label={connected ? `Выключить ${isGateway ? 'Gateway' : 'VPN'}` : `Включить ${isGateway ? 'Gateway' : 'VPN'}`} + aria-label={connected ? 'Выключить VPN' : 'Включить VPN'} disabled={busy || (!connected && !canStart)} onClick={toggleConnection} > @@ -204,9 +210,7 @@ export function ClientOverviewPage({

- {isGateway - ? connected ? 'Gateway включён' : 'Gateway выключен' - : connected ? 'VPN включён' : 'VPN выключен'} + {connected ? 'VPN включён' : 'VPN выключен'}

{connected ? ( @@ -221,24 +225,42 @@ export function ClientOverviewPage({
-
+
{isGateway && ( -
- Gateway +
+ {[ + ['gateway', 'Gateway'], + ['proxy', 'Local Proxy'], + ].map(([tab, label]) => ( + + ))} +
+ )} + {(isGateway ? accessTab === 'gateway' : false) ? ( +
{gatewayAddress}
- )} -
- {isGateway ? 'Proxy' : 'Адрес'} + ) : ( +
+ {!isGateway && Адрес} {proxyUrls.http.replace(/^https?:\/\//, '')} @@ -248,18 +270,19 @@ export function ClientOverviewPage({ ['http', 'HTTP'], ].map(([kind, label]) => ( ))}
+ )}
)} diff --git a/src/web/styles.css b/src/web/styles.css index 19165cb..796763b 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -66,10 +66,12 @@ p { } .app-body.client-mode { + min-height: 100vh; background: var(--client-bg); } .client-mode .app-main { + min-height: 100vh; display: grid; place-items: center; padding: 48px 24px 32px; @@ -724,10 +726,14 @@ p { display: grid; justify-items: center; gap: 5px; - width: 220px; + width: 240px; margin-top: 6px; } +.client-proxies.has-tabs { + min-height: 91px; +} + .client-proxy-label { color: var(--client-muted); font-size: 9px; @@ -747,10 +753,44 @@ p { display: grid; justify-items: center; gap: 5px; + animation: client-access-reveal 450ms cubic-bezier(0.16, 1, 0.3, 1) both; } -.client-access-point + .client-access-point { - margin-top: 12px; +@keyframes client-access-reveal { + 0% { opacity: 0; filter: blur(4px); } + 100% { opacity: 1; filter: blur(0); } +} + +.client-access-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + width: 220px; + margin-bottom: 8px; + border-bottom: 1px solid var(--client-border); +} + +.client-access-tabs .client-access-tab { + width: auto; + padding: 6px 4px 7px; + border: 0; + border-bottom: 1px solid transparent; + background: transparent; + color: var(--client-muted); + font: 700 9px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + letter-spacing: 0.06em; + cursor: pointer; + transition: color 220ms ease, border-color 300ms ease, filter 300ms ease; +} + +.client-access-tabs .client-access-tab.is-active { + border-bottom-color: var(--client-accent); + color: var(--client-text); + filter: drop-shadow(0 0 5px color-mix(in oklch, var(--client-accent) 45%, transparent)); +} + +.client-access-tab:focus-visible { + outline: 2px solid var(--client-accent); + outline-offset: 2px; } .client-proxy-actions { @@ -770,7 +810,7 @@ p { transform: translateX(-50%); } -.client-proxies button { +.client-copy-button { position: relative; width: 86px; padding: 6px 10px; @@ -788,7 +828,8 @@ p { transition: opacity 100ms ease; } -.client-proxies button.is-copied .client-copy-label { +.client-copy-button.is-copied .client-copy-label, +.client-copy-button.is-copy-error .client-copy-label { opacity: 0; } @@ -801,27 +842,32 @@ p { animation: client-copy-fade 800ms cubic-bezier(0.4, 0, 1, 1) forwards; } +.client-copy-button.is-copy-error .client-copy-feedback { + color: oklch(0.68 0.15 28); + filter: drop-shadow(0 0 5px oklch(0.68 0.15 28 / 0.45)); +} + @keyframes client-copy-fade { 0%, 18% { opacity: 1; filter: drop-shadow(0 0 5px var(--client-accent)); } 100% { opacity: 0; filter: drop-shadow(0 0 0 transparent); } } -.client-proxies button:hover { +.client-copy-button:hover { color: var(--client-text); opacity: 1; transform: translateY(-2px); } -.client-proxies button:active { +.client-copy-button:active { transform: translateY(0) scale(0.98); } -.client-proxies button.is-copied { +.client-copy-button.is-copied { opacity: 1; color: var(--client-accent); } -.client-proxies button:focus-visible { +.client-copy-button:focus-visible { outline: 2px solid var(--client-accent); outline-offset: 3px; } @@ -877,7 +923,9 @@ p { .client-usage, .client-usage > strong, .client-server, - .client-proxies button, + .client-copy-button, + .client-access-tab, + .client-access-point, .client-copy-feedback, .client-subscription-edit, .client-subscription-edit::after, diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js index 663a0f5..cf6039d 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -36,6 +36,27 @@ export function localProxyUrls(port = 8082, host = '127.0.0.1') { }; } +export async function copyText(text, options = {}) { + const clipboard = options.clipboard ?? globalThis.navigator?.clipboard; + const documentRef = options.documentRef ?? globalThis.document; + + if (documentRef?.execCommand) { + const textarea = documentRef.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + documentRef.body.append(textarea); + textarea.select(); + const copied = documentRef.execCommand('copy'); + textarea.remove(); + if (copied) return; + } + + if (!clipboard?.writeText) throw new Error('Copy failed'); + await clipboard.writeText(text); +} + export function subscriptionUsage(userInfo = {}) { const upload = Math.max(0, Number(userInfo.upload) || 0); const download = Math.max(0, Number(userInfo.download) || 0); diff --git a/test/server/entrypoint-tproxy.test.js b/test/server/entrypoint-tproxy.test.js index 527144c..77a6f77 100644 --- a/test/server/entrypoint-tproxy.test.js +++ b/test/server/entrypoint-tproxy.test.js @@ -8,8 +8,11 @@ const entrypoint = fs.readFileSync( 'utf8', ); -test('gateway intercepts all public TCP and UDP traffic without source bypasses', () => { +test('gateway keeps direct forwarding active while TProxy interception is switchable', () => { assert.match(entrypoint, /-p tcp -j TPROXY --on-port "\$TPROXY_PORT"/); assert.match(entrypoint, /-p udp -j TPROXY --on-port "\$TPROXY_PORT"/); + assert.match(entrypoint, /-I FORWARD 1 -j "\$GATEWAY_FORWARD_CHAIN"/); + assert.match(entrypoint, /-I POSTROUTING 1 -j "\$GATEWAY_NAT_CHAIN"/); + assert.doesNotMatch(entrypoint, /-A PREROUTING -j "\$TPROXY_CHAIN"/); assert.doesNotMatch(entrypoint, /TPROXY_BYPASS_SOURCE_CIDRS|DIRECT_BYPASS_CACHE|ipset/); }); diff --git a/test/server/gateway-routing.test.js b/test/server/gateway-routing.test.js new file mode 100644 index 0000000..7bfe714 --- /dev/null +++ b/test/server/gateway-routing.test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { setGatewayInterception } from '../../src/server/gatewayRouting.js'; + +test('gateway switches only the TProxy PREROUTING jump', () => { + const calls = []; + const missing = (command, args) => { + calls.push([command, args]); + return { status: args.includes('-C') ? 1 : 0, stderr: '' }; + }; + + setGatewayInterception(true, 'VPN_PROXY_TPROXY', missing); + assert.deepEqual(calls.map(([, args]) => args), [ + ['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'], + ['-w', '-t', 'mangle', '-I', 'PREROUTING', '1', '-j', 'VPN_PROXY_TPROXY'], + ]); + + calls.length = 0; + const existing = (command, args) => { + calls.push([command, args]); + return { status: 0, stderr: '' }; + }; + setGatewayInterception(false, 'VPN_PROXY_TPROXY', existing); + assert.deepEqual(calls.map(([, args]) => args), [ + ['-w', '-t', 'mangle', '-C', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'], + ['-w', '-t', 'mangle', '-D', 'PREROUTING', '-j', 'VPN_PROXY_TPROXY'], + ]); +}); diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index d4d77a2..52dc44b 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -3,6 +3,7 @@ import test from 'node:test'; import { connectionAction, + copyText, formatConnectionDuration, localProxyUrls, subscriptionDomain, @@ -54,6 +55,26 @@ test('gateway proxy URLs use its network address', () => { }); }); +test('copy uses the synchronous native path available on gateway HTTP', async () => { + const textarea = { + style: {}, + setAttribute() {}, + select() { this.selected = true; }, + remove() { this.removed = true; }, + }; + const documentRef = { + body: { append(node) { node.appended = true; } }, + createElement: () => textarea, + execCommand: (command) => command === 'copy', + }; + + await copyText('192.168.50.111', { documentRef }); + + assert.equal(textarea.value, '192.168.50.111'); + assert.equal(textarea.selected, true); + assert.equal(textarea.removed, true); +}); + test('subscription usage combines traffic and caps progress', () => { assert.deepEqual(subscriptionUsage({ upload: 30, download: 80, total: 100, expire: 2 }), { upload: 30,