Compare commits
20
Commits
d4046526c9
..
beta
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a345e70e5a | ||
|
|
dab8b09f0c | ||
|
|
635f190630 | ||
|
|
64b1fc5cb3 | ||
|
|
047e4be207 | ||
|
|
7d90568869 | ||
|
|
92c5c2ace6 | ||
|
|
9001b47204 | ||
|
|
ba5b581aaf | ||
|
|
2776eba034 | ||
|
|
09c1f57a34 | ||
|
|
a4798f8db6 | ||
|
|
cc72b92932 | ||
|
|
aea27916e8 | ||
|
|
6f7fa2fad1 | ||
|
|
4e3c99650e | ||
|
|
cf49340b9a | ||
|
|
0b80a69192 | ||
|
|
1e9cef9e02 | ||
|
|
4a0383dce1 |
@@ -1 +1,3 @@
|
||||
/shell2.exe
|
||||
/BOT_PLAN.md
|
||||
/SECURITY_REVIEW.md
|
||||
|
||||
@@ -11,8 +11,18 @@ DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, paine
|
||||
- Banco de dados PostgreSQL
|
||||
- Integração com Xray-core/V2Ray
|
||||
- Configurador visual para VLESS e VMess
|
||||
- Editor visual completo de inbounds (criar, editar, duplicar e remover)
|
||||
- Endpoint XHTTP compartilhado no modo nativo: VLESS **ou** VMess em `/` e SSH em `/ssh`, usando o mesmo domínio/porta/TLS
|
||||
- Área compacta de infraestrutura com Servidores, Status, Monitoramento e Tráfego no mesmo seletor visual
|
||||
- Cartões de status ao vivo nos espaços SSH, Xray e Infraestrutura, com confirmações integradas ao painel
|
||||
- Navegação interna consistente com o Bot: SSH/SlowDNS e Revendedores separam consulta de cadastro; Xray separa Usuários, Criar usuário, Configuração e Logs; Configurações separa Rede/SSH, SlowDNS, UDP, TLS e Xray
|
||||
- Revendedores hierárquicos com sub-revendas, planos por validade/slots ou créditos, auditoria e escopo próprio
|
||||
- Gerenciamento multi-servidor (master/slave) direto pelo painel
|
||||
- API HTTP completa para bots/automações (ver **HTTP API Reference**)
|
||||
- API pública `/check` para consultar usuário ou UUID
|
||||
- Aba de logs no painel para ver logs do sistema, DNSTT e Xray
|
||||
- Túnel DNSTT integrado com proteção de escala e reinício automático
|
||||
- DNS local embutido (fake DNS) em IPv4 ou IPv6 para testes sem um segundo servidor DNS
|
||||
- Salvamento live das configurações principais, com checagem se o serviço realmente subiu
|
||||
- Serviço `systemd` para iniciar automaticamente com o sistema
|
||||
|
||||
@@ -43,6 +53,36 @@ gRPC
|
||||
|
||||
Observação: Reality deve ser usado apenas em protocolos compatíveis. No configurador visual, VMess não usa Reality.
|
||||
|
||||
### Endpoint XHTTP compartilhado
|
||||
|
||||
Na aba **Xray → Configuração Xray → Visual**, o cartão **Shared XHTTP endpoint** cria dois inbounds na mesma porta:
|
||||
|
||||
- VLESS **ou** VMess em `/` (selecione um protocolo raiz);
|
||||
- SSH sobre XHTTP em `/ssh`, autenticado pelas contas SSH normais do painel.
|
||||
|
||||
O listener HTTP interno escolhe o caminho mais específico, portanto `/ssh` é encaminhado ao servidor SSH antes do inbound raiz. Todos os inbounds na mesma porta devem usar a mesma opção TLS e o mesmo certificado. Esse recurso exige o modo **Internal native emulator**; o binário Xray externo não reconhece o protocolo `ssh` específico do DragonCore.
|
||||
|
||||
Para configurações XHTTP antigas, carregue a configuração visual e clique em **Ativar SSH /ssh** no cartão VLESS/VMess existente. O painel preserva o inbound original, seus clientes, path, host, modo, opções avançadas e TLS; cria apenas o inbound SSH correspondente na mesma porta e salva/reinicia o Xray. Não é necessário recriar clientes ou o endpoint.
|
||||
|
||||
A confirmação dessa migração é exibida dentro do próprio painel. Se a gravação falhar, o inbound SSH temporário é removido do rascunho e o inbound antigo permanece intacto, permitindo tentar novamente após corrigir o erro exibido.
|
||||
|
||||
### Revendedores compatíveis com o painel PHP antigo
|
||||
|
||||
A área **Revendedores** mantém o fluxo mais importante do DraconCore PHP, com autorização refeita no servidor:
|
||||
|
||||
- cada revendedor gerencia apenas suas contas e seus sub-revendedores diretos;
|
||||
- o superadmin enxerga toda a hierarquia;
|
||||
- plano **Validade / slots** compartilha a cota entre a capacidade `max_connections` das contas SSH/Xray e os slots reservados aos sub-revendedores;
|
||||
- plano **Créditos** debita no cadastro e na renovação; o custo acompanha `max_connections`, com mínimo de uma conexão, e esse limite fica congelado após a criação;
|
||||
- contas de plano por crédito recebem 31 dias por cadastro ou renovação;
|
||||
- criação, edição, renovação, suspensão, reativação e exclusão de revendedores ficam registradas em auditoria;
|
||||
- suspensão bloqueia a árvore e remove os clientes Xray do runtime sem apagar seus metadados; a reativação restaura os clientes ainda válidos;
|
||||
- exclusão remove a árvore, suas contas SSH/Xray e devolve ao pai os créditos ainda não usados;
|
||||
- a cota considera todos os nós gerenciados, mesmo temporariamente desativados, para impedir liberação artificial de limite;
|
||||
- o estado de acesso é sincronizado com nós gerenciados sem copiar senha ou hash de login.
|
||||
|
||||
Revendedores existentes são migrados automaticamente como contas principais no modo **Validade / slots**. Não é necessário recriá-los. Por segurança, as funções antigas de revelar senha em texto puro e de alterar a sessão para “entrar como revendedor” não foram copiadas.
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Servidor Linux com `systemd`
|
||||
@@ -50,6 +90,8 @@ Observação: Reality deve ser usado apenas em protocolos compatíveis. No confi
|
||||
- Gerenciador de pacotes `apt`, `yum` ou `dnf`
|
||||
- Portas liberadas no firewall/security group conforme a configuração usada
|
||||
|
||||
O instalador usa Go 1.25.12 e baixa as dependências fixadas no `go.mod`. As bibliotecas oficiais `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text` e `x/time` estão fixadas nas versões de segurança revisadas em 13/07/2026.
|
||||
|
||||
Distribuições alvo:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -69,6 +111,7 @@ sudo bash install.sh
|
||||
Durante a instalação, o script instala/configura:
|
||||
|
||||
- Go
|
||||
- verificação SHA-256 dos arquivos oficiais de Go e Xray antes da extração
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- Binário do DragonCoreSSH V40
|
||||
@@ -263,7 +306,7 @@ Ou gerar uma senha nova automaticamente:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
O script atualiza o usuário `admin` no PostgreSQL, ativa ele como `superadmin`, salva `ADMIN_PASSWORD` em `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
O script atualiza o usuário `admin` no PostgreSQL com hash bcrypt, ativa ele como `superadmin`, remove qualquer `ADMIN_PASSWORD` legado em texto puro de `/opt/sshpanel/.env` e reinicia o serviço `sshpanel` para recarregar o cache interno de admins.
|
||||
|
||||
### Atualização automática pelo Git
|
||||
|
||||
@@ -281,6 +324,15 @@ https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
|
||||
|
||||
Depois ele recompila o binário e atualiza o painel web e os scripts auxiliares, mantendo as configurações e dados existentes.
|
||||
|
||||
O painel do superadmin também mostra um cartão **Atualizações do painel** na tela inicial. Ele compara o commit compilado no servidor com o commit mais recente da branch configurada no Git. A verificação usa cache de 5 minutos; o botão **Verificar agora** força uma nova consulta.
|
||||
|
||||
Variáveis opcionais do serviço para apontar a verificação para outro Git/branch:
|
||||
|
||||
```text
|
||||
DRAGON_UPDATE_REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
|
||||
DRAGON_UPDATE_BRANCH=main
|
||||
```
|
||||
|
||||
O update preserva:
|
||||
|
||||
```text
|
||||
@@ -386,6 +438,115 @@ Erros comuns:
|
||||
{"error":"database not configured"}
|
||||
```
|
||||
|
||||
### Proteção de escala do DNSTT para servidores com muitos usuários
|
||||
|
||||
O serviço DNSTT integrado inclui proteção contra sobrecarga para que milhares de usuários do túnel DNS não esgotem a RAM nem derrubem o painel inteiro com facilidade.
|
||||
|
||||
Campos de configuração do DNSTT:
|
||||
|
||||
```json
|
||||
{
|
||||
"dnstt": {
|
||||
"max_sessions": 10000,
|
||||
"max_streams": 15000,
|
||||
"pending_responses": 20000,
|
||||
"stream_buffer": 262144,
|
||||
"udp_read_buffer": 16777216,
|
||||
"udp_write_buffer": 16777216,
|
||||
"log_connections": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Os valores podem ser alterados no painel admin em **DNSTT Tunnel**. Use `0` para manter o padrão seguro. Use `-1` apenas em `max_sessions` ou `max_streams` se quiser intencionalmente não ter limite máximo.
|
||||
|
||||
Valores recomendados para servidor movimentado:
|
||||
|
||||
- `max_sessions`: `10000`
|
||||
- `max_streams`: `15000`
|
||||
- `pending_responses`: `20000`
|
||||
- `stream_buffer`: `262144`
|
||||
- `udp_read_buffer`: `16777216`
|
||||
- `udp_write_buffer`: `16777216`
|
||||
- `log_connections`: `false`
|
||||
|
||||
O DNSTT também recupera panics dentro das goroutines do DNSTT, rejeita novas sessões/streams quando os limites são atingidos e reporta esses contadores em `/api/dnstt`. O painel admin os mostra no **Dashboard** principal quando o DNSTT está ativado. Se `dnstt` estiver desativado na config, o card do dashboard fica oculto. `/api/dnstt` também retorna um campo `enabled`. Contadores expostos:
|
||||
|
||||
- `active_sessions`
|
||||
- `active_streams`
|
||||
- `sess_rejected`
|
||||
- `stream_rejected`
|
||||
- `panic_recovered`
|
||||
- `rec_dropped`
|
||||
- `parse_err`
|
||||
- `ch_len`
|
||||
|
||||
Para implantações muito grandes, aumente também os limites de buffer de socket do Linux, por exemplo:
|
||||
|
||||
```bash
|
||||
cat >/etc/sysctl.d/99-dragon-dnstt.conf <<'SYSCTL'
|
||||
net.core.rmem_max=67108864
|
||||
net.core.wmem_max=67108864
|
||||
net.core.netdev_max_backlog=250000
|
||||
net.ipv4.udp_mem=262144 524288 1048576
|
||||
SYSCTL
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
### DNS local embutido / fake DNS (IPv4 e IPv6)
|
||||
|
||||
O DNSTT pode abrir um listener DNS interno extra para testes locais/LAN sem precisar de um segundo servidor DNS. Esse listener injeta os pacotes do túnel DNS diretamente no mesmo pool de sessões e chave privada do DNSTT integrado.
|
||||
|
||||
O DNS local embutido funciona em **IPv4 e IPv6**. A família de endereço é escolhida automaticamente a partir de `fake_dns_listen`: um endereço IPv4 abre um socket `udp4`, e um endereço IPv6 (entre colchetes) abre um socket `udp6`.
|
||||
|
||||
Exemplo IPv4:
|
||||
|
||||
```json
|
||||
{
|
||||
"dnstt": {
|
||||
"domain": "t.example.com",
|
||||
"domains": ["t.example.com", "t.local.lan"],
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"fake_dns_enabled": true,
|
||||
"fake_dns_listen": "192.168.0.10:53",
|
||||
"fake_dns_domain": "t.local.lan",
|
||||
"fake_dns_workers": 4,
|
||||
"dns_response_workers": 1,
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Exemplo IPv6:
|
||||
|
||||
```json
|
||||
{
|
||||
"dnstt": {
|
||||
"domain": "t.example.com",
|
||||
"domains": ["t.example.com", "t.local.lan"],
|
||||
"udp_listen": "[::]:5300",
|
||||
"fake_dns_enabled": true,
|
||||
"fake_dns_listen": "[2001:db8::1234]:53",
|
||||
"fake_dns_domain": "t.local.lan",
|
||||
"fake_dns_workers": 4,
|
||||
"dns_response_workers": 1,
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notas:
|
||||
|
||||
- `fake_dns_listen` aceita um endereço IPv4 (`192.168.0.10:53`, `0.0.0.0:53`) ou um endereço IPv6 entre colchetes (`[2001:db8::1234]:53`, `[::]:53`). Se ficar vazio com `fake_dns_enabled` em `true`, o padrão é `[::]:53`.
|
||||
- A família do socket é escolhida pelo endereço: IPv4 → `udp4`, IPv6 → `udp6`. Um listener `udp6` não tenta reservar a porta 53 em IPv4, então um DNS master IPv4 existente pode continuar usando a porta 53 em IPv4 enquanto o DNSTT usa um novo endereço IPv6. Da mesma forma, um listener `udp4` num IPv4 específico evita conflito com um servidor DNS IPv6.
|
||||
- O DNS local embutido só aceita `fake_dns_domain`, por exemplo `t.local.lan`. Se vazio, o padrão é `t.local.lan`. Essa zona também é adicionada à lista `domains` do listener principal.
|
||||
- Se `fake_dns_listen` apontar para o mesmo endereço de `udp_listen`, o listener separado é ignorado e o listener DNSTT principal é usado.
|
||||
- `fake_dns_workers` adiciona workers de leitura/parse UDP concorrentes para o DNS local. Use `0` para o padrão automático; `4` a `8` é uma boa faixa inicial para servidores movimentados.
|
||||
- `dns_response_workers` distribui o envio de respostas DNS em shards. Mantenha em `0` ou `1` a menos que a **Queue** do DNSTT cresça sob carga; então teste `2` a `4`.
|
||||
- O listener normal `udp_listen` continua aceitando toda a lista `domains`.
|
||||
- A porta 53 pode exigir privilégios de root ou a capability `CAP_NET_BIND_SERVICE`.
|
||||
- Esses campos podem ser alterados no painel admin em **DNSTT Tunnel**.
|
||||
|
||||
---
|
||||
|
||||
## EN-US
|
||||
@@ -399,8 +560,19 @@ DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, Post
|
||||
- PostgreSQL database
|
||||
- Xray-core/V2Ray integration
|
||||
- Visual configurator for VLESS and VMess
|
||||
- Full visual inbound editor (create, edit, duplicate, and remove)
|
||||
- Native shared XHTTP endpoint: VLESS **or** VMess on `/` and SSH on `/ssh`, using the same domain/port/TLS
|
||||
- Compact infrastructure workspace with Servers, Status, Monitoring, and Traffic in one visual switcher
|
||||
- Live status cards across SSH, Xray, and Infrastructure, with panel-native confirmations
|
||||
- Bot-style section navigation throughout the panel: SSH/SlowDNS and Resellers separate lists from creation; Xray separates Users, Create User, Configuration, and Logs; Settings separates Network/SSH, SlowDNS, UDP, TLS, and Xray
|
||||
- Full reseller workflow compatible with the useful parts of the legacy PHP panel: direct-child hierarchy, validity/slot and credit plans, weighted SSH/Xray connection quotas, renew, suspend/reactivate, delete, and audit history
|
||||
- Existing reseller-owned SSH/Xray accounts with a legacy zero connection limit are migrated to one slot automatically; they do not need to be recreated
|
||||
- Multi-server (master/slave) management directly from the panel
|
||||
- Full HTTP API for bots/automations (see **HTTP API Reference**)
|
||||
- Public `/check` API for checking username or UUID
|
||||
- Logs tab in the panel for system, DNSTT, and Xray logs
|
||||
- Integrated DNSTT tunnel with scale protection and auto restart
|
||||
- Built-in local DNS (fake DNS) on IPv4 or IPv6 for testing without a second DNS server
|
||||
- Live-save for main service settings, with checks that enabled services actually started
|
||||
- `systemd` service for automatic startup
|
||||
|
||||
@@ -431,6 +603,19 @@ gRPC
|
||||
|
||||
Note: Reality should only be used with compatible protocols. In the visual configurator, VMess does not use Reality.
|
||||
|
||||
### Shared XHTTP endpoint
|
||||
|
||||
In **Xray → Xray Config → Visual**, the **Shared XHTTP endpoint** card creates two inbounds on the same port:
|
||||
|
||||
- VLESS **or** VMess on `/` (select one root protocol);
|
||||
- SSH over XHTTP on `/ssh`, authenticated by the panel's regular SSH accounts.
|
||||
|
||||
The internal HTTP listener selects the most specific path, so `/ssh` reaches the SSH server before the root inbound. Every inbound sharing a port must use the same TLS setting and certificate. This feature requires **Internal native emulator** mode; the external Xray binary does not recognize DragonCore's custom `ssh` protocol.
|
||||
|
||||
For older XHTTP configurations, load the visual configuration and click **Enable SSH /ssh** on the existing VLESS/VMess card. The panel preserves the original inbound, clients, path, host, mode, advanced options, and TLS; it only creates the matching SSH inbound on the same port and then saves/restarts Xray. There is no need to recreate clients or the endpoint.
|
||||
|
||||
The migration confirmation is rendered inside the panel. If saving fails, the temporary SSH inbound is removed from the draft and the old inbound remains intact, so the operation can be retried after fixing the displayed error.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux server with `systemd`
|
||||
@@ -438,6 +623,8 @@ Note: Reality should only be used with compatible protocols. In the visual confi
|
||||
- `apt`, `yum`, or `dnf` package manager
|
||||
- Required ports opened in the firewall/security group
|
||||
|
||||
The installer uses Go 1.25.12 and downloads the versions pinned in `go.mod`. The official `golang.org/x/crypto`, `x/net`, `x/sys`, `x/text`, and `x/time` modules are pinned to the security-reviewed versions current on 2026-07-13.
|
||||
|
||||
Target distributions:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
@@ -457,6 +644,7 @@ sudo bash install.sh
|
||||
During installation, the script installs/configures:
|
||||
|
||||
- Go
|
||||
- SHA-256 verification of the official Go and Xray archives before extraction
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- DragonCoreSSH V40 binary
|
||||
@@ -651,7 +839,7 @@ Or generate a new password automatically:
|
||||
sudo bash /opt/sshpanel/change_admin_password.sh --user admin --generate
|
||||
```
|
||||
|
||||
The script updates the `admin` user in PostgreSQL, enables it as `superadmin`, saves `ADMIN_PASSWORD` in `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
The script updates the `admin` user in PostgreSQL with a bcrypt hash, enables it as `superadmin`, removes any legacy plaintext `ADMIN_PASSWORD` from `/opt/sshpanel/.env`, and restarts `sshpanel` so the in-memory admin cache is reloaded.
|
||||
|
||||
### Automatic Git update
|
||||
|
||||
@@ -669,6 +857,15 @@ https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
|
||||
|
||||
Then it rebuilds the binary and updates the web panel and helper scripts while keeping existing configuration and user data.
|
||||
|
||||
The superadmin dashboard also shows a **Panel Updates** card. It compares the commit compiled into the running server with the latest commit on the configured Git branch. Results are cached for 5 minutes; **Check now** forces a fresh lookup.
|
||||
|
||||
Optional service variables for a different repository/branch:
|
||||
|
||||
```text
|
||||
DRAGON_UPDATE_REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
|
||||
DRAGON_UPDATE_BRANCH=main
|
||||
```
|
||||
|
||||
The update preserves:
|
||||
|
||||
```text
|
||||
@@ -829,12 +1026,34 @@ SYSCTL
|
||||
sysctl --system
|
||||
```
|
||||
|
||||
### DNSTT built-in local DNS / fake DNS over IPv6
|
||||
### DNSTT built-in local DNS / fake DNS (IPv4 and IPv6)
|
||||
|
||||
DNSTT can now open an extra internal DNS listener for local testing without a second DNS server.
|
||||
DNSTT can open an extra internal DNS listener for local/LAN testing without a second DNS server.
|
||||
This listener feeds DNS tunnel packets directly into the same integrated DNSTT session pool and private key.
|
||||
|
||||
Example IPv6-only config:
|
||||
The built-in local DNS listener works on **both IPv4 and IPv6**. The address family is chosen
|
||||
automatically from `fake_dns_listen`: an IPv4 address opens a `udp4` socket, and an IPv6 address
|
||||
(bracket form) opens a `udp6` socket.
|
||||
|
||||
IPv4 example:
|
||||
|
||||
```json
|
||||
{
|
||||
"dnstt": {
|
||||
"domain": "t.example.com",
|
||||
"domains": ["t.example.com", "t.local.lan"],
|
||||
"udp_listen": "0.0.0.0:5300",
|
||||
"fake_dns_enabled": true,
|
||||
"fake_dns_listen": "192.168.0.10:53",
|
||||
"fake_dns_domain": "t.local.lan",
|
||||
"fake_dns_workers": 4,
|
||||
"dns_response_workers": 1,
|
||||
"privkey_file": "/opt/sshpanel/dnstt.key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
IPv6 example:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -854,11 +1073,289 @@ Example IPv6-only config:
|
||||
|
||||
Notes:
|
||||
|
||||
- `fake_dns_listen` accepts IPv6 bracket syntax such as `[2001:db8::1234]:53` or `[::]:53`.
|
||||
- IPv6 listeners are opened with `udp6`, so they do not try to reserve IPv4 port 53. This lets an existing IPv4 master DNS keep using IPv4 port 53 while DNSTT uses a new IPv6 address.
|
||||
- The built-in local DNS listener only accepts `fake_dns_domain`, for example `t.local.lan`.
|
||||
- `fake_dns_listen` accepts an IPv4 address (`192.168.0.10:53`, `0.0.0.0:53`) or an IPv6 address in bracket form (`[2001:db8::1234]:53`, `[::]:53`). If left empty while `fake_dns_enabled` is `true`, it defaults to `[::]:53`.
|
||||
- The socket family is selected from the address: IPv4 → `udp4`, IPv6 → `udp6`. A `udp6` listener does not try to reserve IPv4 port 53, so an existing IPv4 master DNS can keep IPv4 port 53 while DNSTT uses a new IPv6 address. Likewise a `udp4` listener on a specific IPv4 address avoids clashing with an IPv6 DNS server.
|
||||
- The built-in local DNS listener only accepts `fake_dns_domain`, for example `t.local.lan`. If empty, it defaults to `t.local.lan`. The zone is also added to the main listener's `domains` list.
|
||||
- If `fake_dns_listen` resolves to the same address as `udp_listen`, the separate listener is skipped and the main DNSTT listener is used instead.
|
||||
- `fake_dns_workers` adds concurrent UDP read/parse workers for the local DNS listener. Use `0` for the automatic default; `4` to `8` is a good starting range for busy servers.
|
||||
- `dns_response_workers` shards DNS response sending. Keep it at `0` or `1` unless the DNSTT **Queue** grows under load; then test `2` to `4`.
|
||||
- The normal `udp_listen` listener still accepts the full `domains` list.
|
||||
- Port 53 may require root privileges or the `CAP_NET_BIND_SERVICE` capability.
|
||||
- These fields can be changed from the admin panel under **DNSTT Tunnel**.
|
||||
|
||||
---
|
||||
|
||||
## HTTP API Reference
|
||||
|
||||
**PT-BR:** Esta seção documenta todos os endpoints HTTP do painel para quem quer integrar bots do Telegram, painéis web externos, automações, etc., sem precisar ler o código-fonte. Além da API pública `/check` (sem autenticação), todos os endpoints administrativos usam o cabeçalho `X-Session-Token`.
|
||||
|
||||
**EN-US:** This section documents every HTTP endpoint of the panel so you can build Telegram bots, external web panels, automations, etc. without reading the source. Besides the public `/check` API (no auth), all admin endpoints use the `X-Session-Token` header.
|
||||
|
||||
Base URL: `http://SERVER_IP:9090` (the web panel + API port).
|
||||
|
||||
### Authentication model
|
||||
|
||||
- All authenticated calls use the `X-Session-Token` request header (no cookies). Obtain a token from `POST /api/auth/login`.
|
||||
- Auth levels used in this doc:
|
||||
- **None** — public, no token required.
|
||||
- **Session** — any valid logged-in session (superadmin or reseller). Missing/invalid token → `401`.
|
||||
- **Superadmin** — a valid session whose role is `superadmin`. Non-superadmin → `403`; missing token → `401`.
|
||||
- Roles: `superadmin` and `reseller`. Resellers are automatically scoped to their own users/clients and limited by their user quota.
|
||||
- Method mismatch on most handlers returns `405`. Invalid JSON bodies return `400 invalid json`.
|
||||
- Error responses from the API are `text/plain` bodies (the message strings shown below) with the noted HTTP status code — **not** JSON. Success bodies are JSON (or empty for `201`/`204`).
|
||||
- Send `Content-Type: application/json` on every POST that takes a body.
|
||||
- `expires_at` accepts RFC3339 (e.g. `2026-12-31T23:59:59Z`). Xray client endpoints additionally accept `YYYY-MM-DDThh:mm` and `YYYY-MM-DD`.
|
||||
- Only `/check` sends permissive CORS (`*`). The authenticated routes have no CORS/OPTIONS handling and are meant for server-to-server or same-origin use.
|
||||
|
||||
### Managed servers (`server_id`) — master/slave
|
||||
|
||||
Many endpoints accept an optional `server_id` query param (alias `server`), or `server_id` JSON field on create/add calls. When it names a remote managed slave node, the master proxies the request to that node and returns its response verbatim. Empty, `0`, `local`, or the local id means "this server". Proxy failures return `502` with `remote server error: ...`. Endpoints below note when `server_id` is supported.
|
||||
|
||||
### Quick auth example
|
||||
|
||||
```bash
|
||||
# 1) Log in and capture the token
|
||||
TOKEN=$(curl -s -X POST "http://SERVER_IP:9090/api/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username":"admin","password":"YOUR_PASSWORD"}' | jq -r .token)
|
||||
|
||||
# 2) Use the token on any authenticated endpoint
|
||||
curl -s "http://SERVER_IP:9090/api/users" -H "X-Session-Token: $TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Auth
|
||||
|
||||
#### `POST /api/auth/login` — none
|
||||
- Body: `username` (string, required), `password` (string, required).
|
||||
- `200`: `{ "token": string, "username": string, "role": string }`.
|
||||
- Errors: `400` username/password required or invalid json; `401 invalid credentials`; `403 account suspended` / `account expired`.
|
||||
|
||||
#### `POST /api/auth/logout` — session
|
||||
- No body. Deletes the session for the supplied `X-Session-Token`. Returns `200` (empty).
|
||||
|
||||
#### `GET /api/auth/me` — session
|
||||
- `200`: `{ "username": string, "role": string }`. Reseller responses also include `max_users`, weighted `used_users`, SSH/Xray account counts across managed nodes, `parent_username`, `quota_mode`, `credit_balance`, child allocation/count, expiry, and direct/effective active status.
|
||||
|
||||
---
|
||||
|
||||
### SSH users
|
||||
|
||||
#### `GET /api/users` — session
|
||||
- Optional query: `server_id`. Resellers see only their own users; superadmins see all.
|
||||
- `200`: array of user objects: `username` (string), `active_conns` (int), `max_connections` (int), `expires_at` (string/null), `limit_mbps_up` (int), `limit_mbps_down` (int), `totp_secret` (string, omitempty), `totp_period` (int), `totp_window` (int), `totp_digits` (int), `allow_static_password` (bool), `totp_enabled` (bool), `owner_username` (string, omitempty), `server_id` (string, omitempty).
|
||||
|
||||
#### `POST /api/users/create` — session
|
||||
Creates or updates (upsert) an SSH user.
|
||||
- Body: `username` (string, required); `password` (string, optional — empty keeps the existing password on an existing user; for a new user either `password` or `totp_secret` is required); `max_connections` (int); `expires_at` (string); `limit_mbps_up` (int); `limit_mbps_down` (int); `totp_secret` (string); `totp_period` (int); `totp_window` (int); `totp_digits` (int); `allow_static_password` (bool); `owner_username` (string, optional — honored only for superadmin; resellers are forced to themselves); `server_id` (string, optional).
|
||||
- `201 Created` (empty body). A proxied create returns the remote node's status/body.
|
||||
- Errors: `400 username required`, `400 password or totp_secret required for new user`; `403 user limit reached (N)`; `403 SSH creation is disabled for this server`; `503 database not configured`.
|
||||
|
||||
#### `DELETE /api/users/delete` — session
|
||||
- Query: `username` (string, required); optional `server_id`. Resellers may delete only their own users.
|
||||
- `204 No Content`. Errors: `400 username required`; `403 forbidden`; `503 database not configured`.
|
||||
|
||||
---
|
||||
|
||||
### Stats & bandwidth
|
||||
|
||||
#### `GET /api/stats` — session
|
||||
- Optional query: `server_id`.
|
||||
- `200`: `{ "cpu_percent": float, "mem_total_bytes": uint, "mem_used_bytes": uint, "mem_avail_bytes": uint, "mem_percent": float, "interfaces": [ { "name": string, "rx_bytes": uint, "tx_bytes": uint, "rx_mbps": float, "tx_mbps": float } ] }`.
|
||||
|
||||
#### `POST /api/stats/interfaces/reset` — superadmin
|
||||
- No body. Resets persisted per-interface byte totals to the current kernel counters. `200`: `{ "ok": true }`. Errors: `503`, `500`.
|
||||
|
||||
#### `GET /api/vnstat` — superadmin
|
||||
- Query: `days` (int, optional, default 31), `months` (int, optional, default 12).
|
||||
- `200`: interface usage dataset (daily/monthly aggregates). Errors: `503 database not configured`; `500 db error`.
|
||||
|
||||
#### `POST /api/vnstat/reset` — superadmin
|
||||
- No body. Clears stored vnstat usage. `200`: `{ "ok": true }`. Errors: `503`, `500 db error`.
|
||||
|
||||
---
|
||||
|
||||
### Logs
|
||||
|
||||
#### `GET /api/system/logs` — superadmin
|
||||
- Query: `source` (`panel` (default), `dnstt`, or `xray`); `lines` (int, optional, default 300, max 2000).
|
||||
- `200`: `{ "source": string, "path": string (panel only), "lines": string[] }`.
|
||||
|
||||
#### `POST /api/system/logs/reset` — superadmin
|
||||
- No body. Truncates the panel log file. `200`: `{ "ok": true, "path": string, "max_bytes": int }`. Error: `500`.
|
||||
|
||||
---
|
||||
|
||||
### DNSTT
|
||||
|
||||
#### `GET /api/dnstt` — superadmin
|
||||
- Optional query: `server_id`.
|
||||
- `200`: 5-second stats snapshot: `timestamp` (string), `enabled` (bool), `running` (bool); uint counters `dns_rx`, `parse_err`, `no_edns`, `limit512`, `rec_queued`, `rec_dropped`, `resp_sent`, `resp_bytes`, `resp_empty`, `resp_data`, `resp_oversize`, `kcp_new`, `kcp_end`, `smux_new`, `smux_end`, `sess_rejected`, `stream_rejected`, `panic_recovered`; `active_sessions` (int64), `active_streams` (int64), `ch_len` (int), `fake_dns_workers` (int, omitempty), `dns_response_workers` (int, omitempty).
|
||||
|
||||
#### `GET /api/dnstt/logs` — superadmin
|
||||
- `200`: array of log line strings (empty array if uninitialized). Does not proxy to managed servers.
|
||||
|
||||
#### `POST /api/dnstt/genkey` — superadmin
|
||||
- No body. Generates a new Noise keypair and writes the private key to the configured key file. `200`: `{ "privkey_file": string, "pubkey": string }`. Error: `500`. Supports `server_id` proxying.
|
||||
|
||||
#### `GET /api/dnstt/pubkey` — superadmin
|
||||
- No body. Returns the public key derived from the configured private key. `200`: `{ "pubkey": string }`. Error: `500`. Supports `server_id` proxying.
|
||||
|
||||
---
|
||||
|
||||
### Resellers (authenticated; hierarchy scoped)
|
||||
|
||||
Superadmins manage every reseller. A reseller sees and manages only its direct children; it cannot skip a hierarchy level. Child plans inherit the parent's `slots` or `credits` mode.
|
||||
|
||||
#### `GET /api/resellers`
|
||||
- `200`: direct-child array with hierarchy, plan, weighted quota, account-count breakdown, contact, price, expiry, and effective status fields: `{ "id", "username", "parent_username", "quota_mode", "max_users", "credit_balance", "used_users", "used_ssh_users", "used_xray_users", "child_allocation", "child_count", "available", "usage_incomplete", "whatsapp", "monthly_price_cents", "expires_at", "is_active", "effective_active", "created_at" }`.
|
||||
- `used_users` is weighted by each SSH/Xray account's connection limit and includes every configured managed node. If a node cannot be verified, `usage_incomplete` is true and provisioning remains fail-closed.
|
||||
|
||||
#### `POST /api/resellers/create`
|
||||
Creates or edits a reseller.
|
||||
- Body: `username` (required); `password` (required only on create); `parent_username`; `quota_mode` (`slots` or `credits`); `max_users`; `credits`; `expires_at` (RFC3339 for slot plans); `is_active`; `whatsapp`; `monthly_price_cents`.
|
||||
- For reseller callers, `parent_username` and `quota_mode` are forced to the signed-in parent. Parent and plan mode are immutable after creation.
|
||||
- Credit transfers are atomic and audited. Slot limits cannot be reduced below direct account use plus reserved child allocation.
|
||||
- `201`: `{ "username": string, "created": bool }`.
|
||||
|
||||
#### `POST /api/resellers/action`
|
||||
- Body: `username`, `action` (`renew`, `suspend`, `reactivate`, or superadmin-only `pull`), and optional `days`.
|
||||
- `renew` extends a validity reseller from the later of now/current expiry. `suspend` and `reactivate` apply to the full descendant tree and owned SSH/Xray access on managed nodes. `pull` safely attaches a nested reseller directly to the main panel without duplicating transferred credits.
|
||||
- `200`: `{ "ok": true, "runtime_warning": string }`.
|
||||
|
||||
#### `DELETE /api/resellers/delete`
|
||||
- Query: `username` (required). Suspends the subtree first, then removes all descendant reseller records and their owned SSH/Xray accounts locally and from every configured managed node. Unused descendant credit balances are returned once to the direct credit parent.
|
||||
- `204 No Content`.
|
||||
|
||||
#### `GET /api/resellers/audit`
|
||||
- Returns the latest 200 lifecycle/credit events. Resellers receive only their own and direct-child activity.
|
||||
|
||||
#### `POST /api/resellers/runtime` — superadmin/internal node synchronization
|
||||
- Password-free master-to-node hierarchy/status synchronization used for managed-server suspension, expiry, reactivation, and cleanup. Login passwords and password hashes are never replicated.
|
||||
|
||||
---
|
||||
|
||||
### Managed servers (superadmin, except list)
|
||||
|
||||
#### `/api/servers` — GET: session · POST/DELETE: superadmin
|
||||
Accepts `GET`, `POST`, `DELETE`.
|
||||
- **GET**: list servers. Resellers see only active servers with `admin_username` blanked. The local server is always first. `200`: array of `{ "id": string, "name": string, "base_url": string, "admin_username": string, "enable_ssh": bool, "enable_xray": bool, "is_active": bool, "is_local": bool, "created_at": string, "updated_at": string }`.
|
||||
- **POST**: upsert a slave node. Body: `id` (string), `name` (string), `base_url` (string), `admin_username` (string), `admin_key` (string), `enable_ssh` (bool), `enable_xray` (bool), `is_active` (bool). `200`: the created/updated server object.
|
||||
- **DELETE**: query `id` (int, required, > 0). `204`. Errors: `400 invalid server id`; `403 forbidden`.
|
||||
- All methods: `503 database not configured`.
|
||||
|
||||
#### `POST /api/servers/test` — superadmin
|
||||
Tests connectivity/credentials to a managed node (remote login + `/api/auth/me`).
|
||||
- Body: `base_url`, `admin_key` (or password), `admin_username` (default `admin`), `id`, `enable_ssh`, `enable_xray`. If `id` matches a stored server, missing fields are filled from the DB.
|
||||
- `200`: `{ "ok": true, "message": "remote login ok" }`. Errors: `400 base url and admin key/password required`; `502` on remote failure; `503`.
|
||||
|
||||
#### `/api/servers/config` — superadmin (GET or POST)
|
||||
Read/write a managed server's `config.json`. Query: `server_id`. Local delegates to `/api/server/config`; remote proxies GET/POST to that node (POST body ≤ 512 KiB).
|
||||
|
||||
---
|
||||
|
||||
### Xray-core
|
||||
|
||||
#### `GET /api/xray/status` — session
|
||||
- Optional query: `server_id`. For resellers, `online_users` counts only their own clients.
|
||||
- `200`: `{ "enabled": bool, "running": bool, "mode": string, "native": bool, "pid": int, "uptime": string, "error": string, "online_users": int, "stats_error": string, "stats_configured": bool, "stats_missing": string[], "api_server": string, "last_stats_poll": string/null, "online_window_seconds": int }`.
|
||||
|
||||
#### `POST /api/xray/start` · `POST /api/xray/stop` · `POST /api/xray/restart` — superadmin
|
||||
- No body; optional `server_id`. `200` (empty) on success; `500` with message on error.
|
||||
|
||||
#### `POST /api/xray/stats/repair` — superadmin
|
||||
- No body; optional `server_id`. Ensures the Stats API config and restarts Xray if it was running.
|
||||
- `200`: `{ "changed": bool, "restarted": bool, "stats_configured": bool, "stats_missing": string[], "api_server": string }`. Errors: `400`, `500`.
|
||||
|
||||
#### `/api/xray/config` — superadmin (GET or POST)
|
||||
- Optional `server_id`. **GET** returns the raw Xray config JSON. **POST** replaces it — body is the full Xray config JSON (≤ 512 KiB), validated then saved. `200` on success; `400` on invalid config; `500` on read.
|
||||
|
||||
#### `GET /api/xray/logs` — superadmin
|
||||
- Optional `server_id`. `200`: `{ "lines": string[] }`.
|
||||
|
||||
#### `GET /api/xray/inbounds` — session
|
||||
- Optional `server_id`. Lists only inbounds that carry client lists (vless/vmess/trojan). Resellers see all inbounds but only their own clients. Clients are enriched with DB metadata and runtime stats.
|
||||
- `200`: array of `{ "tag": string, "protocol": string, "port": <raw>, "listen": string, "clients": [ XrayClientInfo ] }`.
|
||||
- **XrayClientInfo**: `id` (string, the UUID), `password` (string, omitempty), `email` (string), `level` (int), `online` (bool), `last_active` (string/null), `uplink_bytes` (int64), `downlink_bytes` (int64), `total_bytes` (int64), `active_connections` (int), `name` (string), `expires_at` (string/null), `expiration_days` (int; `-1` = no expiry, `0` = expired), `max_conns`, `owner_username`, `expired`.
|
||||
|
||||
#### `POST /api/xray/clients/add` — session
|
||||
- Body: `inbound_tag` (string, required), `uuid` (string, required), `email` (string, optional — defaults to name then uuid), `name` (string, optional), `expires_at` (string, optional), `max_connections` (int), `owner_username` (string, optional — superadmin only), `server_id` (string, optional).
|
||||
- `201 Created` (empty). Errors: `400 inbound_tag and uuid required` / `UUID already exists in database`; `403 reseller account suspended or expired` / `user limit reached (N)` / `Xray creation is disabled for this server`; `500`.
|
||||
|
||||
#### `POST /api/xray/clients/update` — session
|
||||
- Body: `uuid` (string, required), `name` (string), `email` (string), `expires_at` (string), `max_connections` (int), `server_id` (string, optional). Inbound tag and owner are preserved from existing metadata. Resellers may update only their own clients.
|
||||
- `200`. Errors: `400 uuid required`; `403 forbidden`; `404 client metadata not found`; `500`.
|
||||
|
||||
#### `DELETE /api/xray/clients/remove` — session
|
||||
- Query: `inbound_tag` (string, required), `uuid` (string, required); optional `server_id`. Resellers may remove only their own clients.
|
||||
- `204 No Content`. Errors: `400 inbound_tag and uuid required`; `403 forbidden`; `500`.
|
||||
|
||||
---
|
||||
|
||||
### TLS certificates (superadmin only)
|
||||
|
||||
All three accept `POST` only and support `server_id` proxying.
|
||||
|
||||
#### `POST /api/tls/generate-selfsigned`
|
||||
- Body: `domain` (string, required). Writes a self-signed ECDSA (P-256) cert (10-year validity) to `/opt/sshpanel/certs/<domain>/`.
|
||||
- `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 domain required`; `500`.
|
||||
|
||||
#### `POST /api/tls/letsencrypt`
|
||||
- Body: `domain` (string, required), `email` (string, required). Runs `certbot certonly --standalone` (requires certbot and a free port 80).
|
||||
- `200`: `{ "cert_file": string, "key_file": string, "output": string }`. Errors: `400 domain and email required`; `500 certbot failed: ...`.
|
||||
|
||||
#### `POST /api/tls/upload-pem`
|
||||
- Body: `name` (string, required), `cert` (string, required — PEM), `key` (string, required — PEM). Saves to `/opt/sshpanel/certs/<name>/`.
|
||||
- `200`: `{ "cert_file": string, "key_file": string }`. Errors: `400 name, cert, and key required` / `invalid name`; `500`.
|
||||
|
||||
---
|
||||
|
||||
### Panel config
|
||||
|
||||
#### `/api/server/config` — superadmin (GET or POST)
|
||||
Reads/writes the panel's `config.json` and hot-applies changes.
|
||||
- **GET**: returns the raw config file JSON.
|
||||
- **POST**: body is the full config JSON (≤ 512 KiB). Validated (`listen` required), the file-based `users` array is preserved, ports normalized, then written to disk and applied live.
|
||||
- `200`: reload report `{ "applied": bool, "warnings": string[], "services": { "<name>": ServiceReloadStatus } }`.
|
||||
- Errors: `400 invalid JSON: ...` / `listen address required`; `500 config path not set` / read/write errors.
|
||||
|
||||
---
|
||||
|
||||
### Public — CheckUser
|
||||
|
||||
#### `GET /check` — none
|
||||
Public status lookup for SSH users and Xray/V2Ray UUIDs (CORS `*`). See the **Public CheckUser API** section above for query params, response fields, and examples.
|
||||
|
||||
---
|
||||
|
||||
## Bot de Vendas (Telegram + Mercado Pago)
|
||||
|
||||
**PT-BR:** O painel inclui um bot de Telegram integrado ao próprio binário para vender contas **SSH e Xray** por **PIX** (Mercado Pago), com **teste grátis**, **renovação**, e um **sistema de revendedores por créditos**. Toda a gestão fica na aba **Bot / Vendas** do painel (somente superadmin).
|
||||
|
||||
### Como configurar
|
||||
1. Abra o painel → aba **Bot / Vendas**.
|
||||
2. Em **Configuração**: marque *Bot ativo*, cole o **Token do Telegram** (via @BotFather) e o **Access Token do Mercado Pago**. Clique **Testar conexão** e depois **Salvar**.
|
||||
3. **Confirmação do pagamento — Webhook × Polling** (o painel deixa você escolher):
|
||||
- **Polling** (padrão): o bot consulta o status do PIX a cada intervalo. Não precisa de domínio/HTTPS.
|
||||
- **Webhook**: configure no painel do Mercado Pago a URL `https://SEU_DOMINIO/api/mp/webhook`. O painel mostra a URL exata quando você seleciona esse modo.
|
||||
- O **Telegram** usa long-polling automático — não requer domínio, webhook nem configuração extra.
|
||||
4. Em **Planos**: crie planos SSH e/ou Xray (dias, conexões, preço em R$, e — para Xray — o *inbound* e protocolo). Para revendedores, defina o *custo em créditos*.
|
||||
5. Em **Pacotes de Crédito**: defina os valores de recarga dos revendedores.
|
||||
6. Em **Clientes do Bot**: promova um usuário a **revendedor** (vinculando-o a uma conta de revendedor em *Revendedores*), ajuste créditos ou bloqueie.
|
||||
7. Em **Mensagens**: edite os textos de boas-vindas, contato e link do app.
|
||||
|
||||
### Segurança dos segredos
|
||||
Os tokens (Telegram, Mercado Pago e segredos de webhook) são gravados **criptografados (AES-256-GCM)** na tabela `bot_config` do PostgreSQL. A **chave-mestra** fica **fora do banco**: variável de ambiente `BOT_MASTER_KEY` (64 caracteres hex) ou, se ausente, um arquivo `0600` em `/opt/sshpanel/bot_master.key` gerado automaticamente no primeiro uso. **Faça backup desse arquivo** junto com o banco — sem ele os segredos não podem ser decifrados. A API do painel nunca retorna os tokens em texto puro.
|
||||
|
||||
### Endpoints (superadmin, exceto webhooks)
|
||||
- `GET/POST /api/bot/config` — lê/grava a configuração (segredos só entram no POST; campo vazio mantém o atual; o GET retorna apenas `has_*`).
|
||||
- `GET/POST/DELETE /api/bot/plans` — CRUD de planos (`?id=` no DELETE).
|
||||
- `GET/POST/DELETE /api/bot/credit-packages` — CRUD de pacotes de crédito.
|
||||
- `GET/POST /api/bot/users` — lista clientes; POST com `action` = `set_role` \| `block` \| `unblock` \| `adjust_credits`.
|
||||
- `GET/POST /api/bot/transactions` — lista pagamentos; POST com `action` = `refund` \| `reprocess`.
|
||||
- `GET/POST /api/bot/settings` — textos do bot (chave/valor).
|
||||
- `POST /api/bot/test` — testa token do Telegram e do Mercado Pago.
|
||||
- `POST /api/mp/webhook` — **público**, chamado pelo Mercado Pago quando o modo de confirmação é *webhook* (valida `x-signature` se houver segredo; sempre reconfirma o pagamento na API antes de liberar). Ignorado em modo *polling*.
|
||||
|
||||
**EN-US:** The panel ships an in-process Telegram bot that sells **SSH and Xray** accounts via **PIX (Mercado Pago)**, with free trial, renewal, and a **credit-based reseller system** — all managed from the superadmin **Bot / Vendas** tab. Bot secrets are stored **AES-256-GCM encrypted** in PostgreSQL; the master key lives outside the DB (`BOT_MASTER_KEY` env or a `0600` `/opt/sshpanel/bot_master.key` auto-generated on first use — back it up). Telegram runs on long-polling (no domain needed); only **Mercado Pago confirmation** is toggleable between **polling** (default) and **webhook** in the panel.
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type accountRenewPayload struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UUID string `json:"uuid,omitempty"`
|
||||
Days int `json:"days,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
|
||||
func renewalDays(owner string, requested int) int {
|
||||
if u, ok := adminUsers.get(owner); ok && normalizeQuotaMode(u.QuotaMode) == QuotaModeCredit {
|
||||
return 31
|
||||
}
|
||||
if requested == 0 {
|
||||
return 30
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func renewalExpiry(existing *time.Time, days int) time.Time {
|
||||
base := time.Now()
|
||||
if existing != nil && existing.After(base) {
|
||||
base = *existing
|
||||
}
|
||||
return base.AddDate(0, 0, days)
|
||||
}
|
||||
|
||||
func jsonInt(value interface{}) int {
|
||||
switch value := value.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int64:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
result, _ := strconv.Atoi(value.String())
|
||||
return result
|
||||
default:
|
||||
result, _ := strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
return int(result)
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewSSHUser(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_connections"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew SSH account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
state, ok := userMgr.Get(p.Username)
|
||||
if !ok {
|
||||
http.Error(w, "SSH account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
state.mu.Lock()
|
||||
cfg := state.Cfg
|
||||
existingExpiry := state.ExpiresAt
|
||||
state.mu.Unlock()
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if cfg.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(cfg.MaxConnections)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-ssh:"+p.Username)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(existingExpiry, p.Days)
|
||||
cfg.ExpiresAt = next.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, cfg.OwnerUsername, cost, "renew-ssh:"+p.Username)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
userMgr.DisconnectUser(p.Username)
|
||||
reloadUsersFromDB(ctx, store)
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, cfg.OwnerUsername, "renewed SSH account",
|
||||
fmt.Sprintf("account=%s days=%d", p.Username, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next})
|
||||
}
|
||||
}
|
||||
|
||||
func handleRenewXrayClient(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p accountRenewPayload
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
p.UUID = strings.TrimSpace(p.UUID)
|
||||
if _, err := parseUUID(p.UUID); err != nil {
|
||||
http.Error(w, "invalid UUID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Days < 0 || p.Days > 3650 {
|
||||
http.Error(w, "days must be between 1 and 3650", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
sess := sessionFromCtx(ctx)
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
row, exists, infoErr := remoteXrayClientInfo(ctx, ms, p.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote account", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
owner := strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(jsonInt(row["max_conns"]))
|
||||
charged, infoErr = reserveResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
if infoErr != nil {
|
||||
writeResellerProvisionError(w, infoErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(owner, p.Days)
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, owner); syncErr != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, contentType, proxyErr := proxyManagedServer(ctx, ms, http.MethodPost, "/api/xray/clients/renew", body, "application/json")
|
||||
if proxyErr != nil || status < 200 || status >= 300 {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, owner, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
if proxyErr != nil {
|
||||
writeBadGatewayError(w, "renew Xray account on managed server", proxyErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, contentType)
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := store.GetXrayClientMeta(ctx, p.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
charged, cost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if meta.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
cost = resellerProvisionCost(meta.MaxConns)
|
||||
var creditErr error
|
||||
charged, creditErr = reserveResellerProvisionCredits(ctx, store, sess.Username, cost, "renew-xray:"+p.UUID)
|
||||
if creditErr != nil {
|
||||
writeResellerProvisionError(w, creditErr)
|
||||
return
|
||||
}
|
||||
p.Days = renewalDays(sess.Username, p.Days)
|
||||
}
|
||||
if p.Days == 0 {
|
||||
p.Days = 30
|
||||
}
|
||||
next := renewalExpiry(meta.ExpiresAt, p.Days)
|
||||
meta.ExpiresAt = &next
|
||||
if err := store.UpsertXrayClientMeta(ctx, *meta); err != nil {
|
||||
if charged {
|
||||
refundResellerProvisionCredits(ctx, store, meta.OwnerUsername, cost, "renew-xray:"+p.UUID)
|
||||
}
|
||||
http.Error(w, "database error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
runtimeWarning := ""
|
||||
if meta.OwnerUsername != "" {
|
||||
if runtimeErr := restoreOwnerXrayClients(ctx, store, meta.OwnerUsername); runtimeErr != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, runtimeErr)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
} else if err := ensureXrayClientPresent(*meta); err != nil {
|
||||
log.Printf("restore renewed Xray account %s: %v", p.UUID, err)
|
||||
runtimeWarning = "The account was renewed, but Xray could not restore it yet. Check the Xray service."
|
||||
}
|
||||
if sess != nil {
|
||||
_ = store.appendResellerAudit(ctx, sess.Username, meta.OwnerUsername, "renewed Xray account",
|
||||
fmt.Sprintf("uuid=%s days=%d", p.UUID, p.Days))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "expires_at": next, "runtime_warning": runtimeWarning})
|
||||
}
|
||||
}
|
||||
|
||||
func ensureXrayClientPresent(meta XrayClientMeta) error {
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
if inbound.Tag != meta.InboundTag {
|
||||
continue
|
||||
}
|
||||
for _, client := range inbound.Clients {
|
||||
if client.UUID == meta.UUID {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
email := strings.TrimSpace(meta.Email)
|
||||
if email == "" {
|
||||
email = meta.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(meta.InboundTag, meta.UUID, email); err != nil {
|
||||
return err
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %s no longer exists", meta.InboundTag)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var accountUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`)
|
||||
|
||||
func validateAccountUsername(username string) error {
|
||||
if !accountUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, @, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAccountControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0
|
||||
}
|
||||
|
||||
func validateOptionalAccountExpiry(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if _, err := time.Parse(layout, raw); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid expiration date")
|
||||
}
|
||||
|
||||
func validateSSHUserPayload(p *UserPayload) error {
|
||||
p.Username = strings.TrimSpace(p.Username)
|
||||
p.OwnerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
p.TOTPSecret = normalizeBase32Secret(p.TOTPSecret)
|
||||
if err := validateAccountUsername(p.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Password != nil && len(*p.Password) > 4096 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
if p.MaxConnections < 0 || p.MaxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
if p.LimitUpMbps < 0 || p.LimitUpMbps > 100000 || p.LimitDownMbps < 0 || p.LimitDownMbps > 100000 {
|
||||
return fmt.Errorf("speed limits must be between 0 and 100000 Mbps")
|
||||
}
|
||||
if err := validateOptionalAccountExpiry(p.ExpiresAt); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(p.OwnerUsername); err != nil {
|
||||
return fmt.Errorf("invalid owner username")
|
||||
}
|
||||
}
|
||||
if len(p.ServerID) > 32 || hasAccountControlCharacters(p.ServerID) {
|
||||
return fmt.Errorf("invalid server id")
|
||||
}
|
||||
if p.TOTPSecret != "" {
|
||||
if len(p.TOTPSecret) > 256 {
|
||||
return fmt.Errorf("TOTP secret is too long")
|
||||
}
|
||||
if _, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(p.TOTPSecret); err != nil {
|
||||
return fmt.Errorf("invalid TOTP secret")
|
||||
}
|
||||
}
|
||||
if p.TOTPPeriod != 0 && (p.TOTPPeriod < 15 || p.TOTPPeriod > 300) {
|
||||
return fmt.Errorf("TOTP period must be between 15 and 300 seconds")
|
||||
}
|
||||
if p.TOTPWindow < 0 || p.TOTPWindow > 10 {
|
||||
return fmt.Errorf("TOTP window must be between 0 and 10")
|
||||
}
|
||||
if p.TOTPDigits != 0 && (p.TOTPDigits < 6 || p.TOTPDigits > 8) {
|
||||
return fmt.Errorf("TOTP digits must be between 6 and 8")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateXrayClientFields(uuid, inboundTag, email, name, expiresAt string, maxConnections int, requireInbound bool) error {
|
||||
uuid = strings.TrimSpace(uuid)
|
||||
if _, err := parseUUID(uuid); err != nil {
|
||||
return fmt.Errorf("invalid UUID")
|
||||
}
|
||||
if requireInbound && strings.TrimSpace(inboundTag) == "" {
|
||||
return fmt.Errorf("inbound_tag required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"inbound_tag": inboundTag,
|
||||
"email": email,
|
||||
"name": name,
|
||||
} {
|
||||
limit := 256
|
||||
if field == "inbound_tag" {
|
||||
limit = 128
|
||||
}
|
||||
if len(value) > limit || hasAccountControlCharacters(value) {
|
||||
return fmt.Errorf("invalid %s", field)
|
||||
}
|
||||
}
|
||||
if maxConnections < 0 || maxConnections > 1000 {
|
||||
return fmt.Errorf("max_connections must be between 0 and 1000")
|
||||
}
|
||||
return validateOptionalAccountExpiry(expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateSSHUserPayloadBounds(t *testing.T) {
|
||||
valid := &UserPayload{
|
||||
Username: "client-01",
|
||||
MaxConnections: 2,
|
||||
TOTPPeriod: 60,
|
||||
TOTPWindow: 1,
|
||||
TOTPDigits: 6,
|
||||
}
|
||||
if err := validateSSHUserPayload(valid); err != nil {
|
||||
t.Fatalf("valid SSH payload rejected: %v", err)
|
||||
}
|
||||
|
||||
invalid := *valid
|
||||
invalid.MaxConnections = -1
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("negative max_connections was accepted")
|
||||
}
|
||||
|
||||
invalid = *valid
|
||||
invalid.TOTPSecret = "not base32!"
|
||||
if err := validateSSHUserPayload(&invalid); err == nil {
|
||||
t.Fatal("invalid TOTP secret was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateXrayClientFields(t *testing.T) {
|
||||
const id = "d9428888-122b-11e1-b85c-61cd3cbb3210"
|
||||
if err := validateXrayClientFields(id, "vless-in", "client@example.test", "Client", "", 2, true); err != nil {
|
||||
t.Fatalf("valid Xray client rejected: %v", err)
|
||||
}
|
||||
if err := validateXrayClientFields("not-a-uuid", "vless-in", "", "", "", 1, true); err == nil {
|
||||
t.Fatal("invalid Xray UUID was accepted")
|
||||
}
|
||||
if err := validateXrayClientFields(id, "vless-in", "", "", "", 1001, true); err == nil {
|
||||
t.Fatal("excessive Xray connection limit was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreditAccountConnectionLimitIsImmutable(t *testing.T) {
|
||||
username := "credit-limit-test"
|
||||
adminUsers.set(&AdminUser{Username: username, Role: RoleReseller, QuotaMode: QuotaModeCredit, IsActive: true})
|
||||
defer adminUsers.delete(username)
|
||||
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 3); err != errCreditLimitImmutable {
|
||||
t.Fatalf("credit limit change error = %v, want %v", err, errCreditLimitImmutable)
|
||||
}
|
||||
if err := authorizeResellerQuotaChange(nil, nil, username, 2, 2); err != nil {
|
||||
t.Fatalf("unchanged credit limit rejected: %v", err)
|
||||
}
|
||||
if _, _, err := authorizeResellerProvision(nil, nil, username, "ssh:test", 0); err != errResellerConnLimit {
|
||||
t.Fatalf("zero-connection credit account error = %v, want %v", err, errResellerConnLimit)
|
||||
}
|
||||
}
|
||||
@@ -638,3 +638,116 @@ select:disabled {
|
||||
color:#f3f7ff;
|
||||
font-size:.78rem;
|
||||
}
|
||||
|
||||
/* Git update status card */
|
||||
.update-commit{
|
||||
font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
|
||||
font-size:1rem!important;
|
||||
letter-spacing:.01em!important;
|
||||
overflow-wrap:anywhere;
|
||||
}
|
||||
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
|
||||
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
|
||||
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
|
||||
|
||||
/* --- Shared visual language for every workspace tab --- */
|
||||
.page-hero{
|
||||
--hero-accent:34,211,238;
|
||||
position:relative;display:flex;align-items:flex-end;justify-content:space-between;gap:22px;
|
||||
min-height:150px;margin-bottom:18px;padding:25px 27px;overflow:hidden;
|
||||
border:1px solid rgba(var(--hero-accent),.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 88% 8%,rgba(var(--hero-accent),.24),transparent 33%),
|
||||
radial-gradient(circle at 8% 115%,rgba(139,92,246,.12),transparent 40%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.32),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.page-hero::after{content:"";position:absolute;right:-65px;top:-100px;width:250px;height:250px;border:1px solid rgba(255,255,255,.055);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.017),0 0 0 68px rgba(255,255,255,.011);pointer-events:none;}
|
||||
.page-hero[data-tone="green"]{--hero-accent:49,214,123}.page-hero[data-tone="purple"]{--hero-accent:139,92,246}.page-hero[data-tone="amber"]{--hero-accent:255,200,87}.page-hero[data-tone="blue"]{--hero-accent:80,145,255}
|
||||
.page-hero-copy,.page-hero-pills,.page-hero-mark{position:relative;z-index:1}.page-hero-copy{max-width:700px}.page-kicker{display:block;color:rgb(var(--hero-accent));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.page-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em}.page-hero p{max-width:650px;margin-top:9px;color:var(--muted);font-size:.82rem;line-height:1.55}.page-hero-pills{display:flex;align-items:center;justify-content:flex-end;gap:7px;flex-wrap:wrap;max-width:42%}.page-hero-pills span{padding:7px 10px;border:1px solid rgba(var(--hero-accent),.18);border-radius:999px;background:rgba(var(--hero-accent),.075);color:var(--text-2);font-size:.69rem;font-weight:850;letter-spacing:.04em}.page-hero-mark{display:grid;place-items:center;width:64px;height:64px;border:1px solid rgba(var(--hero-accent),.25);border-radius:22px;background:rgba(var(--hero-accent),.10);color:rgb(var(--hero-accent));font-size:1.05rem;font-weight:950;box-shadow:0 18px 44px rgba(0,0,0,.24)}
|
||||
|
||||
/* Bot-style live workspace heroes shared by SSH, Xray, and infrastructure. */
|
||||
.page-hero.status-hero{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;min-height:0}.status-hero .page-hero-copy,.workspace-hero-actions,.workspace-overview-grid,.workspace-hero-toolbar{position:relative;z-index:1}.workspace-hero-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:620px}.workspace-live-status{display:inline-flex;align-items:center;gap:7px;max-width:360px;min-height:34px;padding:7px 11px;overflow:hidden;border:1px solid rgba(160,174,192,.14);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.7rem;font-weight:850;text-overflow:ellipsis;white-space:nowrap}.workspace-live-status::before{content:"";width:7px;height:7px;flex:0 0 auto;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor}.workspace-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08)}.workspace-live-status.is-warn{color:#ffd36d;border-color:rgba(255,200,87,.25);background:rgba(255,200,87,.08)}.workspace-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08)}.workspace-live-status.is-loading{color:#71dff0;border-color:rgba(34,211,238,.23);background:rgba(34,211,238,.075)}
|
||||
.workspace-overview-grid{grid-column:1/-1;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;width:100%;margin-top:23px}.workspace-overview-grid.five{grid-template-columns:repeat(5,minmax(0,1fr))}.workspace-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid rgba(160,174,192,.14);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px)}.workspace-overview-card>div{display:flex;flex:1;flex-direction:column;gap:4px;min-width:0}.workspace-overview-card small{color:var(--muted);font-size:.64rem;font-weight:800;letter-spacing:.095em;text-transform:uppercase}.workspace-overview-card strong{overflow:hidden;color:var(--text);font-size:1rem;text-overflow:ellipsis;white-space:nowrap}.workspace-card-note{overflow:hidden;color:var(--muted);font-size:.61rem;text-overflow:ellipsis;white-space:nowrap}.workspace-overview-icon{display:grid;place-items:center;flex:0 0 auto;width:34px;height:34px;border:1px solid rgba(34,211,238,.17);border-radius:12px;background:rgba(34,211,238,.12);color:#4de0ef;font-size:.68rem;font-weight:950}.workspace-overview-icon.green{color:#72e6a4;border-color:rgba(49,214,123,.18);background:rgba(49,214,123,.11)}.workspace-overview-icon.purple{color:#b19cff;border-color:rgba(139,92,246,.2);background:rgba(139,92,246,.13)}.workspace-overview-icon.amber{color:#ffd36d;border-color:rgba(255,200,87,.18);background:rgba(255,200,87,.11)}.workspace-overview-icon.blue{color:#84b3ff;border-color:rgba(80,145,255,.2);background:rgba(80,145,255,.12)}.workspace-overview-icon.red{color:#ff8f99;border-color:rgba(255,91,105,.2);background:rgba(255,91,105,.11)}.workspace-mini-meter{height:3px;margin-top:2px;overflow:hidden;border-radius:999px;background:rgba(148,163,184,.1)}.workspace-mini-meter span{display:block;width:0;height:100%;border-radius:inherit;background:linear-gradient(90deg,rgb(var(--hero-accent)),#74edb0);transition:width .25s ease}
|
||||
.workspace-hero-toolbar{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:13px;padding-top:13px;border-top:1px solid rgba(148,163,184,.1)}.workspace-toolbar-status{min-width:0;color:var(--muted);font-size:.7rem;line-height:1.45}.workspace-toolbar-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex-wrap:wrap}.workspace-toolbar-actions .input-sm{max-width:220px}
|
||||
|
||||
/* Four infrastructure screens share one compact section switcher. */
|
||||
.infra-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.infra-section-nav{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px}.infra-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.infra-section-nav button span{margin-right:6px;color:#80abff}.infra-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.infra-section-nav button.active{color:#fff;border-color:rgba(80,145,255,.3);background:linear-gradient(135deg,rgba(80,145,255,.2),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.infra-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(80,145,255,.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}
|
||||
|
||||
/* Bot-style sub-navigation shared by SSH, Xray, resellers, and settings. */
|
||||
#tab-ssh{--section-accent:49,214,123}#tab-xray{--section-accent:139,92,246}#tab-resellers{--section-accent:255,200,87}#tab-server{--section-accent:80,145,255}
|
||||
.workspace-nav-shell{--section-accent:80,145,255;position:sticky;top:92px;z-index:12;margin-bottom:20px;padding:6px;border:1px solid rgba(160,174,192,.14);border-radius:19px;background:rgba(7,10,16,.9);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px)}.workspace-nav-shell[data-tone="green"]{--section-accent:49,214,123}.workspace-nav-shell[data-tone="purple"]{--section-accent:139,92,246}.workspace-nav-shell[data-tone="amber"]{--section-accent:255,200,87}.workspace-nav-shell[data-tone="blue"]{--section-accent:80,145,255}.workspace-section-nav{display:grid;grid-template-columns:repeat(var(--workspace-nav-columns,2),minmax(0,1fr));gap:5px}.workspace-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease}.workspace-section-nav button span{margin-right:6px;color:rgb(var(--section-accent));font-size:.72rem}.workspace-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04)}.workspace-section-nav button.active{color:#fff;border-color:rgba(var(--section-accent),.3);background:linear-gradient(135deg,rgba(var(--section-accent),.19),rgba(34,211,238,.07));box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.workspace-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(var(--section-accent),.3);border-radius:13px;background:#090d15;color:var(--text);font-weight:850}.workspace-section{display:none;min-width:0}.workspace-section.active{display:block}.workspace-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px}.workspace-section-heading>div>span{display:block;color:rgb(var(--section-accent,80,145,255));font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase}.workspace-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em}.workspace-section-heading p{color:var(--muted);font-size:.79rem;line-height:1.5}.workspace-form-card{width:min(100%,920px)}.workspace-section-status{margin-top:16px}.settings-panel-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.settings-panel-grid>.card{margin-top:0!important}.settings-panel-grid>.settings-span-all{grid-column:1/-1}.settings-workspace>.workspace-section>.card{width:min(100%,1040px);margin-top:0!important}
|
||||
.role-reseller #xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
|
||||
/* Shared panel-native confirmations and non-blocking notifications. */
|
||||
.panel-dialog{position:fixed;inset:0;z-index:120;display:grid;place-items:center;padding:20px}.panel-dialog.hidden{display:none!important}.panel-dialog-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(8px)}.panel-dialog-card{--hero-accent:80,145,255;position:relative;width:min(100%,510px);padding:22px;border:1px solid rgba(var(--hero-accent),.28);border-radius:25px;background:radial-gradient(circle at 100% 0,rgba(var(--hero-accent),.13),transparent 34%),linear-gradient(180deg,#121925,#080c13);box-shadow:0 36px 110px rgba(0,0,0,.66);animation:fadeIn .16s ease both}.panel-dialog-card.is-danger{--hero-accent:255,91,105}.panel-dialog-card.is-success{--hero-accent:49,214,123}.panel-dialog-head{display:flex;align-items:center;gap:13px}.panel-dialog-head h3{margin-top:5px;font-size:1.15rem;letter-spacing:-.025em}.panel-dialog-icon{display:grid;place-items:center;flex:0 0 auto;width:45px;height:45px;border:1px solid rgba(var(--hero-accent),.28);border-radius:15px;background:rgba(var(--hero-accent),.11);color:rgb(var(--hero-accent));font-size:.76rem;font-weight:950}.panel-dialog-card>p{margin-top:16px;color:var(--text-2);font-size:.79rem;line-height:1.58}.panel-dialog-detail{margin-top:12px;padding:12px 13px;border:1px solid rgba(148,163,184,.11);border-radius:14px;background:rgba(255,255,255,.035);color:var(--muted);font-size:.7rem;line-height:1.5;white-space:pre-line}.panel-dialog-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:19px;padding-top:15px;border-top:1px solid rgba(148,163,184,.1)}.panel-dialog-open{overflow:hidden}.panel-toast-stack{position:fixed;right:20px;bottom:20px;z-index:130;display:flex;flex-direction:column-reverse;gap:9px;width:min(390px,calc(100vw - 40px));pointer-events:none}.panel-toast{--toast-accent:80,145,255;display:flex;align-items:flex-start;gap:10px;padding:12px 13px;border:1px solid rgba(var(--toast-accent),.28);border-radius:17px;background:rgba(10,15,24,.96);box-shadow:0 18px 55px rgba(0,0,0,.48);backdrop-filter:blur(15px);animation:toastIn .18s ease both;pointer-events:auto}.panel-toast.success{--toast-accent:49,214,123}.panel-toast.warning{--toast-accent:255,200,87}.panel-toast.error{--toast-accent:255,91,105}.panel-toast-icon{display:grid;place-items:center;flex:0 0 auto;width:28px;height:28px;border-radius:10px;background:rgba(var(--toast-accent),.12);color:rgb(var(--toast-accent));font-size:.68rem;font-weight:950}.panel-toast-copy{min-width:0;flex:1}.panel-toast-copy strong{display:block;color:var(--text);font-size:.75rem}.panel-toast-copy p{margin-top:3px;color:var(--muted);font-size:.69rem;line-height:1.42}.panel-toast-close{padding:2px;border:0;background:transparent;color:var(--muted);font-size:.85rem;cursor:pointer}@keyframes toastIn{from{opacity:0;transform:translateY(9px) scale(.98)}to{opacity:1;transform:none}}
|
||||
|
||||
/* Bring legacy screens up to the Bot workspace's information density. */
|
||||
.tab-pane:not(#tab-bot)>.grid2,.tab-pane:not(#tab-bot)>#serversListView>.grid2{gap:16px}.tab-pane:not(#tab-bot) .card-hdr{padding-bottom:12px;border-bottom:1px solid rgba(148,163,184,.09)}.tab-pane:not(#tab-bot) .card-title{font-size:.96rem}.tab-pane:not(#tab-bot) .statusbar{margin-top:13px;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
|
||||
/* Xray visual configuration studio */
|
||||
.shared-endpoint-card{--hero-accent:139,92,246;position:relative;margin-bottom:18px;padding:20px;overflow:hidden;border:1px solid rgba(139,92,246,.22);border-radius:22px;background:radial-gradient(circle at 96% 0,rgba(139,92,246,.17),transparent 32%),rgba(8,12,20,.82)}
|
||||
.shared-endpoint-head{display:flex;align-items:flex-start;justify-content:space-between;gap:14px}.shared-endpoint-head h3,.visual-editor-heading h3{margin-top:5px;font-size:1.12rem;letter-spacing:-.02em}.shared-endpoint-head p{margin-top:5px;color:var(--muted);font-size:.75rem;line-height:1.45}.shared-endpoint-head code,.shared-route-preview code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;color:#c8bbff}
|
||||
.shared-route-preview{display:grid;grid-template-columns:1fr 48px 1fr;align-items:center;gap:8px;margin:17px 0;padding:10px;border:1px solid rgba(148,163,184,.11);border-radius:17px;background:rgba(255,255,255,.025)}.shared-route-preview span{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:11px 12px;border:1px solid rgba(139,92,246,.16);border-radius:13px;background:rgba(139,92,246,.07)}.shared-route-preview strong{font-size:.78rem}.shared-route-preview code{font-size:.77rem;font-weight:900}.shared-route-preview i{height:1px;background:linear-gradient(90deg,rgba(139,92,246,.2),rgba(34,211,238,.7),rgba(139,92,246,.2));position:relative}.shared-route-preview i::after{content:"";position:absolute;right:0;top:-3px;width:7px;height:7px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent)}
|
||||
.shared-endpoint-grid{grid-template-columns:repeat(3,minmax(0,1fr));}.shared-endpoint-actions{display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:15px;padding-top:14px;border-top:1px solid rgba(148,163,184,.1)}.shared-endpoint-actions .hint{max-width:650px}
|
||||
.legacy-xhttp-migration{display:flex;align-items:center;gap:13px;margin:-3px 0 15px;padding:13px 15px;border:1px solid rgba(49,214,123,.19);border-radius:18px;background:linear-gradient(135deg,rgba(49,214,123,.075),rgba(34,211,238,.035));color:var(--text-2)}.legacy-xhttp-icon{display:grid;place-items:center;flex:0 0 auto;width:42px;height:42px;border:1px solid rgba(49,214,123,.25);border-radius:14px;background:rgba(49,214,123,.11);color:#72e6a4;font-size:.68rem;font-weight:950;letter-spacing:.035em}.legacy-xhttp-migration strong{display:block;color:var(--text);font-size:.8rem}.legacy-xhttp-migration p{margin-top:3px;color:var(--muted);font-size:.71rem;line-height:1.5}
|
||||
.visual-config-toolbar{display:grid;grid-template-columns:180px minmax(0,1fr) auto;align-items:end;gap:14px;margin-bottom:13px;padding:13px 15px;border:1px solid rgba(148,163,184,.1);border-radius:18px;background:rgba(255,255,255,.025)}.visual-config-toolbar-copy{display:flex;flex-direction:column;gap:4px;padding-bottom:4px}.visual-config-toolbar-copy strong{font-size:.84rem}.visual-config-toolbar-copy span{color:var(--muted);font-size:.71rem}
|
||||
.visual-inbound-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:14px}.visual-inbound-card{position:relative;display:flex;flex-direction:column;gap:13px;min-width:0;padding:15px;border:1px solid rgba(148,163,184,.11);border-radius:18px;background:rgba(255,255,255,.027);transition:.15s ease}.visual-inbound-card:hover{border-color:rgba(139,92,246,.3);background:rgba(139,92,246,.045);transform:translateY(-1px)}.visual-inbound-card-head,.visual-inbound-meta,.visual-inbound-actions{display:flex;align-items:center;gap:8px}.visual-inbound-card-head{justify-content:space-between}.visual-inbound-name{min-width:0}.visual-inbound-name strong{display:block;overflow:hidden;text-overflow:ellipsis;color:var(--text);font-size:.84rem;white-space:nowrap}.visual-inbound-name small{display:block;margin-top:4px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.67rem}.visual-inbound-meta{flex-wrap:wrap}.visual-inbound-meta span{padding:4px 7px;border-radius:8px;background:rgba(148,163,184,.07);color:var(--muted);font-size:.67rem}.visual-inbound-actions{justify-content:flex-end;margin-top:auto;padding-top:11px;border-top:1px solid rgba(148,163,184,.08)}
|
||||
.legacy-ssh-btn{margin-right:auto;border-color:rgba(49,214,123,.3)!important;background:linear-gradient(135deg,rgba(49,214,123,.18),rgba(34,211,238,.09))!important;color:#8af0b5!important;box-shadow:inset 0 1px 0 rgba(255,255,255,.04)}.legacy-ssh-btn:hover:not(:disabled){border-color:rgba(49,214,123,.52)!important;transform:translateY(-1px)}.legacy-ssh-btn.is-enabled:disabled{opacity:1;border-color:rgba(49,214,123,.16)!important;background:rgba(49,214,123,.07)!important;color:#72b98e!important;cursor:default}
|
||||
.visual-inbound-editor{margin:14px 0;padding:18px;border:1px solid rgba(34,211,238,.2);border-radius:22px;background:radial-gradient(circle at 100% 0,rgba(34,211,238,.09),transparent 28%),rgba(6,10,16,.86)}.visual-editor-heading{--hero-accent:34,211,238;display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:15px;padding-bottom:13px;border-bottom:1px solid rgba(148,163,184,.1)}.visual-save-bar{position:sticky;bottom:14px;z-index:8;display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:16px;padding:13px 15px;border:1px solid rgba(139,92,246,.2);border-radius:19px;background:rgba(8,12,20,.9);box-shadow:0 18px 48px rgba(0,0,0,.35);backdrop-filter:blur(16px)}
|
||||
|
||||
@media(max-width:1180px){.workspace-overview-grid.five{grid-template-columns:repeat(3,minmax(0,1fr))}.infra-nav-shell,.workspace-nav-shell{top:78px}}
|
||||
@media(max-width:1100px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr))}#configSectionNav{grid-template-columns:repeat(3,minmax(0,1fr))}#xraySectionNav{grid-template-columns:repeat(2,minmax(0,1fr))}.shared-endpoint-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.visual-inbound-list{grid-template-columns:1fr}}
|
||||
@media(max-width:760px){.page-hero{min-height:0;padding:20px;border-radius:22px;align-items:flex-start;flex-direction:column}.page-hero.status-hero{display:grid;grid-template-columns:1fr}.page-hero h2{font-size:1.55rem}.page-hero-pills{max-width:none;justify-content:flex-start}.page-hero-mark{width:50px;height:50px;border-radius:17px}.workspace-hero-actions{justify-content:flex-start;max-width:none;margin-top:14px}.workspace-live-status{max-width:100%}.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:repeat(2,minmax(0,1fr));margin-top:18px}.workspace-hero-toolbar{align-items:stretch;flex-direction:column}.workspace-toolbar-actions{justify-content:flex-start}.workspace-toolbar-actions .input-sm{width:100%;max-width:none}.infra-section-nav,.workspace-section-nav{display:none}.infra-section-select,.workspace-section-select{display:block}.infra-nav-shell,.workspace-nav-shell{top:76px}.workspace-section-heading{align-items:flex-start;flex-direction:column}.workspace-section-heading>.btn,.workspace-section-heading>.card-actions{width:100%}.workspace-section-heading>.btn{justify-content:center}.settings-panel-grid{grid-template-columns:1fr}.settings-panel-grid>.settings-span-all{grid-column:auto}.shared-endpoint-card{padding:15px}.shared-endpoint-head,.shared-endpoint-actions,.visual-save-bar{align-items:flex-start;flex-direction:column}.shared-endpoint-actions .btn,.visual-save-bar .btn{width:100%}.shared-route-preview{grid-template-columns:1fr}.shared-route-preview i{width:1px;height:22px;justify-self:center}.shared-route-preview i::after{right:-3px;top:auto;bottom:0}.shared-endpoint-grid{grid-template-columns:1fr!important}.legacy-xhttp-migration{align-items:flex-start}.visual-config-toolbar{grid-template-columns:1fr;align-items:stretch}.visual-config-toolbar .btn{width:100%}.visual-inbound-actions{align-items:stretch;flex-wrap:wrap}.legacy-ssh-btn{flex:1 0 100%;margin-right:0}.panel-dialog{padding:14px}.panel-dialog-card{padding:19px}.panel-dialog-actions .btn{flex:1}.panel-toast-stack{right:14px;bottom:14px;width:calc(100vw - 28px)}}
|
||||
@media(max-width:460px){.workspace-overview-grid,.workspace-overview-grid.five{grid-template-columns:1fr}.workspace-hero-actions .btn{flex:1}.workspace-toolbar-actions .btn{flex:1}.workspace-overview-card{padding:11px 12px}}
|
||||
|
||||
/* --- Bot sales workspace --- */
|
||||
#tab-bot{--bot-accent:#7c5cff;--bot-line:rgba(160,174,192,.14);}
|
||||
.bot-hero{
|
||||
position:relative;overflow:hidden;margin-bottom:18px;padding:26px;border:1px solid rgba(139,92,246,.22);border-radius:28px;
|
||||
background:
|
||||
radial-gradient(circle at 86% 4%,rgba(124,92,255,.30),transparent 34%),
|
||||
radial-gradient(circle at 12% 100%,rgba(34,211,238,.12),transparent 38%),
|
||||
linear-gradient(135deg,rgba(17,22,35,.98),rgba(8,11,18,.98));
|
||||
box-shadow:0 24px 70px rgba(0,0,0,.34),inset 0 1px 0 rgba(255,255,255,.04);
|
||||
}
|
||||
.bot-hero::after{content:"";position:absolute;right:-70px;top:-90px;width:260px;height:260px;border:1px solid rgba(255,255,255,.06);border-radius:50%;box-shadow:0 0 0 34px rgba(255,255,255,.018),0 0 0 68px rgba(255,255,255,.012);pointer-events:none;}
|
||||
.bot-hero-copy,.bot-hero-actions,.bot-overview-grid{position:relative;z-index:1;}
|
||||
.bot-hero-copy{max-width:620px;}
|
||||
.bot-eyebrow,.bot-section-heading>div>span{display:block;color:#a997ff;font-size:.69rem;font-weight:900;letter-spacing:.17em;text-transform:uppercase;}
|
||||
.bot-hero h2{margin-top:7px;font-size:2rem;line-height:1.05;letter-spacing:-.045em;}
|
||||
.bot-hero p,.bot-section-heading p,.bot-card-heading p{color:var(--muted);font-size:.79rem;line-height:1.5;}
|
||||
.bot-hero-copy p{margin-top:8px;}
|
||||
.bot-hero-actions{position:absolute;right:26px;top:26px;display:flex;align-items:center;justify-content:flex-end;gap:8px;flex-wrap:wrap;max-width:48%;}
|
||||
.bot-live-status{display:inline-flex;align-items:center;gap:7px;min-height:34px;padding:7px 11px;border:1px solid var(--bot-line);border-radius:999px;background:rgba(255,255,255,.04);color:var(--muted);font-size:.72rem;font-weight:850;}
|
||||
.bot-live-status::before{content:"";width:7px;height:7px;border-radius:50%;background:currentColor;box-shadow:0 0 12px currentColor;}
|
||||
.bot-live-status.is-ok{color:#72e6a4;border-color:rgba(49,214,123,.25);background:rgba(49,214,123,.08);}
|
||||
.bot-live-status.is-error{color:#ff8f99;border-color:rgba(255,91,105,.28);background:rgba(255,91,105,.08);}
|
||||
.bot-overview-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px;margin-top:24px;}
|
||||
.bot-overview-card{display:flex;align-items:center;gap:11px;min-width:0;padding:13px 14px;border:1px solid var(--bot-line);border-radius:18px;background:rgba(255,255,255,.04);backdrop-filter:blur(8px);}
|
||||
.bot-overview-card>div{display:flex;flex-direction:column;gap:4px;min-width:0;}.bot-overview-card small{color:var(--muted);font-size:.66rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;}.bot-overview-card strong{font-size:1rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
|
||||
.bot-overview-icon{width:34px;height:34px;display:grid;place-items:center;flex:0 0 auto;border-radius:12px;color:#4de0ef;background:rgba(34,211,238,.12);border:1px solid rgba(34,211,238,.17);font-size:.78rem;font-weight:950;}.bot-overview-icon.bot-purple{color:#b19cff;background:rgba(139,92,246,.13);border-color:rgba(139,92,246,.2);}.bot-overview-icon.bot-green{color:#72e6a4;background:rgba(49,214,123,.11);border-color:rgba(49,214,123,.18);}.bot-overview-icon.bot-amber{color:#ffd36d;background:rgba(255,200,87,.11);border-color:rgba(255,200,87,.18);}
|
||||
|
||||
.bot-nav-shell{position:sticky;top:92px;z-index:12;margin-bottom:22px;padding:6px;border:1px solid var(--bot-line);border-radius:19px;background:rgba(7,10,16,.88);box-shadow:0 14px 40px rgba(0,0,0,.24);backdrop-filter:blur(16px);}
|
||||
.bot-section-nav{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:5px;}
|
||||
.bot-section-nav button{min-height:42px;border:1px solid transparent;border-radius:14px;background:transparent;color:var(--muted);font-size:.76rem;font-weight:850;cursor:pointer;transition:.15s ease;}.bot-section-nav button span{margin-right:5px;color:#9b88ff;}.bot-section-nav button:hover{color:var(--text);background:rgba(255,255,255,.04);}.bot-section-nav button.active{color:#fff;border-color:rgba(139,92,246,.28);background:linear-gradient(135deg,rgba(139,92,246,.20),rgba(34,211,238,.08));box-shadow:inset 0 1px 0 rgba(255,255,255,.04);}
|
||||
.bot-section-select{display:none;width:100%;padding:10px 12px;border:1px solid rgba(139,92,246,.28);border-radius:13px;background:#090d15;color:var(--text);font-weight:850;}
|
||||
.bot-section{display:none;animation:fadeIn .18s ease both;}.bot-section.active{display:block;}
|
||||
.bot-section-heading{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;margin:0 2px 16px;}.bot-section-heading h3{margin:5px 0 4px;font-size:1.34rem;letter-spacing:-.025em;}.bot-section-heading>.card-actions{justify-content:flex-end;}
|
||||
|
||||
.bot-config-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-config-grid>.card,.bot-master-detail>.card{margin-top:0!important;}
|
||||
.bot-integration-card{min-height:255px;padding:20px;}.bot-card-heading{display:flex;align-items:center;gap:12px;margin-bottom:18px;}.bot-card-heading>div:nth-child(2){min-width:0;flex:1;}.bot-card-heading h4{font-size:1rem;margin-bottom:3px;}.bot-service-icon{width:44px;height:44px;display:grid;place-items:center;flex:0 0 auto;border-radius:15px;font-weight:950;border:1px solid rgba(255,255,255,.08);background:rgba(255,255,255,.05);}.bot-service-icon.telegram{color:#5ed8ff;background:rgba(41,182,246,.11);}.bot-service-icon.mercado{color:#77b8ff;background:rgba(52,131,250,.11);font-size:.72rem;}.bot-service-icon.trial{color:#ffd36d;background:rgba(255,200,87,.1);}.bot-service-icon.host{color:#9ff4bf;background:rgba(49,214,123,.1);}
|
||||
.bot-switch{position:relative;display:inline-flex;cursor:pointer;}.bot-switch input{position:absolute;opacity:0;pointer-events:none;}.bot-switch span{width:44px;height:24px;border-radius:999px;background:#222b38;border:1px solid rgba(148,163,184,.18);transition:.16s ease;}.bot-switch span::after{content:"";display:block;width:18px;height:18px;margin:2px;border-radius:50%;background:#8793a4;transition:.16s ease;}.bot-switch input:checked+span{background:rgba(49,214,123,.19);border-color:rgba(49,214,123,.38);}.bot-switch input:checked+span::after{transform:translateX(20px);background:#70e7a3;box-shadow:0 0 14px rgba(49,214,123,.45);}
|
||||
.bot-secret-state{display:inline-flex;margin-left:5px;color:var(--muted);font-size:.67rem;font-weight:750;}.bot-secret-state.is-set{color:#72e6a4;}.bot-secret-state.is-missing{color:#ffb3ba;}
|
||||
.bot-webhook-box{margin-top:13px;padding:13px;border:1px solid rgba(139,92,246,.18);border-radius:16px;background:rgba(139,92,246,.055);}.bot-webhook-box.hidden{display:none!important;}.bot-copy-row{display:flex;align-items:center;gap:8px;margin-top:10px;}.bot-copy-row code{min-width:0;flex:1;padding:9px 10px;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--bot-line);border-radius:11px;background:#06090f;color:#b8c4d4;font-size:.7rem;white-space:nowrap;}
|
||||
.bot-input-suffix{display:flex;align-items:center;border:1px solid var(--line);border-radius:14px;background:linear-gradient(180deg,var(--input-bg),#06090f);overflow:hidden;}.bot-input-suffix input{border:0!important;border-radius:0!important;background:transparent!important;box-shadow:none!important;}.bot-input-suffix span{padding:0 11px;color:var(--muted);font-size:.72rem;font-weight:850;}.bot-note{margin-top:13px;padding:11px 12px;border-left:2px solid #7c5cff;border-radius:0 12px 12px 0;background:rgba(124,92,255,.07);color:var(--muted);font-size:.73rem;line-height:1.45;}
|
||||
|
||||
.bot-master-detail{display:grid;grid-template-columns:minmax(0,1.35fr) minmax(360px,.65fr);gap:16px;align-items:start;}.bot-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:14px;}.bot-list-heading>div{min-width:0;}.bot-list-heading strong{font-size:.91rem;}.bot-editor-card{position:sticky;top:168px;}.bot-span-2{grid-column:1/-1;}.bot-check-field{display:flex;align-items:center;gap:9px;min-height:44px;margin-top:19px;padding:0 12px;border:1px solid var(--line);border-radius:14px;background:rgba(255,255,255,.025);color:var(--text-2);font-size:.76rem;font-weight:800;cursor:pointer;}.bot-check-field input{width:16px;height:16px;}.bot-table{min-width:720px;}.bot-table td:last-child{text-align:right;white-space:nowrap;}.bot-table .bot-primary-cell{display:flex;flex-direction:column;gap:3px;}.bot-table .bot-primary-cell strong{color:var(--text);font-size:.82rem;}.bot-table .bot-primary-cell small{color:var(--muted);font-size:.69rem;}.bot-empty-row td{text-align:center!important;padding:34px!important;color:var(--muted);}.bot-row-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;}.bot-row-actions .btn+.btn{margin-left:0;}.bot-status{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;font-size:.68rem;font-weight:850;text-transform:capitalize;}.bot-status::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;}.bot-status.active,.bot-status.approved{color:#79e9aa;background:rgba(49,214,123,.09);}.bot-status.pending{color:#ffd36d;background:rgba(255,200,87,.09);}.bot-status.blocked,.bot-status.refunded,.bot-status.error{color:#ff929d;background:rgba(255,91,105,.09);}.bot-status.inactive,.bot-status.expired,.bot-status.customer{color:#9eabbd;background:rgba(148,163,184,.09);}.bot-status.reseller{color:#b5a4ff;background:rgba(139,92,246,.11);}
|
||||
.reseller-row-actions{min-width:265px;flex-wrap:wrap}.reseller-audit-table{min-width:820px}.reseller-audit-table td:nth-child(1){white-space:nowrap}.reseller-audit-table td:nth-child(4){color:#ffd36d;font-weight:800}.reseller-audit-table td:last-child{max-width:360px;color:var(--muted);font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.68rem;white-space:normal}
|
||||
.bot-message-editor{padding:22px;}.bot-message-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;}.bot-message-grid textarea{min-height:128px;}.bot-save-row{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:16px;padding-top:16px;border-top:1px solid var(--bot-line);}
|
||||
|
||||
.bot-modal{position:fixed;inset:0;z-index:80;display:grid;place-items:center;padding:20px;}.bot-modal.hidden{display:none!important;}.bot-modal-backdrop{position:absolute;inset:0;background:rgba(1,3,6,.78);backdrop-filter:blur(7px);}.bot-modal-card{position:relative;width:min(100%,480px);padding:20px;border:1px solid rgba(139,92,246,.25);border-radius:24px;background:linear-gradient(180deg,#111723,#080c13);box-shadow:0 34px 100px rgba(0,0,0,.65);}.bot-modal-open{overflow:hidden;}
|
||||
|
||||
@media(max-width:1180px){.bot-overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.bot-section-nav{grid-template-columns:repeat(3,minmax(0,1fr));}.bot-master-detail{grid-template-columns:1fr;}.bot-editor-card{position:static;}.bot-nav-shell{top:78px;}}
|
||||
@media(max-width:760px){.bot-hero{padding:20px;border-radius:22px;}.bot-hero h2{font-size:1.55rem;}.bot-hero-actions{position:relative;right:auto;top:auto;max-width:none;justify-content:flex-start;margin-top:16px;}.bot-overview-grid{grid-template-columns:1fr 1fr;margin-top:18px;}.bot-section-nav{display:none;}.bot-section-select{display:block;}.bot-nav-shell{top:76px;}.bot-config-grid,.bot-message-grid{grid-template-columns:1fr;}.bot-section-heading{align-items:flex-start;flex-direction:column;}.bot-section-heading>.card-actions{width:100%;justify-content:flex-start;}.bot-master-detail{display:block;}.bot-master-detail>.card+.card{margin-top:14px!important;}.bot-save-row{align-items:flex-start;flex-direction:column;}}
|
||||
@media(max-width:460px){.bot-overview-grid{grid-template-columns:1fr;}.bot-overview-card{padding:11px 12px;}.bot-hero-actions .btn{width:100%;}.bot-section-heading .btn{width:100%;}.bot-copy-row{align-items:stretch;flex-direction:column;}.bot-copy-row .btn{width:100%;}}
|
||||
|
||||
+193
-7
@@ -1,10 +1,13 @@
|
||||
|
||||
// ─── State ───────────────────────────────────────────────────────────────────
|
||||
let sessionToken = localStorage.getItem("SESSION_TOKEN") || "";
|
||||
let sessionToken = sessionStorage.getItem("SESSION_TOKEN") || localStorage.getItem("SESSION_TOKEN") || "";
|
||||
if (sessionToken) sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
let currentRole = "";
|
||||
let currentUser = "";
|
||||
let currentQuotaMode = "slots";
|
||||
let currentCreditBalance = 0;
|
||||
let statsTimer = null, usersTimer = null, xrayTimer = null;
|
||||
let formCollapsed = true;
|
||||
let tlsForwardersState = [];
|
||||
let managedTlsForwardersState = [];
|
||||
let editingXrayClientId = null;
|
||||
@@ -13,6 +16,7 @@ let wzLoadedFullConfig = null;
|
||||
let wzLoadedConfigText = "";
|
||||
let wzLoadedServerID = null;
|
||||
let wzDirty = false;
|
||||
let wzEditingIndex = -1;
|
||||
let dashboardCache = { sshUsers: [], xrayInbounds: [], me: null };
|
||||
let currentTab = "dashboard";
|
||||
let inboundsRefreshInFlight = false;
|
||||
@@ -96,6 +100,80 @@ Object.assign(I18N_ALIASES, {
|
||||
"Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients",
|
||||
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Panel Updates":"Panel Updates","Checking…":"Checking…","Open Git":"Open Git","Check now":"Check now","Installed version":"Installed version","Latest Git version":"Latest Git version","Branch":"Branch","Last checked":"Last checked",
|
||||
"Comparing the installed version with the Git repository.":"Comparing the installed version with the Git repository.","To update:":"To update:","Copy command":"Copy command","Up to date":"Up to date","Update available":"Update available","Local changes":"Local changes","Unknown":"Unknown",
|
||||
"The installed version matches the latest commit on {branch}.":"The installed version matches the latest commit on {branch}.","A newer commit is available on {branch}.":"A newer commit is available on {branch}.","This build contains local changes, so it cannot be compared safely.":"This build contains local changes, so it cannot be compared safely.",
|
||||
"Could not check for updates: {error}":"Could not check for updates: {error}","The update status could not be determined.":"The update status could not be determined.","Checking repository…":"Checking repository…","Update check timed out.":"Update check timed out.","Could not reach the Git repository.":"Could not reach the Git repository.","Current build commit is unavailable.":"Current build commit is unavailable.","Unknown update-check error.":"Unknown update-check error.","Copied":"Copied"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Panel Updates":"Atualizações do painel","Checking…":"Verificando…","Open Git":"Abrir Git","Check now":"Verificar agora","Installed version":"Versão instalada","Latest Git version":"Última versão no Git","Branch":"Branch","Last checked":"Última verificação",
|
||||
"Comparing the installed version with the Git repository.":"Comparando a versão instalada com o repositório Git.","To update:":"Para atualizar:","Copy command":"Copiar comando","Up to date":"Atualizado","Update available":"Atualização disponível","Local changes":"Alterações locais","Unknown":"Desconhecido",
|
||||
"The installed version matches the latest commit on {branch}.":"A versão instalada corresponde ao commit mais recente da branch {branch}.","A newer commit is available on {branch}.":"Existe um commit mais recente disponível na branch {branch}.","This build contains local changes, so it cannot be compared safely.":"Esta compilação contém alterações locais e não pode ser comparada com segurança.",
|
||||
"Could not check for updates: {error}":"Não foi possível verificar atualizações: {error}","The update status could not be determined.":"Não foi possível determinar o status da atualização.","Checking repository…":"Verificando o repositório…","Update check timed out.":"A verificação de atualização excedeu o tempo limite.","Could not reach the Git repository.":"Não foi possível acessar o repositório Git.","Current build commit is unavailable.":"O commit da compilação atual não está disponível.","Unknown update-check error.":"Erro desconhecido ao verificar atualização.","Copied":"Copiado"
|
||||
});
|
||||
Object.assign(I18N_ALIASES, {
|
||||
"Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked",
|
||||
"Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Command center":"Command center","Overview command copy":"Accounts, connections, and infrastructure at a glance.","Access workspace":"Access workspace","SSH accounts":"SSH accounts","SSH accounts copy":"Create, limit, and monitor SSH and SlowDNS access securely.",
|
||||
"Proxy studio":"Proxy studio","Xray visual":"Visual Xray","Xray visual copy":"Manage clients, inbounds, and a shared XHTTP endpoint without editing JSON.","Partner operations":"Partner operations","Partner operations copy":"Control partner quotas, expiration, and access in one place.",
|
||||
"Fleet control":"Fleet control","Fleet control copy":"Add nodes, test credentials, and configure remote infrastructure.","Live fleet":"Live fleet","Server status copy":"Health, load, and active sessions for every managed node.","Observability":"Observability","Monitoring copy":"Server resources, interfaces, and capacity in real time.",
|
||||
"Traffic intelligence":"Traffic intelligence","Network traffic":"Network traffic","Network traffic copy":"Daily and monthly history for understanding infrastructure usage.","Diagnostics":"Diagnostics","System logs copy":"Investigate the panel, DNSTT, and Xray in a focused view.","System studio":"System studio","Settings copy":"Network, SSH, tunnels, and TLS organized visually and applied live.",
|
||||
"One domain and port":"One domain and port","Shared endpoint copy":"The selected protocol uses /; SSH uses /ssh. Available in native Xray mode.","Protocol on /":"Protocol on /","Shared port":"Shared port","Listen IP":"Listen IP","HTTP host":"HTTP host","optional":"optional","XHTTP mode":"XHTTP mode","Security":"Security","No TLS":"No TLS","Certificate file":"Certificate file","Key file":"Key file","Create / update endpoint":"Create / update endpoint",
|
||||
"Configured inbounds":"Configured inbounds","Visual inbound help":"Edit any card visually or use JSON for advanced fields.","New inbound":"New inbound","Visual editor":"Visual editor","Add inbound":"Add inbound","Save changes":"Save changes","Duplicate":"Duplicate","Remove":"Remove","Save config and restart":"Save config and restart",
|
||||
"Old XHTTP configuration?":"Old XHTTP configuration?","Legacy XHTTP migration copy":"Use “Enable SSH /ssh” on the existing inbound card. The panel preserves the inbound, clients, and every current option; it only adds the SSH route and restarts Xray.","Enable SSH /ssh":"Enable SSH /ssh","SSH /ssh enabled":"SSH /ssh enabled","Legacy XHTTP migration":"Legacy XHTTP migration"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Command center":"Central de comando","Overview command copy":"Contas, conexões e infraestrutura em uma leitura rápida.","Access workspace":"Área de acessos","SSH accounts":"Contas SSH","SSH accounts copy":"Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.",
|
||||
"Proxy studio":"Estúdio de proxy","Xray visual":"Xray visual","Xray visual copy":"Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.","Partner operations":"Operação de parceiros","Partner operations copy":"Controle cotas, validade e acesso dos parceiros em um só lugar.",
|
||||
"Fleet control":"Controle da frota","Fleet control copy":"Adicione nós, teste credenciais e configure a infraestrutura remota.","Live fleet":"Frota ao vivo","Server status copy":"Saúde, carga e sessões ativas de cada nó gerenciado.","Observability":"Observabilidade","Monitoring copy":"Recursos, interfaces e capacidade do servidor em tempo real.",
|
||||
"Traffic intelligence":"Inteligência de tráfego","Network traffic":"Tráfego de rede","Network traffic copy":"Histórico diário e mensal para entender o consumo da infraestrutura.","Diagnostics":"Diagnóstico","System logs copy":"Investigue painel, DNSTT e Xray com uma visualização focada.","System studio":"Estúdio do sistema","Settings copy":"Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.",
|
||||
"One domain and port":"Um domínio e uma porta","Shared endpoint copy":"O protocolo selecionado usa /; SSH usa /ssh. Disponível no modo Xray nativo.","Protocol on /":"Protocolo em /","Shared port":"Porta compartilhada","Listen IP":"IP de listen","HTTP host":"Host HTTP","optional":"opcional","XHTTP mode":"Modo XHTTP","Security":"Segurança","No TLS":"Sem TLS","Certificate file":"Arquivo do certificado","Key file":"Arquivo da chave","Create / update endpoint":"Criar / atualizar endpoint",
|
||||
"Configured inbounds":"Inbounds configurados","Visual inbound help":"Edite qualquer cartão visualmente ou use JSON para campos avançados.","New inbound":"Novo inbound","Visual editor":"Editor visual","Add inbound":"Adicionar inbound","Save changes":"Salvar alterações","Duplicate":"Duplicar","Remove":"Remover","Save config and restart":"Salvar configuração e reiniciar",
|
||||
"Old XHTTP configuration?":"Configuração XHTTP antiga?","Legacy XHTTP migration copy":"Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.","Enable SSH /ssh":"Ativar SSH /ssh","SSH /ssh enabled":"SSH /ssh ativado","Legacy XHTTP migration":"Migração de configuração XHTTP antiga"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Infrastructure":"Infrastructure","Infrastructure areas":"Infrastructure areas","Infrastructure area":"Infrastructure area","Waiting for data":"Waiting for data","Operation":"Operation","Accounts":"Accounts","Active accounts":"Active","Online now":"Online now","Counters":"Counters","Save mode":"Save mode",
|
||||
"Nodes":"Nodes","Active nodes":"Active","SSH enabled":"SSH enabled","Xray enabled":"Xray enabled","Sessions":"Sessions","Processor":"Processor","Memory":"Memory","Network now":"Network now","Interfaces":"Interfaces","Today":"Today","This month":"This month","Recent period":"Recent period","Update":"Refresh","Clear history":"Clear history",
|
||||
"Infrastructure workspace copy":"Manage nodes, monitor system health, and inspect traffic from one workspace.","Confirmation":"Confirmation","Confirm action":"Confirm action","Confirm":"Confirm","Completed":"Completed","Action failed":"Action failed","Attention":"Attention","Information":"Information","Close":"Close",
|
||||
"Loading SSH status…":"Loading SSH status…","Loading":"Loading","Could not load SSH status":"Could not load SSH status","Error":"Error","Online":"Online","SSH data updated at {time}":"SSH data updated at {time}","Delete SSH account":"Delete SSH account","The active SSH sessions for this account will be disconnected.":"The active SSH sessions for this account will be disconnected.","Delete account":"Delete account",
|
||||
"Loading Xray status…":"Loading Xray status…","Could not load Xray status":"Could not load Xray status","Remove Xray client":"Remove Xray client","The client will lose access immediately after the configuration reload.":"The client will lose access immediately after the configuration reload.","Remove client":"Remove client","Client removed successfully.":"Client removed successfully.","Xray client":"Xray client",
|
||||
"Loading infrastructure…":"Loading infrastructure…","Infrastructure loaded with fallback data":"Infrastructure loaded with fallback data","{count} active nodes · updated {time}":"{count} active nodes · updated {time}","Delete managed server":"Delete managed server","Delete server \"{name}\"?":"Delete server \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.","Delete server":"Delete server",
|
||||
"Updating live status…":"Updating live status…","Live · updated {time}":"Live · updated {time}","Error loading server status":"Error loading server status","Clean live interface totals":"Clean live interface totals","Clean the live Interface totals now?":"Clean the live Interface totals now?","VnStat daily and monthly history will be preserved.":"VnStat daily and monthly history will be preserved.","Clean totals":"Clean totals","Live interface totals were cleaned.":"Live interface totals were cleaned.","Traffic counters":"Traffic counters","Clean VnStat history":"Clean VnStat history","Clean all daily and monthly traffic history?":"Clean all daily and monthly traffic history?","Live interface totals are separate and will not be reset.":"Live interface totals are separate and will not be reset.","Clean history":"Clean history","VnStat history was cleaned.":"VnStat history was cleaned.","Traffic history":"Traffic history","Clean panel log":"Clean panel log","Clean the current panel log now?":"Clean the current panel log now?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"This only clears the panel log file. Automatic size-based cleanup remains enabled.","Clean log":"Clean log",
|
||||
"Delete reseller":"Delete reseller","Delete reseller \"{name}\"?":"Delete reseller \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Their owned access will be removed and active SSH sessions will be disconnected.","Remove inbound":"Remove inbound","Remove inbound {name}?":"Remove inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clients attached only to this inbound will stop connecting after the configuration is saved.",
|
||||
"This endpoint already has an SSH /ssh route.":"This endpoint already has an SSH /ssh route.","Add SSH /ssh without rebuilding this inbound.":"Add SSH /ssh without rebuilding this inbound.","SSH migration attention":"SSH migration attention","Could not enable SSH /ssh":"Could not enable SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Select a VLESS/VMess inbound using XHTTP.","Load the selected server configuration before enabling SSH.":"Load the selected server configuration before enabling SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH is already enabled on /ssh by inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"Path /ssh is already used by inbound {name}. Edit that path first.","This inbound uses TLS but has no reusable certificate and key file paths.":"This inbound uses TLS but has no reusable certificate and key file paths.","Safe XHTTP migration":"Safe XHTTP migration","Enable SSH on /ssh":"Enable SSH on /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Add SSH to the same endpoint without rebuilding {name}?","Listener":"Listener","Existing path preserved":"Existing path preserved","New SSH path":"New SSH path","Clients preserved":"Clients preserved","Enabling SSH…":"Enabling SSH…","Could not enable SSH: {error}":"Could not enable SSH: {error}","Migration was cancelled because it would alter the old inbound.":"Migration was cancelled because it would alter the old inbound.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh added to the draft without changing {name}.","Enabling SSH /ssh without changing {name}…":"Enabling SSH /ssh without changing {name}…","The SSH route could not be saved. The old inbound was not changed.":"The SSH route could not be saved. The old inbound was not changed.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh is active. The old inbound and all clients were preserved.","Configuration for this server was not loaded.":"Configuration for this server was not loaded.","Invalid visual config: {error}":"Invalid visual config: {error}","Xray could not restart.":"Xray could not restart.","Could not save configuration: {error}":"Could not save configuration: {error}","selected":"selected","this inbound":"this inbound","the old inbound":"the old inbound","untagged":"untagged"
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Infrastructure":"Infraestrutura","Infrastructure areas":"Áreas da infraestrutura","Infrastructure area":"Área da infraestrutura","Waiting for data":"Aguardando dados","Operation":"Operação","Accounts":"Contas","Active accounts":"Ativas","Online now":"Online agora","Counters":"Contadores","Save mode":"Salvar modo",
|
||||
"Nodes":"Nós","Active nodes":"Ativos","SSH enabled":"SSH habilitado","Xray enabled":"Xray habilitado","Sessions":"Sessões","Processor":"Processador","Memory":"Memória","Network now":"Rede agora","Interfaces":"Interfaces","Today":"Hoje","This month":"Este mês","Recent period":"Período recente","Update":"Atualizar","Clear history":"Limpar histórico",
|
||||
"Infrastructure workspace copy":"Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.","Confirmation":"Confirmação","Confirm action":"Confirmar ação","Confirm":"Confirmar","Completed":"Concluído","Action failed":"Ação não concluída","Attention":"Atenção","Information":"Informação","Close":"Fechar",
|
||||
"Loading SSH status…":"Carregando status SSH…","Loading":"Carregando","Could not load SSH status":"Não foi possível carregar o status SSH","Error":"Erro","Online":"Online","SSH data updated at {time}":"Dados SSH atualizados às {time}","Delete SSH account":"Excluir conta SSH","The active SSH sessions for this account will be disconnected.":"As sessões SSH ativas desta conta serão desconectadas.","Delete account":"Excluir conta",
|
||||
"Loading Xray status…":"Carregando status do Xray…","Could not load Xray status":"Não foi possível carregar o status do Xray","Remove Xray client":"Remover cliente Xray","The client will lose access immediately after the configuration reload.":"O cliente perderá o acesso imediatamente após recarregar a configuração.","Remove client":"Remover cliente","Client removed successfully.":"Cliente removido com sucesso.","Xray client":"Cliente Xray",
|
||||
"Loading infrastructure…":"Carregando infraestrutura…","Infrastructure loaded with fallback data":"Infraestrutura carregada com dados locais de segurança","{count} active nodes · updated {time}":"{count} nós ativos · atualizado às {time}","Delete managed server":"Excluir servidor gerenciado","Delete server \"{name}\"?":"Excluir o servidor \"{name}\"?","The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions.":"O nó remoto não será apagado, mas será removido deste painel e deixará de receber ações gerenciadas.","Delete server":"Excluir servidor",
|
||||
"Updating live status…":"Atualizando status ao vivo…","Live · updated {time}":"Ao vivo · atualizado às {time}","Error loading server status":"Erro ao carregar status do servidor","Clean live interface totals":"Limpar totais ao vivo das interfaces","Clean the live Interface totals now?":"Limpar agora os totais ao vivo das interfaces?","VnStat daily and monthly history will be preserved.":"O histórico diário e mensal do VnStat será preservado.","Clean totals":"Limpar totais","Live interface totals were cleaned.":"Os totais ao vivo das interfaces foram limpos.","Traffic counters":"Contadores de tráfego","Clean VnStat history":"Limpar histórico VnStat","Clean all daily and monthly traffic history?":"Limpar todo o histórico diário e mensal de tráfego?","Live interface totals are separate and will not be reset.":"Os totais ao vivo das interfaces são separados e não serão zerados.","Clean history":"Limpar histórico","VnStat history was cleaned.":"O histórico VnStat foi limpo.","Traffic history":"Histórico de tráfego","Clean panel log":"Limpar log do painel","Clean the current panel log now?":"Limpar agora o log atual do painel?","This only clears the panel log file. Automatic size-based cleanup remains enabled.":"Isso limpa somente o arquivo de log do painel. A limpeza automática por tamanho continuará ativa.","Clean log":"Limpar log",
|
||||
"Delete reseller":"Excluir revendedor","Delete reseller \"{name}\"?":"Excluir o revendedor \"{name}\"?","Their owned access will be removed and active SSH sessions will be disconnected.":"Os acessos pertencentes a ele serão removidos e as sessões SSH ativas serão desconectadas.","Remove inbound":"Remover inbound","Remove inbound {name}?":"Remover o inbound {name}?","Clients attached only to this inbound will stop connecting after the configuration is saved.":"Clientes vinculados somente a este inbound deixarão de conectar após salvar a configuração.",
|
||||
"This endpoint already has an SSH /ssh route.":"Este endpoint já possui uma rota SSH /ssh.","Add SSH /ssh without rebuilding this inbound.":"Adicione SSH /ssh sem recriar este inbound.","SSH migration attention":"Atenção na migração SSH","Could not enable SSH /ssh":"Não foi possível ativar SSH /ssh","Select a VLESS/VMess inbound using XHTTP.":"Selecione um inbound VLESS/VMess usando XHTTP.","Load the selected server configuration before enabling SSH.":"Carregue a configuração do servidor selecionado antes de ativar SSH.","Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first.":"O SSH compartilhado exige o modo Xray nativo. Selecione Internal native emulator e salve o modo primeiro.","Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.":"O inbound {name} usa {security}, que o SSH XHTTP nativo não suporta. Use TLS ou sem TLS.","SSH is already enabled on /ssh by inbound {name}.":"SSH já está ativado em /ssh pelo inbound {name}.","Path /ssh is already used by inbound {name}. Edit that path first.":"O caminho /ssh já é usado pelo inbound {name}. Edite esse caminho primeiro.","This inbound uses TLS but has no reusable certificate and key file paths.":"Este inbound usa TLS, mas não possui caminhos reutilizáveis para certificado e chave.","Safe XHTTP migration":"Migração XHTTP segura","Enable SSH on /ssh":"Ativar SSH em /ssh","Add SSH to the same endpoint without rebuilding {name}?":"Adicionar SSH ao mesmo endpoint sem recriar {name}?","Listener":"Listener","Existing path preserved":"Path existente preservado","New SSH path":"Novo path SSH","Clients preserved":"Clientes preservados","Enabling SSH…":"Ativando SSH…","Could not enable SSH: {error}":"Não foi possível ativar SSH: {error}","Migration was cancelled because it would alter the old inbound.":"A migração foi cancelada porque alteraria o inbound antigo.","SSH /ssh added to the draft without changing {name}.":"SSH /ssh foi adicionado ao rascunho sem alterar {name}.","Enabling SSH /ssh without changing {name}…":"Ativando SSH /ssh sem alterar {name}…","The SSH route could not be saved. The old inbound was not changed.":"A rota SSH não pôde ser salva. O inbound antigo não foi alterado.","SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log.":"SSH /ssh foi salvo, mas o Xray não conseguiu reiniciar. Verifique o log e use o botão Reiniciar.","SSH /ssh is active. The old inbound and all clients were preserved.":"SSH /ssh está ativo. O inbound antigo e todos os clientes foram preservados.","Configuration for this server was not loaded.":"A configuração deste servidor não foi carregada.","Invalid visual config: {error}":"Configuração visual inválida: {error}","Xray could not restart.":"O Xray não conseguiu reiniciar.","Could not save configuration: {error}":"Não foi possível salvar a configuração: {error}","selected":"selecionado","this inbound":"este inbound","the old inbound":"o inbound antigo","untagged":"sem tag"
|
||||
});
|
||||
Object.assign(I18N_TEXT["en-US"], {
|
||||
"Create user":"Create user","Edit user":"Edit user","Create SSH user":"Create SSH user","SSH user saved successfully.":"SSH user saved successfully.","SSH and SlowDNS areas":"SSH and SlowDNS areas","SSH and SlowDNS area":"SSH and SlowDNS area",
|
||||
"01 · Management":"01 · Management","02 · Registration":"02 · Registration","SSH and SlowDNS users":"SSH and SlowDNS users","SSH users section copy":"Review connections, limits, expiration, and actions for every account.","SSH create section copy":"Set authentication, expiration, connections, and speed in a dedicated screen.",
|
||||
"Configuration":"Configuration","Xray areas":"Xray areas","Xray area":"Xray area","Xray users section copy":"Review clients, connections, expiration, and traffic by inbound.","Create Xray user":"Create Xray user","Xray create section copy":"Choose the inbound and register the client without opening the users table.","New Xray client":"New Xray client","Loading inbounds…":"Loading inbounds…","Select where the client will be added.":"Select where the client will be added.","Generated automatically":"Generated automatically","Generate":"Generate","Display name":"Display name","Email / identifier":"Email / identifier","Expires on":"Expires on","Maximum connections":"Maximum connections","0 = unlimited":"0 = unlimited","Back to users":"Back to users","Fill in the new client details.":"Fill in the new client details.",
|
||||
"No compatible inbound found":"No compatible inbound found","No inbound":"No inbound","The client will be added to {tag} on port {port}.":"The client will be added to {tag} on port {port}.","Create or enable a compatible inbound before adding a client.":"Create or enable a compatible inbound before adding a client.","Ready to create a new Xray client.":"Ready to create a new Xray client.","Waiting for a compatible inbound.":"Waiting for a compatible inbound.","Select a compatible inbound.":"Select a compatible inbound.","Creating Xray client…":"Creating Xray client…","Xray user created successfully.":"Xray user created successfully.","Xray user":"Xray user","Could not create the Xray user: {error}":"Could not create the Xray user: {error}",
|
||||
"03 · Service":"03 · Service","04 · Diagnostics":"04 · Diagnostics","Xray configuration section copy":"Edit the endpoint, inbounds, TLS, and advanced options visually or as JSON.","Xray logs section copy":"Review the latest service messages in a focused screen.",
|
||||
"Reseller areas":"Reseller areas","Reseller area":"Reseller area","Create reseller":"Create reseller","Edit reseller":"Edit reseller","Registered resellers":"Registered resellers","Reseller list section copy":"Review quotas, shared usage, expiration, and status for every partner.","Reseller create section copy":"Set login, shared limit, expiration, and access in a dedicated screen.","Reseller saved successfully.":"Reseller saved successfully.",
|
||||
"Configuration areas":"Configuration areas","Configuration area":"Configuration area","Network and SSH":"Network and SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"TLS forwarders","01 · Base":"01 · Base","02 · DNS tunnel":"02 · DNS tunnel","03 · UDP":"03 · UDP","04 · Security":"04 · Security","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, default limits, idle timeout, and the connection banner.","SlowDNS section copy":"Manage domains, local DNS, capacity, queues, and controlled restarts.","UDP section copy":"Set the listener, capacity, map expiration, and service restart.","TLS section copy":"Create TLS listeners with automatic, pasted, or file-based certificates.","Xray core section copy":"Enable the core, choose the runtime, and apply safe native tuning."
|
||||
});
|
||||
Object.assign(I18N_TEXT["pt-BR"], {
|
||||
"Create user":"Criar usuário","Edit user":"Editar usuário","Create SSH user":"Criar usuário SSH","SSH user saved successfully.":"Usuário SSH salvo com sucesso.","SSH and SlowDNS areas":"Áreas SSH e SlowDNS","SSH and SlowDNS area":"Área SSH e SlowDNS",
|
||||
"01 · Management":"01 · Gestão","02 · Registration":"02 · Cadastro","SSH and SlowDNS users":"Usuários SSH e SlowDNS","SSH users section copy":"Consulte conexões, limites, validade e ações de cada conta.","SSH create section copy":"Defina autenticação, validade, conexões e velocidade em uma tela dedicada.",
|
||||
"Configuration":"Configuração","Xray areas":"Áreas do Xray","Xray area":"Área do Xray","Xray users section copy":"Consulte clientes, conexões, validade e tráfego separados por inbound.","Create Xray user":"Criar usuário Xray","Xray create section copy":"Escolha o inbound e cadastre o cliente sem abrir a tabela de usuários.","New Xray client":"Novo cliente Xray","Loading inbounds…":"Carregando inbounds…","Select where the client will be added.":"Selecione onde o cliente será adicionado.","Generated automatically":"Gerado automaticamente","Generate":"Gerar","Display name":"Nome de exibição","Email / identifier":"Email / identificação","Expires on":"Expira em","Maximum connections":"Máximo de conexões","0 = unlimited":"0 = ilimitado","Back to users":"Voltar aos usuários","Fill in the new client details.":"Preencha os dados do novo cliente.",
|
||||
"No compatible inbound found":"Nenhum inbound compatível encontrado","No inbound":"Sem inbound","The client will be added to {tag} on port {port}.":"O cliente será adicionado em {tag} na porta {port}.","Create or enable a compatible inbound before adding a client.":"Crie ou ative um inbound compatível antes de adicionar um cliente.","Ready to create a new Xray client.":"Pronto para criar um novo cliente Xray.","Waiting for a compatible inbound.":"Aguardando um inbound compatível.","Select a compatible inbound.":"Selecione um inbound compatível.","Creating Xray client…":"Criando cliente Xray…","Xray user created successfully.":"Usuário Xray criado com sucesso.","Xray user":"Usuário Xray","Could not create the Xray user: {error}":"Não foi possível criar o usuário Xray: {error}",
|
||||
"03 · Service":"03 · Serviço","04 · Diagnostics":"04 · Diagnóstico","Xray configuration section copy":"Edite endpoint, inbounds, TLS e opções avançadas visualmente ou em JSON.","Xray logs section copy":"Acompanhe as últimas mensagens do serviço em uma tela focada.",
|
||||
"Reseller areas":"Áreas de revendedores","Reseller area":"Área de revendedores","Create reseller":"Criar revendedor","Edit reseller":"Editar revendedor","Registered resellers":"Revendedores cadastrados","Reseller list section copy":"Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.","Reseller create section copy":"Defina login, limite compartilhado, validade e acesso em uma tela dedicada.","Reseller saved successfully.":"Revendedor salvo com sucesso.",
|
||||
"Configuration areas":"Áreas de configuração","Configuration area":"Área de configuração","Network and SSH":"Rede e SSH","SlowDNS / DNSTT":"SlowDNS / DNSTT","TLS forwarders":"Encaminhadores TLS","01 · Base":"01 · Base","02 · DNS tunnel":"02 · Túnel DNS","03 · UDP":"03 · UDP","04 · Security":"04 · Segurança","05 · Core":"05 · Core","Network and SSH section copy":"Configure listeners, limites padrão, tempo ocioso e o banner de conexão.","SlowDNS section copy":"Gerencie domínios, DNS local, capacidade, filas e reinício controlado.","UDP section copy":"Defina listener, capacidade, expiração de mapa e reinício do serviço.","TLS section copy":"Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.","Xray core section copy":"Ative o core, escolha o runtime e aplique os ajustes nativos seguros."
|
||||
});
|
||||
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
|
||||
let currentLang = detectInitialLanguage();
|
||||
let i18nTranslating = false;
|
||||
@@ -245,8 +323,6 @@ const rStatus = document.getElementById("rStatus");
|
||||
|
||||
// User form
|
||||
const userForm = document.getElementById("userForm");
|
||||
const userFormWrap = document.getElementById("userFormWrap");
|
||||
const toggleFormBtn = document.getElementById("toggleFormBtn");
|
||||
const cancelUserBtn = document.getElementById("cancelUserBtn");
|
||||
const newUserBtn = document.getElementById("newUserBtn");
|
||||
const saveUserBtn = document.getElementById("saveUserBtn");
|
||||
@@ -261,6 +337,11 @@ const fMaxConn = document.getElementById("fMaxConn");
|
||||
const fExpires = document.getElementById("fExpires");
|
||||
const fUp = document.getElementById("fUp");
|
||||
const fDown = document.getElementById("fDown");
|
||||
const sshLiveStatus = document.getElementById("sshLiveStatus");
|
||||
const sshMetricState = document.getElementById("sshMetricState");
|
||||
const sshMetricTotal = document.getElementById("sshMetricTotal");
|
||||
const sshMetricActive = document.getElementById("sshMetricActive");
|
||||
const sshMetricOnline = document.getElementById("sshMetricOnline");
|
||||
|
||||
// Xray
|
||||
const xrayChip = document.getElementById("xrayChip");
|
||||
@@ -290,8 +371,13 @@ const resellerFormTitle = document.getElementById("resellerFormTitle");
|
||||
const resellerForm = document.getElementById("resellerForm");
|
||||
const rUsername = document.getElementById("rUsername");
|
||||
const rPassword = document.getElementById("rPassword");
|
||||
const rParent = document.getElementById("rParent");
|
||||
const rQuotaMode = document.getElementById("rQuotaMode");
|
||||
const rMaxUsers = document.getElementById("rMaxUsers");
|
||||
const rCredits = document.getElementById("rCredits");
|
||||
const rExpires = document.getElementById("rExpires");
|
||||
const rWhatsApp = document.getElementById("rWhatsApp");
|
||||
const rMonthlyPrice = document.getElementById("rMonthlyPrice");
|
||||
const rActive = document.getElementById("rActive");
|
||||
|
||||
// Managed servers
|
||||
@@ -312,11 +398,18 @@ const serverFormStatus = document.getElementById("serverFormStatus");
|
||||
const serversListView = document.getElementById("serversListView");
|
||||
const serverConfigSubpage = document.getElementById("serverConfigSubpage");
|
||||
const cfgServerName = document.getElementById("cfgServerName");
|
||||
const managedConfigEditor = document.getElementById("managedConfigEditor");
|
||||
const managedConfigStatus = document.getElementById("managedConfigStatus");
|
||||
const serversStatusGrid = document.getElementById("serversStatusGrid");
|
||||
const serversStatusPageStatus = document.getElementById("serversStatusPageStatus");
|
||||
const serversStatusCountChip = document.getElementById("serversStatusCountChip");
|
||||
const fleetLiveStatus = document.getElementById("fleetLiveStatus");
|
||||
const fleetMetricNodes = document.getElementById("fleetMetricNodes");
|
||||
const fleetMetricActive = document.getElementById("fleetMetricActive");
|
||||
const fleetMetricSSH = document.getElementById("fleetMetricSSH");
|
||||
const fleetMetricXray = document.getElementById("fleetMetricXray");
|
||||
const fleetStatusOnline = document.getElementById("fleetStatusOnline");
|
||||
const fleetStatusOffline = document.getElementById("fleetStatusOffline");
|
||||
const fleetStatusSessions = document.getElementById("fleetStatusSessions");
|
||||
|
||||
// Stats
|
||||
const cpuVal = document.getElementById("cpuVal");
|
||||
@@ -327,6 +420,8 @@ const memDetail = document.getElementById("memDetail");
|
||||
const ifaceBody = document.getElementById("ifaceBody");
|
||||
const ifaceSummary = document.getElementById("ifaceSummary");
|
||||
const statsUpdated = document.getElementById("statsUpdated");
|
||||
const statsNetVal = document.getElementById("statsNetVal");
|
||||
const statsIfaceVal = document.getElementById("statsIfaceVal");
|
||||
const resetIfaceStatsBtn = document.getElementById("resetIfaceStatsBtn");
|
||||
const dnsttDashboardCard = document.getElementById("dnsttDashboardCard");
|
||||
const dnsttHealthUpdated = document.getElementById("dnsttHealthUpdated");
|
||||
@@ -344,9 +439,99 @@ const vnstatStatus = document.getElementById("vnstatStatus");
|
||||
const vnTodayTotal = document.getElementById("vnTodayTotal");
|
||||
const vnMonthTotal = document.getElementById("vnMonthTotal");
|
||||
const vnIfaceCount = document.getElementById("vnIfaceCount");
|
||||
const vnLatestPeriod = document.getElementById("vnLatestPeriod");
|
||||
const reloadVnstatBtn = document.getElementById("reloadVnstatBtn");
|
||||
const resetVnstatBtn = document.getElementById("resetVnstatBtn");
|
||||
|
||||
// Shared panel-native dialog and toast surfaces. Destructive and migration
|
||||
// actions use these instead of browser confirm/alert prompts.
|
||||
const panelConfirmDialog = document.getElementById("panelConfirmDialog");
|
||||
const panelDialogCard = panelConfirmDialog?.querySelector(".panel-dialog-card");
|
||||
const panelDialogIcon = document.getElementById("panelDialogIcon");
|
||||
const panelDialogEyebrow = document.getElementById("panelDialogEyebrow");
|
||||
const panelDialogTitle = document.getElementById("panelDialogTitle");
|
||||
const panelDialogMessage = document.getElementById("panelDialogMessage");
|
||||
const panelDialogDetail = document.getElementById("panelDialogDetail");
|
||||
const panelDialogCancelBtn = document.getElementById("panelDialogCancelBtn");
|
||||
const panelDialogConfirmBtn = document.getElementById("panelDialogConfirmBtn");
|
||||
const panelToastStack = document.getElementById("panelToastStack");
|
||||
let panelDialogResolver = null;
|
||||
let panelDialogLastFocus = null;
|
||||
|
||||
function closePanelConfirm(accepted = false) {
|
||||
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
|
||||
panelConfirmDialog.classList.add("hidden");
|
||||
panelConfirmDialog.setAttribute("aria-hidden", "true");
|
||||
document.body.classList.remove("panel-dialog-open");
|
||||
const resolver = panelDialogResolver;
|
||||
panelDialogResolver = null;
|
||||
resolver?.(!!accepted);
|
||||
panelDialogLastFocus?.focus?.();
|
||||
panelDialogLastFocus = null;
|
||||
}
|
||||
|
||||
function panelConfirm(options = {}) {
|
||||
const opts = typeof options === "string" ? { message: options } : options;
|
||||
if (!panelConfirmDialog || !panelDialogConfirmBtn) return Promise.resolve(false);
|
||||
if (panelDialogResolver) closePanelConfirm(false);
|
||||
panelDialogLastFocus = document.activeElement;
|
||||
const tone = opts.tone || (opts.danger ? "danger" : "default");
|
||||
panelDialogCard?.classList.toggle("is-danger", tone === "danger");
|
||||
panelDialogCard?.classList.toggle("is-success", tone === "success");
|
||||
if (panelDialogIcon) panelDialogIcon.textContent = opts.icon || (tone === "danger" ? "!" : tone === "success" ? "✓" : "?");
|
||||
if (panelDialogEyebrow) panelDialogEyebrow.textContent = opts.eyebrow || t("Confirmation");
|
||||
if (panelDialogTitle) panelDialogTitle.textContent = opts.title || t("Confirm action");
|
||||
if (panelDialogMessage) panelDialogMessage.textContent = opts.message || "";
|
||||
if (panelDialogDetail) {
|
||||
panelDialogDetail.textContent = opts.detail || "";
|
||||
panelDialogDetail.classList.toggle("hidden", !opts.detail);
|
||||
}
|
||||
if (panelDialogCancelBtn) panelDialogCancelBtn.textContent = opts.cancelLabel || t("Cancel");
|
||||
panelDialogConfirmBtn.textContent = opts.confirmLabel || t("Confirm");
|
||||
panelDialogConfirmBtn.className = tone === "danger" ? "btn btn-danger" : "btn";
|
||||
panelConfirmDialog.classList.remove("hidden");
|
||||
panelConfirmDialog.setAttribute("aria-hidden", "false");
|
||||
document.body.classList.add("panel-dialog-open");
|
||||
setTimeout(() => panelDialogConfirmBtn.focus(), 0);
|
||||
return new Promise(resolve => { panelDialogResolver = resolve; });
|
||||
}
|
||||
|
||||
function showPanelToast(message, tone = "info", title = "", duration = 4800) {
|
||||
if (!panelToastStack || !message) return null;
|
||||
const toast = document.createElement("article");
|
||||
toast.className = `panel-toast ${tone}`;
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "panel-toast-icon";
|
||||
icon.textContent = tone === "success" ? "✓" : tone === "error" ? "!" : tone === "warning" ? "i" : "•";
|
||||
const copy = document.createElement("div");
|
||||
copy.className = "panel-toast-copy";
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = title || (tone === "success" ? t("Completed") : tone === "error" ? t("Action failed") : tone === "warning" ? t("Attention") : t("Information"));
|
||||
const body = document.createElement("p");
|
||||
body.textContent = message;
|
||||
copy.append(heading, body);
|
||||
const close = document.createElement("button");
|
||||
close.className = "panel-toast-close";
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", t("Close"));
|
||||
close.textContent = "×";
|
||||
const remove = () => toast.remove();
|
||||
close.addEventListener("click", remove);
|
||||
toast.append(icon, copy, close);
|
||||
panelToastStack.appendChild(toast);
|
||||
if (duration > 0) setTimeout(remove, duration);
|
||||
return toast;
|
||||
}
|
||||
|
||||
panelDialogCancelBtn?.addEventListener("click", () => closePanelConfirm(false));
|
||||
panelDialogConfirmBtn?.addEventListener("click", () => closePanelConfirm(true));
|
||||
panelConfirmDialog?.querySelector("[data-panel-dialog-close]")?.addEventListener("click", () => closePanelConfirm(false));
|
||||
document.addEventListener("keydown", event => {
|
||||
if (!panelConfirmDialog || panelConfirmDialog.classList.contains("hidden")) return;
|
||||
if (event.key === "Escape") closePanelConfirm(false);
|
||||
if (event.key === "Enter" && document.activeElement !== panelDialogCancelBtn) closePanelConfirm(true);
|
||||
});
|
||||
|
||||
// ─── API helper ───────────────────────────────────────────────────────────────
|
||||
async function api(path, opts = {}) {
|
||||
const o = Object.assign({ headers: {} }, opts);
|
||||
@@ -355,7 +540,9 @@ async function api(path, opts = {}) {
|
||||
"X-Session-Token": sessionToken,
|
||||
});
|
||||
const res = await fetch(path, o);
|
||||
if (res.status === 401 || res.status === 403) throw new Error("auth");
|
||||
// A 403 is an in-session permission or quota error; only 401 means the
|
||||
// session is no longer valid and should return to the login screen.
|
||||
if (res.status === 401) throw new Error("auth");
|
||||
return res;
|
||||
}
|
||||
function withServerParam(path, serverID) {
|
||||
@@ -548,4 +735,3 @@ function patchRenderedInbounds(inbounds) {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+187
-17
@@ -4,13 +4,126 @@ const tabTitles = {
|
||||
ssh: ["Accounts", "SSH / SlowDNS"],
|
||||
xray: ["Accounts", "Xray Users"],
|
||||
resellers: ["Administration", "Resellers"],
|
||||
servers: ["Administration", "Servers"],
|
||||
"servers-status": ["Administration", "Servers Status"],
|
||||
stats: ["Server", "Monitoring"],
|
||||
vnstat: ["Traffic", "VnStat"],
|
||||
servers: ["Infrastructure", "Servers"],
|
||||
"servers-status": ["Infrastructure", "Servers Status"],
|
||||
stats: ["Infrastructure", "Monitoring"],
|
||||
vnstat: ["Infrastructure", "Traffic"],
|
||||
logs: ["System", "Logs"],
|
||||
bot: ["Vendas", "Bot / Telegram"],
|
||||
server: ["System", "Settings"],
|
||||
};
|
||||
const infrastructureTabs = ["servers", "servers-status", "stats", "vnstat"];
|
||||
const infrastructureNavItems = [
|
||||
{ tab:"servers", icon:"▣", label:"Servers" },
|
||||
{ tab:"servers-status", icon:"●", label:"Status" },
|
||||
{ tab:"stats", icon:"◴", label:"Server" },
|
||||
{ tab:"vnstat", icon:"⇅", label:"Traffic" },
|
||||
];
|
||||
|
||||
function syncInfrastructureNavigation(tab = currentTab) {
|
||||
if (!infrastructureTabs.includes(tab)) return;
|
||||
document.querySelectorAll("[data-infra-tab]").forEach(button => button.classList.toggle("active", button.dataset.infraTab === tab));
|
||||
document.querySelectorAll(".infra-section-select").forEach(select => { select.value = tab; });
|
||||
}
|
||||
|
||||
function mountInfrastructureNavigation() {
|
||||
document.querySelectorAll(".infra-nav-mount").forEach(mount => {
|
||||
const shell = document.createElement("div");
|
||||
shell.className = "infra-nav-shell";
|
||||
const nav = document.createElement("nav");
|
||||
nav.className = "infra-section-nav";
|
||||
nav.setAttribute("aria-label", t("Infrastructure areas"));
|
||||
const select = document.createElement("select");
|
||||
select.className = "infra-section-select";
|
||||
select.setAttribute("aria-label", t("Infrastructure area"));
|
||||
infrastructureNavItems.forEach(item => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset.infraTab = item.tab;
|
||||
const icon = document.createElement("span");
|
||||
icon.textContent = item.icon;
|
||||
button.append(icon, document.createTextNode(" " + t(item.label)));
|
||||
button.addEventListener("click", () => selectTab(item.tab));
|
||||
nav.appendChild(button);
|
||||
const option = document.createElement("option");
|
||||
option.value = item.tab;
|
||||
option.textContent = t(item.label);
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.addEventListener("change", () => selectTab(select.value));
|
||||
shell.append(nav, select);
|
||||
mount.replaceChildren(shell);
|
||||
});
|
||||
syncInfrastructureNavigation();
|
||||
}
|
||||
|
||||
const workspaceSectionDefaults = {
|
||||
ssh: "users",
|
||||
xray: "users",
|
||||
resellers: "users",
|
||||
config: "general",
|
||||
};
|
||||
|
||||
function workspaceSectionRoot(workspace) {
|
||||
const tab = workspace === "config" ? "server" : workspace;
|
||||
return document.getElementById(`tab-${tab}`);
|
||||
}
|
||||
|
||||
function activeWorkspaceSection(workspace) {
|
||||
const root = workspaceSectionRoot(workspace);
|
||||
return root?.querySelector(`[data-workspace-panel="${workspace}"].active`)?.dataset.workspaceSectionPanel
|
||||
|| workspaceSectionDefaults[workspace]
|
||||
|| "";
|
||||
}
|
||||
|
||||
function setWorkspaceSection(workspace, section, options = {}) {
|
||||
const root = workspaceSectionRoot(workspace);
|
||||
if (!root) return false;
|
||||
const panels = Array.from(root.querySelectorAll(`[data-workspace-panel="${workspace}"]`));
|
||||
const targets = panels.filter(panel => panel.dataset.workspaceSectionPanel === section && !panel.classList.contains("hidden"));
|
||||
if (!targets.length) {
|
||||
section = workspaceSectionDefaults[workspace] || panels.find(panel => !panel.classList.contains("hidden"))?.dataset.workspaceSectionPanel || "";
|
||||
}
|
||||
panels.forEach(panel => panel.classList.toggle("active", panel.dataset.workspaceSectionPanel === section));
|
||||
root.querySelectorAll(`[data-workspace="${workspace}"][data-workspace-section]`).forEach(button => {
|
||||
const active = button.dataset.workspaceSection === section;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
const select = root.querySelector(`[data-workspace-select="${workspace}"]`);
|
||||
if (select) select.value = section;
|
||||
|
||||
if (!options.silent) {
|
||||
if (workspace === "xray" && section === "config" && currentRole === "superadmin" && typeof loadWizardFromConfig === "function") loadWizardFromConfig();
|
||||
if (workspace === "xray" && section === "logs" && currentRole === "superadmin" && typeof loadXrayLogs === "function") loadXrayLogs();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function prepareWorkspaceSection(workspace, section) {
|
||||
if (section !== "create") return;
|
||||
if (workspace === "ssh" && typeof prepareNewSSHUser === "function") prepareNewSSHUser();
|
||||
if (workspace === "xray" && typeof prepareXrayClientCreator === "function") prepareXrayClientCreator();
|
||||
if (workspace === "resellers" && typeof prepareNewReseller === "function") prepareNewReseller();
|
||||
}
|
||||
|
||||
function navigateWorkspaceSection(workspace, section) {
|
||||
prepareWorkspaceSection(workspace, section);
|
||||
return setWorkspaceSection(workspace, section);
|
||||
}
|
||||
|
||||
function mountWorkspaceSectionNavigation() {
|
||||
document.querySelectorAll("[data-workspace][data-workspace-section]").forEach(button => {
|
||||
button.setAttribute("role", "tab");
|
||||
button.addEventListener("click", () => navigateWorkspaceSection(button.dataset.workspace, button.dataset.workspaceSection));
|
||||
});
|
||||
document.querySelectorAll("[data-workspace-select]").forEach(select => {
|
||||
select.addEventListener("change", () => navigateWorkspaceSection(select.dataset.workspaceSelect, select.value));
|
||||
});
|
||||
Object.entries(workspaceSectionDefaults).forEach(([workspace, section]) => setWorkspaceSection(workspace, section, { silent:true }));
|
||||
}
|
||||
|
||||
function updatePageHeading() {
|
||||
const [eyebrow, title] = tabTitles[currentTab] || ["Dashboard", currentTab];
|
||||
if (pageEyebrow) pageEyebrow.textContent = t(eyebrow);
|
||||
@@ -20,12 +133,14 @@ function updatePageHeading() {
|
||||
function selectTab(tab) {
|
||||
currentTab = tab;
|
||||
const pane = document.getElementById("tab-" + tab);
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${tab}"]`);
|
||||
if (!pane || !btn) return;
|
||||
const navTab = infrastructureTabs.includes(tab) ? "servers" : tab;
|
||||
const btn = document.querySelector(`.tab-btn[data-tab="${navTab}"]`);
|
||||
if (!pane) return;
|
||||
document.querySelectorAll(".tab-btn").forEach(b => b.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-pane").forEach(p => p.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
btn?.classList.add("active");
|
||||
pane.classList.add("active");
|
||||
syncInfrastructureNavigation(tab);
|
||||
updatePageHeading();
|
||||
document.body.classList.remove("sidebar-open");
|
||||
|
||||
@@ -33,14 +148,18 @@ function selectTab(tab) {
|
||||
if (tab === "xray") {
|
||||
loadXrayStatus();
|
||||
loadInbounds({ silent: true });
|
||||
if (currentRole === "superadmin") loadWizardFromConfig();
|
||||
if (currentRole === "superadmin" && activeWorkspaceSection("xray") === "config") loadWizardFromConfig();
|
||||
}
|
||||
if (tab === "stats" && currentRole === "superadmin") loadStats();
|
||||
if (tab === "vnstat" && currentRole === "superadmin") loadVnstat();
|
||||
if (tab === "servers-status" && currentRole === "superadmin") loadServersStatus();
|
||||
if (tab === "resellers" && currentRole === "superadmin") loadResellers();
|
||||
if (tab === "resellers") loadResellers();
|
||||
if (tab === "servers" && currentRole === "superadmin") loadServers();
|
||||
if (tab === "bot" && currentRole === "superadmin" && typeof loadBotTab === "function") loadBotTab();
|
||||
}
|
||||
|
||||
mountInfrastructureNavigation();
|
||||
mountWorkspaceSectionNavigation();
|
||||
document.querySelectorAll(".tab-btn").forEach(btn => btn.addEventListener("click", () => selectTab(btn.dataset.tab)));
|
||||
menuToggle?.addEventListener("click", () => document.body.classList.add("sidebar-open"));
|
||||
drawerBackdrop?.addEventListener("click", () => document.body.classList.remove("sidebar-open"));
|
||||
@@ -54,7 +173,7 @@ loginPass.addEventListener("keydown", e => { if (e.key==="Enter") doLogin(); });
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await api("/api/auth/logout", { method: "POST" }); } catch {}
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
@@ -81,7 +200,7 @@ async function doLogin() {
|
||||
sessionToken = data.token;
|
||||
currentRole = data.role;
|
||||
currentUser = data.username;
|
||||
localStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
sessionStorage.setItem("SESSION_TOKEN", sessionToken);
|
||||
loginOverlay.classList.add("hidden");
|
||||
mainApp.classList.remove("hidden");
|
||||
initAfterLogin();
|
||||
@@ -99,6 +218,13 @@ function clearTimers() {
|
||||
}
|
||||
|
||||
function initAfterLogin() {
|
||||
if (currentRole === "superadmin") {
|
||||
currentQuotaMode = "slots";
|
||||
currentCreditBalance = 0;
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (input) { input.disabled = false; input.title = ""; }
|
||||
});
|
||||
}
|
||||
meUsername.textContent = currentUser;
|
||||
mainApp.classList.remove("role-superadmin", "role-reseller");
|
||||
mainApp.classList.add(currentRole === "superadmin" ? "role-superadmin" : "role-reseller");
|
||||
@@ -115,6 +241,13 @@ function initAfterLogin() {
|
||||
document.querySelectorAll(".xray-admin-only").forEach(el => {
|
||||
el.classList.toggle("hidden", currentRole !== "superadmin");
|
||||
});
|
||||
document.querySelectorAll("option.xray-admin-only").forEach(option => {
|
||||
option.hidden = currentRole !== "superadmin";
|
||||
option.disabled = currentRole !== "superadmin";
|
||||
});
|
||||
if (currentRole !== "superadmin" && ["config", "logs"].includes(activeWorkspaceSection("xray"))) {
|
||||
setWorkspaceSection("xray", "users", { silent:true });
|
||||
}
|
||||
|
||||
resellerInfoCard.classList.toggle("hidden", currentRole !== "reseller");
|
||||
dashboardQuotaCard?.classList.toggle("hidden", currentRole !== "reseller");
|
||||
@@ -124,6 +257,7 @@ function initAfterLogin() {
|
||||
|
||||
if (currentRole === "superadmin") {
|
||||
loadDashboardStats();
|
||||
if (typeof loadUpdateStatus === "function") loadUpdateStatus();
|
||||
statsTimer = setInterval(() => {
|
||||
loadDashboardStats();
|
||||
if (currentTab === "stats") loadStats();
|
||||
@@ -149,13 +283,28 @@ async function loadMe() {
|
||||
const res = await api("/api/auth/me");
|
||||
const d = await res.json();
|
||||
dashboardCache.me = d;
|
||||
currentQuotaMode = d.quota_mode || "slots";
|
||||
currentCreditBalance = d.credit_balance || 0;
|
||||
const creditPlan = currentQuotaMode === "credits";
|
||||
[fExpires, document.getElementById("xCreateExpiry"), document.getElementById("editXrayExpiry")].forEach(input => {
|
||||
if (!input) return;
|
||||
input.disabled = creditPlan;
|
||||
input.title = creditPlan ? "Planos por crédito usam 31 dias e são renovados pelo botão +30d." : "";
|
||||
});
|
||||
const used = d.used_users ?? 0;
|
||||
const max = d.max_users || 0;
|
||||
rUsedMax.textContent = used + " / " + (max || "∞");
|
||||
rUsedMax.textContent = currentQuotaMode === "credits"
|
||||
? `${currentCreditBalance} créditos`
|
||||
: `${used + (d.child_allocation || 0)} / ${max || "∞"}`;
|
||||
rExpiry.textContent = d.expires_at ? fmtDate(d.expires_at) : t("No expiration");
|
||||
rStatus.textContent = d.is_active ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = d.is_active ? "var(--success)" : "var(--danger)";
|
||||
updateQuotaCard(used, max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
const effectiveActive = d.effective_active ?? d.is_active;
|
||||
rStatus.textContent = effectiveActive ? t("Active") : t("Suspended");
|
||||
rStatus.style.color = effectiveActive ? "var(--success)" : "var(--danger)";
|
||||
if (currentQuotaMode === "credits") {
|
||||
updateCreditQuotaCard(currentCreditBalance, d.used_ssh_users || 0, d.used_xray_users || 0, d.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard(used + (d.child_allocation || 0), max, d.used_ssh_users || 0, d.used_xray_users || 0);
|
||||
}
|
||||
renderDashboardCounters();
|
||||
} catch {}
|
||||
}
|
||||
@@ -206,6 +355,24 @@ function updateQuotaCard(used, max, sshUsed = 0, xrayUsed = 0) {
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = t("SSH {ssh} · Xray {xray}", {ssh: sshUsed, xray: xrayUsed});
|
||||
}
|
||||
|
||||
function updateCreditQuotaCard(balance, sshUsed = 0, xrayUsed = 0, childCount = 0) {
|
||||
if (!dashQuotaText) return;
|
||||
dashQuotaChip.textContent = `${balance} Cr`;
|
||||
dashQuotaChip.className = `chip ${balance <= 0 ? "red" : balance <= 5 ? "warn" : "green"}`;
|
||||
dashQuotaText.textContent = `${balance} créditos disponíveis`;
|
||||
dashQuotaBreakdown.textContent = `SSH ${sshUsed} · Xray ${xrayUsed} · ${childCount} sub-revendas`;
|
||||
dashQuotaBar.style.width = "100%";
|
||||
if (dashQuotaRemaining) {
|
||||
dashQuotaRemaining.textContent = String(balance);
|
||||
setQuotaTone(dashQuotaRemaining, balance <= 0 ? "quota-danger" : balance <= 5 ? "quota-warn" : "quota-good");
|
||||
}
|
||||
if (dashQuotaSummaryText) dashQuotaSummaryText.textContent = `${balance} créditos no saldo`;
|
||||
if (dashQuotaMiniBar) dashQuotaMiniBar.style.width = balance > 0 ? "100%" : "0%";
|
||||
if (xrayResellerQuotaUsed) xrayResellerQuotaUsed.textContent = `${balance} Cr`;
|
||||
if (xrayResellerQuotaRemaining) xrayResellerQuotaRemaining.textContent = String(balance);
|
||||
if (xrayResellerQuotaMix) xrayResellerQuotaMix.textContent = `SSH ${sshUsed} · Xray ${xrayUsed}`;
|
||||
}
|
||||
|
||||
function flattenXrayClients(inbounds = []) {
|
||||
return inbounds.flatMap(ib => (ib.clients || []).map(c => Object.assign({ inbound_tag: ib.tag }, c)));
|
||||
}
|
||||
@@ -264,7 +431,11 @@ function renderDashboardCounters() {
|
||||
|
||||
const me = dashboardCache.me;
|
||||
if (currentRole === "reseller" && me) {
|
||||
updateQuotaCard(me.used_users ?? total, me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
if ((me.quota_mode || "slots") === "credits") {
|
||||
updateCreditQuotaCard(me.credit_balance || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length, me.child_count || 0);
|
||||
} else {
|
||||
updateQuotaCard((me.used_users ?? total) + (me.child_allocation || 0), me.max_users || 0, me.used_ssh_users ?? sshUsers.length, me.used_xray_users ?? xrayClients.length);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,4 +456,3 @@ function refreshDashboard() {
|
||||
if (currentRole === "superadmin") loadStats();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
// ─── SSH Users ────────────────────────────────────────────────────────────────
|
||||
let editingSSHUser = "";
|
||||
document.getElementById("reloadUsersBtn").addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => {
|
||||
setFormCollapsed(false);
|
||||
document.getElementById("sshHeroRefreshBtn")?.addEventListener("click", loadUsers);
|
||||
newUserBtn.addEventListener("click", () => navigateWorkspaceSection("ssh", "create"));
|
||||
cancelUserBtn.addEventListener("click", () => {
|
||||
prepareNewSSHUser();
|
||||
setWorkspaceSection("ssh", "users");
|
||||
});
|
||||
function prepareNewSSHUser() {
|
||||
editingSSHUser = "";
|
||||
userForm.reset();
|
||||
fTotpPeriod.value = 60; fTotpWindow.value = 1; fTotpDigits.value = 6;
|
||||
fMaxConn.disabled = false;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.value = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Create user");
|
||||
if (title) title.textContent = t("Create SSH user");
|
||||
userStatus.textContent = t("New user.");
|
||||
fUsername.focus();
|
||||
});
|
||||
cancelUserBtn.addEventListener("click", () => setFormCollapsed(true));
|
||||
toggleFormBtn.addEventListener("click", () => setFormCollapsed(!formCollapsed));
|
||||
requestAnimationFrame(() => fUsername.focus());
|
||||
}
|
||||
document.getElementById("genTotpBtn").addEventListener("click", () => {
|
||||
fTotpSecret.value = genBase32();
|
||||
if (!fTotpPeriod.value) fTotpPeriod.value = 60;
|
||||
@@ -18,14 +31,13 @@ document.getElementById("genTotpBtn").addEventListener("click", () => {
|
||||
});
|
||||
document.getElementById("clearTotpBtn").addEventListener("click", () => { fTotpSecret.value = ""; });
|
||||
|
||||
function setFormCollapsed(v) {
|
||||
formCollapsed = v;
|
||||
userFormWrap.classList.toggle("collapsed", v);
|
||||
toggleFormBtn.textContent = v ? t("Show form") : t("Hide form");
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
userStatus.textContent = t("Loading…");
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("Loading SSH status…");
|
||||
sshLiveStatus.className = "workspace-live-status is-loading";
|
||||
}
|
||||
if (sshMetricState) sshMetricState.textContent = t("Loading");
|
||||
try {
|
||||
const res = await api(withServerParam("/api/users", selectedSSHServer()));
|
||||
const data = await res.json();
|
||||
@@ -33,6 +45,11 @@ async function loadUsers() {
|
||||
userStatus.textContent = t("Loaded.");
|
||||
lastReload.textContent = t("Last reload: {time}", {time: new Date().toLocaleTimeString()});
|
||||
} catch (e) {
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("Could not load SSH status");
|
||||
sshLiveStatus.className = "workspace-live-status is-error";
|
||||
}
|
||||
if (sshMetricState) sshMetricState.textContent = t("Error");
|
||||
if (e.message==="auth") { doAuthError(); } else { userStatus.textContent = t("Error loading users."); }
|
||||
}
|
||||
}
|
||||
@@ -76,6 +93,10 @@ function renderUsers(users) {
|
||||
tr.appendChild(td);
|
||||
});
|
||||
const tdA = document.createElement("td");
|
||||
const renewBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:"+30d",
|
||||
onclick: () => renewSSHUser(u),
|
||||
});
|
||||
const editBtn = Object.assign(document.createElement("button"), {
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillUserForm(u),
|
||||
@@ -85,16 +106,26 @@ function renderUsers(users) {
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteUser(u.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
tdA.className = "bot-row-actions";
|
||||
tdA.append(renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(tdA);
|
||||
usersBody.appendChild(tr);
|
||||
});
|
||||
const activeCount = Math.max(0, users.length - expiredCount);
|
||||
userCountChip.textContent = t("{count} total · {active} active · {online} online", {count: users.length, active: activeCount, online});
|
||||
if (sshMetricTotal) sshMetricTotal.textContent = String(users.length);
|
||||
if (sshMetricActive) sshMetricActive.textContent = String(activeCount);
|
||||
if (sshMetricOnline) sshMetricOnline.textContent = String(online);
|
||||
if (sshMetricState) sshMetricState.textContent = t("Online");
|
||||
if (sshLiveStatus) {
|
||||
sshLiveStatus.textContent = t("SSH data updated at {time}", {time:new Date().toLocaleTimeString()});
|
||||
sshLiveStatus.className = "workspace-live-status is-ok";
|
||||
}
|
||||
}
|
||||
|
||||
function fillUserForm(u) {
|
||||
setFormCollapsed(false);
|
||||
editingSSHUser = u.username || "";
|
||||
setWorkspaceSection("ssh", "create");
|
||||
fUsername.value = u.username || "";
|
||||
fPassword.value = "";
|
||||
fTotpSecret.value = u.totp_secret || "";
|
||||
@@ -103,9 +134,17 @@ function fillUserForm(u) {
|
||||
fTotpDigits.value = u.totp_digits || 6;
|
||||
fAllowStatic.checked = !!u.allow_static_password;
|
||||
fMaxConn.value = u.max_connections || "";
|
||||
const creditLocked = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
fMaxConn.disabled = creditLocked;
|
||||
fMaxConn.min = currentRole === "reseller" ? "1" : "0";
|
||||
fMaxConn.title = creditLocked ? "Em planos por crédito, altere o limite criando uma nova conta." : "";
|
||||
fUp.value = u.limit_mbps_up || "";
|
||||
fDown.value = u.limit_mbps_down || "";
|
||||
fExpires.value = u.expires_at ? localFromISO(u.expires_at) : "";
|
||||
const heading = document.getElementById("userFormHeading");
|
||||
const title = document.getElementById("userFormTitle");
|
||||
if (heading) heading.textContent = t("Edit user");
|
||||
if (title) title.textContent = t("Editing {name}", {name:u.username});
|
||||
userStatus.textContent = t("Editing {name}", {name: u.username});
|
||||
}
|
||||
|
||||
@@ -122,7 +161,9 @@ userForm.addEventListener("submit", async e => {
|
||||
totp_digits: parseInt(fTotpDigits.value||"6",10),
|
||||
allow_static_password: !!fAllowStatic.checked,
|
||||
max_connections: parseInt(fMaxConn.value||"0",10),
|
||||
expires_at: isoFromLocal(fExpires.value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits" && editingSSHUser
|
||||
? ""
|
||||
: isoFromLocal(fExpires.value),
|
||||
limit_mbps_up: parseInt(fUp.value||"0",10),
|
||||
limit_mbps_down: parseInt(fDown.value||"0",10),
|
||||
server_id: selectedSSHServer(),
|
||||
@@ -134,6 +175,8 @@ userForm.addEventListener("submit", async e => {
|
||||
fPassword.value = "";
|
||||
loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
showPanelToast(t("SSH user saved successfully."), "success", t("SSH / SlowDNS"));
|
||||
setWorkspaceSection("ssh", "users");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else userStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
@@ -143,7 +186,13 @@ userForm.addEventListener("submit", async e => {
|
||||
});
|
||||
|
||||
async function deleteUser(username) {
|
||||
if (!confirm(t("Delete user \"{name}\"?", {name: username}))) return;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete SSH account"),
|
||||
message:t("Delete user \"{name}\"?", {name: username}),
|
||||
detail:t("The active SSH sessions for this account will be disconnected."),
|
||||
confirmLabel:t("Delete account"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = t("Deleting {name}…", {name: username});
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/users/delete?username=${encodeURIComponent(username)}`, selectedSSHServer()), { method:"DELETE" });
|
||||
@@ -157,3 +206,28 @@ async function deleteUser(username) {
|
||||
}
|
||||
}
|
||||
|
||||
async function renewSSHUser(user) {
|
||||
const creditCost = Math.max(1, Number(user.max_connections || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar SSH", message:`Renovar “${user.username}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
userStatus.textContent = `Renovando ${user.username}…`;
|
||||
try {
|
||||
const res = await api("/api/users/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ username:user.username, days:30, server_id:selectedSSHServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${user.username} renovado.`, "success", "SSH / SlowDNS");
|
||||
await loadUsers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar SSH");
|
||||
}
|
||||
}
|
||||
|
||||
+193
-61
@@ -9,15 +9,28 @@ document.getElementById("xLoadInboundsBtn").addEventListener("click", () => load
|
||||
document.getElementById("xLoadCfgBtn").addEventListener("click", loadXrayCfg);
|
||||
document.getElementById("xSaveCfgBtn").addEventListener("click", saveXrayCfg);
|
||||
document.getElementById("xLoadLogsBtn").addEventListener("click", loadXrayLogs);
|
||||
document.getElementById("xrayOpenCreateBtn")?.addEventListener("click", () => navigateWorkspaceSection("xray", "create"));
|
||||
document.getElementById("xCreateCancelBtn")?.addEventListener("click", () => setWorkspaceSection("xray", "users"));
|
||||
document.getElementById("xCreateUUIDBtn")?.addEventListener("click", () => {
|
||||
const field = document.getElementById("xCreateUUID");
|
||||
if (field) field.value = genUUID();
|
||||
});
|
||||
document.getElementById("xCreateInbound")?.addEventListener("change", updateXrayCreatorInboundLabel);
|
||||
document.getElementById("xCreateClientForm")?.addEventListener("submit", submitXrayClientCreator);
|
||||
|
||||
|
||||
async function loadXrayStatus() {
|
||||
if (xrayChip) {
|
||||
xrayChip.textContent = t("Loading Xray status…");
|
||||
xrayChip.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api(withServerParam("/api/xray/status", selectedXrayServer()));
|
||||
if (!res.ok) throw new Error((await res.text()).trim() || `HTTP ${res.status}`);
|
||||
const s = await res.json();
|
||||
const run = !!s.running;
|
||||
xrayChip.textContent = run ? t("running") : (s.enabled ? t("stopped") : t("disabled"));
|
||||
xrayChip.className = "chip " + (run ? "green" : "red");
|
||||
xrayChip.className = "workspace-live-status " + (run ? "is-ok" : (s.enabled ? "is-warn" : "is-error"));
|
||||
xRunning.textContent = run ? t("Running") : t("Stopped");
|
||||
xRunning.style.color = run ? "var(--success)" : "var(--danger)";
|
||||
xPID.textContent = s.pid || (s.native ? "internal" : "--");
|
||||
@@ -43,7 +56,15 @@ async function loadXrayStatus() {
|
||||
if (dashServerStatus) dashServerStatus.textContent = (serversCache || []).length > 1 ? `${(serversCache || []).filter(n => n.is_active !== false).length} nodes configured` : (run ? t("{count} online", {count: 1}) : (s.enabled ? t("stopped") : t("disabled")));
|
||||
renderDashboardCounters();
|
||||
if (s.error) xStatus.textContent = t("Error: {error}", {error: s.error});
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
} catch (e) {
|
||||
if (xrayChip) {
|
||||
xrayChip.textContent = t("Could not load Xray status");
|
||||
xrayChip.className = "workspace-live-status is-error";
|
||||
}
|
||||
if (xRunning) { xRunning.textContent = t("Error"); xRunning.style.color = "var(--danger)"; }
|
||||
if (xStatus && e.message !== "auth") xStatus.textContent = t("Error: {error}", {error:e.message});
|
||||
if (e.message==="auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
async function saveXrayCoreMode() {
|
||||
@@ -98,9 +119,11 @@ async function xrayCtrl(action) {
|
||||
xStatus.textContent = currentLang === "pt-BR" ? "Xray OK." : "Xray "+action+" OK.";
|
||||
setTimeout(loadXrayStatus, 700);
|
||||
setTimeout(() => loadInbounds({ force: true }), 1200);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +168,7 @@ async function copyText(text) {
|
||||
function renderInbounds(inbounds, options = {}) {
|
||||
const { silent = false, force = false } = options || {};
|
||||
updateDashboardXray(inbounds);
|
||||
syncXrayCreatorInbounds(inbounds);
|
||||
const nextStructure = inboundStructure(inbounds);
|
||||
|
||||
if (silent && !force && nextStructure === lastInboundsStructure && patchRenderedInbounds(inbounds)) return;
|
||||
@@ -179,35 +203,15 @@ function renderInbounds(inbounds, options = {}) {
|
||||
${escapeHTML(ib.tag || "untagged")}
|
||||
<span class="hint">:${escapeHTML(ib.port ?? "?")}</span>
|
||||
<span class="chip ${onlineCount ? "green" : ""}" data-role="inbound-online-chip">${t("{count} online", {count: onlineCount})}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm" onclick="openAddClient('${ib.tag}')">${t("+ Add Client")}</button>`;
|
||||
</div>`;
|
||||
const openButton = document.createElement("button");
|
||||
openButton.className = "btn btn-sm";
|
||||
openButton.type = "button";
|
||||
openButton.textContent = t("Create user");
|
||||
openButton.addEventListener("click", () => openAddClient(ib.tag));
|
||||
hdr.appendChild(openButton);
|
||||
section.appendChild(hdr);
|
||||
|
||||
// Add client mini-form (hidden by default)
|
||||
const addForm = document.createElement("div");
|
||||
addForm.id = `add-form-${ib.tag}`;
|
||||
addForm.className = "hidden";
|
||||
addForm.style = "background:rgba(15,23,42,.9);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;";
|
||||
addForm.innerHTML = `
|
||||
<div class="form-grid" style="grid-template-columns:1fr 1fr;">
|
||||
<div class="field">
|
||||
<label>UUID</label>
|
||||
<div class="field-row">
|
||||
<input id="newUUID-${ib.tag}" placeholder="auto-generate" style="border-radius:6px;"/>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('newUUID-${ib.tag}').value=genUUID()">Gen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field"><label>${t("Email / label")}</label><input id="newEmail-${ib.tag}" placeholder="user@example" style="border-radius:6px;"/></div>
|
||||
<div class="field"><label>${t("Display Name")}</label><input id="newName-${ib.tag}" placeholder="e.g. Maykinho01" style="border-radius:6px;"/></div>
|
||||
<div class="field"><label>${t("Expiry Date")}</label><input type="datetime-local" id="newExpiry-${ib.tag}" style="border-radius:6px;color-scheme:dark;"/></div>
|
||||
<div class="field"><label>${t("Max Connections")} <span class="hint">${t("(0 = unlimited)")}</span></label><input type="number" min="0" id="newMaxConns-${ib.tag}" placeholder="0" style="border-radius:6px;"/></div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:6px;">
|
||||
<button class="btn btn-sm" onclick="addClient('${ib.tag}')">${t("Add")}</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="document.getElementById('add-form-${ib.tag}').classList.add('hidden')">${t("Cancel")}</button>
|
||||
</div>`;
|
||||
section.appendChild(addForm);
|
||||
|
||||
// Clients table
|
||||
const tblWrap = document.createElement("div");
|
||||
tblWrap.className = "tbl-wrap";
|
||||
@@ -240,12 +244,17 @@ function renderInbounds(inbounds, options = {}) {
|
||||
editBtn.style.marginLeft = "4px";
|
||||
editBtn.textContent = t("Edit");
|
||||
editBtn.onclick = () => openEditXrayClient(ib.tag, c);
|
||||
const renewBtn = document.createElement("button");
|
||||
renewBtn.className = "btn btn-ghost btn-sm";
|
||||
renewBtn.style.marginLeft = "4px";
|
||||
renewBtn.textContent = "+30d";
|
||||
renewBtn.onclick = () => renewXrayClient(c);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.style.marginLeft = "4px";
|
||||
delBtn.textContent = t("Del");
|
||||
delBtn.onclick = () => removeClient(ib.tag, c.id);
|
||||
actTd.append(copyBtn, editBtn, delBtn);
|
||||
actTd.append(copyBtn, renewBtn, editBtn, delBtn);
|
||||
tr.appendChild(actTd);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
@@ -262,32 +271,141 @@ function renderInbounds(inbounds, options = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function openAddClient(tag) {
|
||||
const form = document.getElementById(`add-form-${tag}`);
|
||||
if (form) { form.classList.remove("hidden"); }
|
||||
const uuidField = document.getElementById(`newUUID-${tag}`);
|
||||
if (uuidField && !uuidField.value) uuidField.value = genUUID();
|
||||
let xrayCreatorInbounds = [];
|
||||
let xrayCreatorInboundSignature = "";
|
||||
|
||||
function syncXrayCreatorInbounds(inbounds = []) {
|
||||
const select = document.getElementById("xCreateInbound");
|
||||
if (!select) return;
|
||||
const previous = select.value;
|
||||
const nextInbounds = (inbounds || []).filter(ib => ib?.tag).map(ib => ({
|
||||
tag: String(ib.tag),
|
||||
protocol: String(ib.protocol || "xray").toUpperCase(),
|
||||
port: ib.port ?? "?",
|
||||
}));
|
||||
const nextSignature = JSON.stringify(nextInbounds);
|
||||
xrayCreatorInbounds = nextInbounds;
|
||||
if (nextSignature === xrayCreatorInboundSignature) {
|
||||
updateXrayCreatorInboundLabel();
|
||||
return;
|
||||
}
|
||||
xrayCreatorInboundSignature = nextSignature;
|
||||
select.replaceChildren();
|
||||
if (!xrayCreatorInbounds.length) {
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = t("No compatible inbound found");
|
||||
select.appendChild(option);
|
||||
select.disabled = true;
|
||||
} else {
|
||||
xrayCreatorInbounds.forEach(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = inbound.tag;
|
||||
option.textContent = `${inbound.protocol} · ${inbound.tag} · :${inbound.port}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.disabled = false;
|
||||
select.value = xrayCreatorInbounds.some(inbound => inbound.tag === previous) ? previous : xrayCreatorInbounds[0].tag;
|
||||
}
|
||||
updateXrayCreatorInboundLabel();
|
||||
}
|
||||
|
||||
async function addClient(tag) {
|
||||
const uuidEl = document.getElementById(`newUUID-${tag}`);
|
||||
const emailEl = document.getElementById(`newEmail-${tag}`);
|
||||
const nameEl = document.getElementById(`newName-${tag}`);
|
||||
const expiryEl = document.getElementById(`newExpiry-${tag}`);
|
||||
const maxConnsEl = document.getElementById(`newMaxConns-${tag}`);
|
||||
const uuid = (uuidEl?.value || "").trim();
|
||||
const email = (emailEl?.value || "").trim();
|
||||
const name = (nameEl?.value || "").trim();
|
||||
const expiresAt = isoFromLocal(expiryEl?.value || "");
|
||||
const maxConns = parseInt(maxConnsEl?.value || "0", 10) || 0;
|
||||
if (!uuid) { xStatus.textContent = t("UUID required."); return; }
|
||||
function updateXrayCreatorInboundLabel() {
|
||||
const selected = document.getElementById("xCreateInbound")?.value || "";
|
||||
const inbound = xrayCreatorInbounds.find(item => item.tag === selected);
|
||||
const chip = document.getElementById("xCreateProtocolChip");
|
||||
const hint = document.getElementById("xCreateInboundHint");
|
||||
if (chip) chip.textContent = inbound ? inbound.protocol : t("No inbound");
|
||||
if (hint) hint.textContent = inbound
|
||||
? t("The client will be added to {tag} on port {port}.", {tag:inbound.tag, port:inbound.port})
|
||||
: t("Create or enable a compatible inbound before adding a client.");
|
||||
}
|
||||
|
||||
function prepareXrayClientCreator(preferredTag = "") {
|
||||
const form = document.getElementById("xCreateClientForm");
|
||||
form?.reset();
|
||||
const inbound = document.getElementById("xCreateInbound");
|
||||
if (inbound && preferredTag && xrayCreatorInbounds.some(item => item.tag === preferredTag)) inbound.value = preferredTag;
|
||||
const uuid = document.getElementById("xCreateUUID");
|
||||
if (uuid) uuid.value = genUUID();
|
||||
const maxConns = document.getElementById("xCreateMaxConns");
|
||||
if (maxConns) {
|
||||
maxConns.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxConns.value = currentRole === "reseller" ? "1" : "0";
|
||||
}
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
if (status) status.textContent = xrayCreatorInbounds.length ? t("Ready to create a new Xray client.") : t("Waiting for a compatible inbound.");
|
||||
updateXrayCreatorInboundLabel();
|
||||
requestAnimationFrame(() => document.getElementById("xCreateName")?.focus());
|
||||
}
|
||||
|
||||
function openAddClient(tag) {
|
||||
setWorkspaceSection("xray", "create");
|
||||
prepareXrayClientCreator(tag);
|
||||
}
|
||||
|
||||
async function submitXrayClientCreator(event) {
|
||||
event?.preventDefault?.();
|
||||
const tag = document.getElementById("xCreateInbound")?.value || "";
|
||||
const uuid = (document.getElementById("xCreateUUID")?.value || "").trim();
|
||||
const status = document.getElementById("xCreateClientStatus");
|
||||
const button = document.getElementById("xCreateClientBtn");
|
||||
if (!tag) { if (status) status.textContent = t("Select a compatible inbound."); return false; }
|
||||
if (!uuid) { if (status) status.textContent = t("UUID required."); return false; }
|
||||
const payload = {
|
||||
inbound_tag: tag,
|
||||
uuid,
|
||||
email: (document.getElementById("xCreateEmail")?.value || "").trim(),
|
||||
name: (document.getElementById("xCreateName")?.value || "").trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("xCreateExpiry")?.value || ""),
|
||||
max_connections: parseInt(document.getElementById("xCreateMaxConns")?.value || "0", 10) || 0,
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = t("Creating Xray client…");
|
||||
try {
|
||||
const res = await api("/api/xray/clients/add", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ inbound_tag: tag, uuid, email, name, expires_at: expiresAt, max_connections: maxConns, server_id: selectedXrayServer() }),
|
||||
});
|
||||
const res = await api("/api/xray/clients/add", { method:"POST", body:JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client {id}… added. Native mode hot-reloads without restart.", {id: uuid.slice(0,8)});
|
||||
const success = t("Client {id}… added. Native mode hot-reloads without restart.", {id:uuid.slice(0,8)});
|
||||
if (status) status.textContent = success;
|
||||
xStatus.textContent = success;
|
||||
showPanelToast(t("Xray user created successfully."), "success", t("Xray user"));
|
||||
setTimeout(() => { loadInbounds({ force:true }); if (currentRole === "reseller") loadMe(); }, 700);
|
||||
const name = document.getElementById("xCreateName");
|
||||
const email = document.getElementById("xCreateEmail");
|
||||
const expiry = document.getElementById("xCreateExpiry");
|
||||
if (name) name.value = "";
|
||||
if (email) email.value = "";
|
||||
if (expiry) expiry.value = "";
|
||||
const nextUUID = document.getElementById("xCreateUUID");
|
||||
if (nextUUID) nextUUID.value = genUUID();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
if (status) status.textContent = t("Error: {error}", {error:e.message});
|
||||
xStatus.textContent = t("Error: {error}", {error:e.message});
|
||||
showPanelToast(t("Could not create the Xray user: {error}", {error:e.message}), "error", t("Xray user"));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClient(tag, uuid) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove Xray client"),
|
||||
message:t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}),
|
||||
detail:t("The client will lose access immediately after the configuration reload."),
|
||||
confirmLabel:t("Remove client"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
|
||||
showPanelToast(t("Client removed successfully."), "success", t("Xray client"));
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
@@ -295,16 +413,31 @@ async function addClient(tag) {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClient(tag, uuid) {
|
||||
if (!confirm(t("Remove client {id}… from {tag}?", {id: uuid.slice(0,8), tag}))) return;
|
||||
async function renewXrayClient(client) {
|
||||
const creditCost = Math.max(1, Number(client.max_conns || 0));
|
||||
const creditDetail = currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? `Serão usados ${creditCost} crédito(s) e a conta receberá 31 dias.`
|
||||
: "A validade será estendida em 30 dias a partir da data atual ou da validade existente.";
|
||||
const accepted = await panelConfirm({
|
||||
icon:"+30", title:"Renovar Xray", message:`Renovar “${client.name || client.email || client.id.slice(0, 8)}”?`,
|
||||
detail:creditDetail, confirmLabel:"Renovar conta",
|
||||
});
|
||||
if (!accepted) return;
|
||||
xStatus.textContent = "Renovando cliente Xray…";
|
||||
try {
|
||||
const res = await api(withServerParam(`/api/xray/clients/remove?inbound_tag=${encodeURIComponent(tag)}&uuid=${encodeURIComponent(uuid)}`, selectedXrayServer()), { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
xStatus.textContent = t("Client removed. Native mode hot-reloads without restart.");
|
||||
setTimeout(() => { loadInbounds({ force: true }); if (currentRole === "reseller") loadMe(); }, 1500);
|
||||
const res = await api("/api/xray/clients/renew", {
|
||||
method:"POST",
|
||||
body:JSON.stringify({ uuid:client.id, days:30, server_id:selectedXrayServer() }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", "Renovar Xray");
|
||||
else showPanelToast("Cliente Xray renovado.", "success", "Xray");
|
||||
await loadInbounds({ force:true });
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else xStatus.textContent = t("Error: {error}", {error: e.message});
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", "Renovar Xray");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +482,3 @@ async function loadXrayLogs() {
|
||||
xLogsBox.scrollTop = xLogsBox.scrollHeight;
|
||||
} catch (e) { if (e.message==="auth") doAuthError(); }
|
||||
}
|
||||
|
||||
|
||||
+257
-73
@@ -1,113 +1,297 @@
|
||||
// ─── Resellers ────────────────────────────────────────────────────────────────
|
||||
document.getElementById("reloadResellersBtn").addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn").addEventListener("click", () => {
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
resellerForm.reset();
|
||||
rActive.checked = true;
|
||||
resellerStatus.textContent = "New reseller.";
|
||||
});
|
||||
document.getElementById("cancelResellerBtn").addEventListener("click", () => {
|
||||
resellerForm.reset();
|
||||
rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
});
|
||||
// ─── Hierarchical resellers ───────────────────────────────────────────────────
|
||||
let resellersCache = [];
|
||||
let editingReseller = "";
|
||||
|
||||
document.getElementById("reloadResellersBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("resellerHeroReloadBtn")?.addEventListener("click", loadResellers);
|
||||
document.getElementById("newResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
navigateWorkspaceSection("resellers", "create");
|
||||
});
|
||||
document.getElementById("cancelResellerBtn")?.addEventListener("click", () => {
|
||||
prepareNewReseller();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
});
|
||||
document.getElementById("reloadResellerAuditBtn")?.addEventListener("click", loadResellerAudit);
|
||||
document.querySelector("[data-tab='resellers']")?.addEventListener("click", loadResellers);
|
||||
document.querySelectorAll("[data-workspace='resellers'][data-workspace-section='audit']").forEach(el => {
|
||||
el.addEventListener("click", loadResellerAudit);
|
||||
});
|
||||
document.querySelector("[data-workspace-select='resellers']")?.addEventListener("change", e => {
|
||||
if (e.target.value === "audit") loadResellerAudit();
|
||||
});
|
||||
rQuotaMode?.addEventListener("change", toggleResellerPlanFields);
|
||||
|
||||
function toggleResellerPlanFields() {
|
||||
const credit = rQuotaMode.value === "credits";
|
||||
document.getElementById("rSlotsField")?.classList.toggle("hidden", credit);
|
||||
document.getElementById("rCreditsField")?.classList.toggle("hidden", !credit);
|
||||
document.getElementById("rExpiresField")?.classList.toggle("hidden", credit);
|
||||
if (credit) rExpires.value = "";
|
||||
}
|
||||
|
||||
function prepareNewReseller() {
|
||||
editingReseller = "";
|
||||
resellerFormTitle.textContent = t("Create Reseller");
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Create reseller");
|
||||
resellerForm.reset();
|
||||
rUsername.disabled = false;
|
||||
rParent.disabled = false;
|
||||
rQuotaMode.disabled = currentRole === "reseller";
|
||||
rQuotaMode.value = currentRole === "reseller" ? currentQuotaMode : "slots";
|
||||
rMaxUsers.min = currentRole === "reseller" ? "1" : "0";
|
||||
rMaxUsers.value = currentRole === "reseller" ? "1" : "30";
|
||||
rCredits.value = "1";
|
||||
rActive.checked = true;
|
||||
populateResellerParents();
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("New reseller.");
|
||||
requestAnimationFrame(() => rUsername.focus());
|
||||
}
|
||||
|
||||
async function loadResellers() {
|
||||
resellerStatus.textContent = "Loading…";
|
||||
resellerStatus.textContent = t("Loading…");
|
||||
setResellerLiveStatus("Carregando revendedores…", "is-loading");
|
||||
try {
|
||||
const res = await api("/api/resellers");
|
||||
const data = await res.json();
|
||||
renderResellers(data || []);
|
||||
resellerStatus.textContent = "Loaded.";
|
||||
const res = await api("/api/resellers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellersCache = await res.json() || [];
|
||||
renderResellers(resellersCache);
|
||||
populateResellerParents();
|
||||
resellerStatus.textContent = t("Loaded.");
|
||||
setResellerLiveStatus(`Atualizado às ${new Date().toLocaleTimeString()}`, "is-ok");
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error loading.";
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error loading.")} ${e.message || ""}`.trim();
|
||||
setResellerLiveStatus("Falha ao carregar revendedores", "is-error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setResellerLiveStatus(message, tone) {
|
||||
const el = document.getElementById("resellerLiveStatus");
|
||||
if (!el) return;
|
||||
el.textContent = message;
|
||||
el.className = `workspace-live-status ${tone || ""}`.trim();
|
||||
}
|
||||
|
||||
function renderResellerMetrics(list) {
|
||||
const active = list.filter(r => r.effective_active).length;
|
||||
const allocated = list.reduce((sum, r) => sum + (r.quota_mode === "slots" ? Number(r.max_users || 0) : 0), 0);
|
||||
const credits = list.reduce((sum, r) => sum + (r.quota_mode === "credits" ? Number(r.credit_balance || 0) : 0), 0);
|
||||
document.getElementById("resellerMetricTotal").textContent = String(list.length);
|
||||
document.getElementById("resellerMetricActive").textContent = String(active);
|
||||
document.getElementById("resellerMetricAllocated").textContent = String(allocated);
|
||||
document.getElementById("resellerMetricCredits").textContent = String(credits);
|
||||
}
|
||||
|
||||
function renderResellers(list) {
|
||||
resellerCountChip.textContent = list.length;
|
||||
renderResellerMetrics(list);
|
||||
resellersBody.innerHTML = "";
|
||||
if (!list.length) {
|
||||
resellersBody.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhum revendedor direto cadastrado.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
list.forEach(r => {
|
||||
const expired = r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const max = r.max_users || 0;
|
||||
const used = r.used_users || 0;
|
||||
const remaining = max ? Math.max(0, max - used) : "∞";
|
||||
const pct = max ? Math.min(100, Math.round((used / max) * 100)) : 0;
|
||||
const expired = !!r.expires_at && new Date(r.expires_at) < new Date();
|
||||
const effective = !!r.effective_active && !expired;
|
||||
const maxUsers = Number(r.max_users || 0);
|
||||
const directUsed = Number(r.used_users || 0);
|
||||
const childAllocation = Number(r.child_allocation || 0);
|
||||
const committed = directUsed + childAllocation;
|
||||
const remaining = maxUsers ? Math.max(0, maxUsers - committed) : "∞";
|
||||
const pct = maxUsers ? Math.min(100, Math.round((committed / maxUsers) * 100)) : 0;
|
||||
const isCredit = r.quota_mode === "credits";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `
|
||||
<td>${r.username}</td>
|
||||
<td>
|
||||
<strong>${used} / ${max || "∞"}</strong>
|
||||
<div class="hint">Disponível ${remaining} · SSH ${r.used_ssh_users || 0} · Xray ${r.used_xray_users || 0}</div>
|
||||
<div class="table-meter"><span style="width:${pct}%"></span></div>
|
||||
<div class="bot-primary-cell"><strong>${escapeHTML(r.username)}</strong>
|
||||
<small>${r.parent_username ? `pai: ${escapeHTML(r.parent_username)}` : "revenda principal"}${r.child_count ? ` · ${r.child_count} sub-revenda(s)` : ""}</small>
|
||||
${r.whatsapp ? `<small>${escapeHTML(r.whatsapp)}</small>` : ""}
|
||||
</div>
|
||||
</td>
|
||||
<td>${r.expires_at ? fmtDate(r.expires_at) : "—"}</td>
|
||||
<td><span class="${r.is_active && !expired ? 'badge-on' : 'badge-off'}">${r.is_active && !expired ? "Active" : expired ? "Expired" : "Suspended"}</span></td>
|
||||
<td>
|
||||
<strong>${isCredit ? `${r.credit_balance || 0} créditos` : `${committed} / ${maxUsers || "∞"}`}</strong>
|
||||
<div class="hint">${isCredit ? "31 dias por renovação" : `Disponível ${remaining} · capacidade usada ${directUsed} · reservado ${childAllocation}`} · SSH ${r.used_ssh_users || 0} contas · Xray ${r.used_xray_users || 0} contas</div>
|
||||
${isCredit ? "" : `<div class="table-meter"><span style="width:${pct}%"></span></div>`}
|
||||
</td>
|
||||
<td>${isCredit ? "Sem expiração" : r.expires_at ? escapeHTML(fmtDate(r.expires_at)) : "—"}</td>
|
||||
<td><span class="${effective ? "badge-on" : "badge-off"}">${effective ? "Ativo" : expired ? "Expirado" : r.is_active ? "Bloqueado pelo pai" : "Suspenso"}</span></td>
|
||||
<td></td>`;
|
||||
const tdA = tr.lastElementChild;
|
||||
const editBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-ghost btn-sm", textContent:t("Edit"),
|
||||
onclick: () => fillResellerForm(r),
|
||||
});
|
||||
const delBtn = Object.assign(document.createElement("button"),{
|
||||
className:"btn btn-danger btn-sm", textContent:t("Del"),
|
||||
style: "margin-left:4px;",
|
||||
onclick: () => deleteReseller(r.username),
|
||||
});
|
||||
tdA.append(editBtn, delBtn);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "bot-row-actions reseller-row-actions";
|
||||
actions.appendChild(resellerActionButton(t("Edit"), "btn btn-ghost btn-sm", () => fillResellerForm(r)));
|
||||
if (!isCredit) actions.appendChild(resellerActionButton("+30d", "btn btn-ghost btn-sm", () => runResellerAction(r, "renew")));
|
||||
if (currentRole === "superadmin" && r.parent_username) actions.appendChild(resellerActionButton("Puxar", "btn btn-ghost btn-sm", () => runResellerAction(r, "pull")));
|
||||
actions.appendChild(resellerActionButton(r.is_active ? "Suspender" : "Reativar", r.is_active ? "btn btn-warn btn-sm" : "btn btn-ghost btn-sm", () => runResellerAction(r, r.is_active ? "suspend" : "reactivate")));
|
||||
actions.appendChild(resellerActionButton(t("Del"), "btn btn-danger btn-sm", () => deleteReseller(r)));
|
||||
tr.lastElementChild.appendChild(actions);
|
||||
resellersBody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
function resellerActionButton(label, className, onclick) {
|
||||
return Object.assign(document.createElement("button"), { type: "button", className, textContent: label, onclick });
|
||||
}
|
||||
|
||||
function populateResellerParents() {
|
||||
if (!rParent) return;
|
||||
const selected = rParent.value;
|
||||
rParent.innerHTML = `<option value="">Principal / sem pai</option>`;
|
||||
resellersCache
|
||||
.filter(r => r.username !== editingReseller && r.effective_active)
|
||||
.forEach(r => {
|
||||
const option = document.createElement("option");
|
||||
option.value = r.username;
|
||||
option.textContent = `${r.username} · ${r.quota_mode === "credits" ? `${r.credit_balance || 0} Cr` : `${r.available < 0 ? "∞" : r.available} slots`}`;
|
||||
rParent.appendChild(option);
|
||||
});
|
||||
if ([...rParent.options].some(o => o.value === selected)) rParent.value = selected;
|
||||
}
|
||||
|
||||
function fillResellerForm(r) {
|
||||
resellerFormTitle.textContent = `Edit: ${r.username}`;
|
||||
rUsername.value = r.username;
|
||||
rPassword.value = "";
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rActive.checked = r.is_active;
|
||||
resellerStatus.textContent = `Editing ${r.username}.`;
|
||||
editingReseller = r.username;
|
||||
setWorkspaceSection("resellers", "create");
|
||||
resellerFormTitle.textContent = `${t("Edit")}: ${r.username}`;
|
||||
const heading = document.getElementById("resellerFormHeading");
|
||||
if (heading) heading.textContent = t("Edit reseller");
|
||||
rUsername.value = r.username;
|
||||
rUsername.disabled = true;
|
||||
rPassword.value = "";
|
||||
populateResellerParents();
|
||||
rParent.value = r.parent_username || "";
|
||||
rParent.disabled = true;
|
||||
rQuotaMode.value = r.quota_mode || "slots";
|
||||
rQuotaMode.disabled = true;
|
||||
rMaxUsers.value = r.max_users || 0;
|
||||
rCredits.value = r.credit_balance || 0;
|
||||
rExpires.value = r.expires_at ? localFromISO(r.expires_at) : "";
|
||||
rWhatsApp.value = r.whatsapp || "";
|
||||
rMonthlyPrice.value = ((r.monthly_price_cents || 0) / 100).toFixed(2);
|
||||
rActive.checked = !!r.is_active;
|
||||
toggleResellerPlanFields();
|
||||
resellerStatus.textContent = t("Editing {name}.", {name: r.username});
|
||||
}
|
||||
|
||||
resellerForm.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const btn = document.getElementById("saveResellerBtn");
|
||||
btn.disabled = true;
|
||||
resellerStatus.textContent = "Saving…";
|
||||
resellerStatus.textContent = t("Saving…");
|
||||
const mode = rQuotaMode.value || "slots";
|
||||
const payload = {
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
max_users: parseInt(rMaxUsers.value||"0",10),
|
||||
expires_at: isoFromLocal(rExpires.value),
|
||||
is_active: rActive.checked,
|
||||
username: rUsername.value.trim(),
|
||||
password: rPassword.value || undefined,
|
||||
parent_username: currentRole === "superadmin" ? rParent.value : undefined,
|
||||
quota_mode: mode,
|
||||
max_users: parseInt(rMaxUsers.value || "0", 10),
|
||||
credits: parseInt(rCredits.value || "0", 10),
|
||||
expires_at: mode === "slots" ? isoFromLocal(rExpires.value) : "",
|
||||
whatsapp: rWhatsApp.value.trim(),
|
||||
monthly_price_cents: Math.round(Math.max(0, parseFloat(rMonthlyPrice.value || "0")) * 100),
|
||||
is_active: rActive.checked,
|
||||
};
|
||||
try {
|
||||
const res = await api("/api/resellers/create", { method:"POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
resellerStatus.textContent = "Saved.";
|
||||
resellerForm.reset(); rActive.checked = true;
|
||||
resellerFormTitle.textContent = "Create Reseller";
|
||||
loadResellers();
|
||||
const res = await api("/api/resellers/create", { method: "POST", body: JSON.stringify(payload) });
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
showPanelToast(t("Reseller saved successfully."), "success", t("Resellers"));
|
||||
prepareNewReseller();
|
||||
await loadResellers();
|
||||
setWorkspaceSection("resellers", "users");
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error: "+e.message;
|
||||
} finally { btn.disabled = false; }
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
resellerStatus.textContent = `${t("Error")}: ${e.message}`;
|
||||
showPanelToast(e.message, "error", t("Resellers"));
|
||||
}
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function deleteReseller(username) {
|
||||
if (!confirm(`Delete reseller "${username}"? All their SSH sessions will be disconnected.`)) return;
|
||||
resellerStatus.textContent = `Deleting ${username}…`;
|
||||
async function runResellerAction(reseller, action) {
|
||||
const labels = { renew: "Renovar por 30 dias", suspend: "Suspender revendedor", reactivate: "Reativar revendedor", pull: "Puxar para o painel principal" };
|
||||
const descriptions = {
|
||||
renew: "A validade será estendida a partir da data atual ou da validade existente.",
|
||||
suspend: "A conta, seus descendentes e os acessos SSH/Xray ficarão bloqueados sem apagar os cadastros.",
|
||||
reactivate: "Os acessos preservados serão restaurados nos servidores disponíveis.",
|
||||
pull: "O revendedor deixará a revenda atual e passará a ser administrado diretamente pelo superadmin. Os créditos já transferidos não serão duplicados.",
|
||||
};
|
||||
const accepted = await panelConfirm({
|
||||
tone: action === "suspend" ? "danger" : "default",
|
||||
icon: action === "renew" ? "+30" : action === "suspend" ? "!" : action === "pull" ? "↥" : "✓",
|
||||
title: labels[action],
|
||||
message: `${labels[action]} “${reseller.username}”?`,
|
||||
detail: descriptions[action],
|
||||
confirmLabel: labels[action],
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = `${labels[action]}…`;
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(username)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error("failed");
|
||||
resellerStatus.textContent = "Deleted.";
|
||||
loadResellers();
|
||||
const res = await api("/api/resellers/action", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: reseller.username, action, days: action === "renew" ? 30 : undefined }),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()).trim());
|
||||
const data = await res.json();
|
||||
if (data.runtime_warning) showPanelToast(data.runtime_warning, "warning", labels[action]);
|
||||
else showPanelToast(`${reseller.username}: operação concluída.`, "success", labels[action]);
|
||||
await loadResellers();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else resellerStatus.textContent = "Error deleting.";
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message, "error", labels[action]);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteReseller(reseller) {
|
||||
const accepted = await panelConfirm({
|
||||
tone: "danger", icon: "×", title: t("Delete reseller"),
|
||||
message: t("Delete reseller \"{name}\"?", {name: reseller.username}),
|
||||
detail: `Serão removidos ${reseller.child_count || 0} sub-revendedores e todos os acessos SSH/Xray pertencentes à árvore. Esta ação não pode ser desfeita.`,
|
||||
confirmLabel: t("Delete reseller"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resellerStatus.textContent = t("Deleting {name}…", {name: reseller.username});
|
||||
try {
|
||||
const res = await api(`/api/resellers/delete?username=${encodeURIComponent(reseller.username)}`, { method: "DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error((await res.text()).trim());
|
||||
showPanelToast(`${reseller.username} removido.`, "success", t("Resellers"));
|
||||
await loadResellers();
|
||||
if (currentRole === "reseller") loadMe();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else showPanelToast(e.message || "Falha ao excluir.", "error", t("Delete reseller"));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResellerAudit() {
|
||||
const body = document.getElementById("resellerAuditBody");
|
||||
if (!body) return;
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Carregando atividade…</td></tr>`;
|
||||
try {
|
||||
const res = await api("/api/resellers/audit");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const rows = await res.json() || [];
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Nenhuma atividade registrada.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
rows.forEach(item => {
|
||||
const tr = document.createElement("tr");
|
||||
[fmtDate(item.created_at), item.actor_username, item.target_username, item.action, item.details || "—"].forEach(value => {
|
||||
const td = document.createElement("td");
|
||||
td.textContent = value;
|
||||
tr.appendChild(td);
|
||||
});
|
||||
body.appendChild(tr);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else body.innerHTML = `<tr class="bot-empty-row"><td colspan="5">Falha ao carregar a atividade.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,6 @@ document.getElementById("reloadServersBtn")?.addEventListener("click", loadServe
|
||||
document.getElementById("reloadServersBtn2")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersBtn")?.addEventListener("click", loadServers);
|
||||
document.getElementById("refreshServersStatusBtn")?.addEventListener("click", () => loadServersStatus());
|
||||
document.querySelector("[data-tab='servers']")?.addEventListener("click", loadServers);
|
||||
document.querySelector("[data-tab='servers-status']")?.addEventListener("click", () => loadServersStatus());
|
||||
document.getElementById("clearServerFormBtn")?.addEventListener("click", clearServerForm);
|
||||
document.getElementById("testServerBtn")?.addEventListener("click", testServerForm);
|
||||
document.getElementById("backToServersBtn")?.addEventListener("click", () => showServerListView());
|
||||
@@ -34,17 +32,39 @@ serverForm?.addEventListener("submit", async e => {
|
||||
});
|
||||
|
||||
async function loadServers() {
|
||||
let loadError = null;
|
||||
if (fleetLiveStatus) {
|
||||
fleetLiveStatus.textContent = t("Loading infrastructure…");
|
||||
fleetLiveStatus.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/servers");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
serversCache = await res.json() || [];
|
||||
} catch (e) {
|
||||
loadError = e;
|
||||
serversCache = [{ id:"local", name:"Master node", base_url:"local", enable_ssh:true, enable_xray:true, is_active:true, is_local:true }];
|
||||
if (serversStatus) serversStatus.textContent = "Error loading servers: " + e.message;
|
||||
if (e.message === "auth") doAuthError();
|
||||
}
|
||||
renderServerSelectors();
|
||||
renderServersTable();
|
||||
updateFleetOverview(loadError);
|
||||
}
|
||||
|
||||
function updateFleetOverview(error = null) {
|
||||
const rows = Array.isArray(serversCache) ? serversCache.filter(Boolean) : [];
|
||||
const active = rows.filter(server => server.is_active !== false);
|
||||
if (fleetMetricNodes) fleetMetricNodes.textContent = String(rows.length);
|
||||
if (fleetMetricActive) fleetMetricActive.textContent = String(active.length);
|
||||
if (fleetMetricSSH) fleetMetricSSH.textContent = String(active.filter(server => server.enable_ssh || server.is_local).length);
|
||||
if (fleetMetricXray) fleetMetricXray.textContent = String(active.filter(server => server.enable_xray || server.is_local).length);
|
||||
if (fleetLiveStatus) {
|
||||
fleetLiveStatus.textContent = error
|
||||
? t("Infrastructure loaded with fallback data")
|
||||
: t("{count} active nodes · updated {time}", {count:active.length, time:new Date().toLocaleTimeString()});
|
||||
fleetLiveStatus.className = `workspace-live-status ${error ? "is-warn" : "is-ok"}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +77,7 @@ async function loadServersStatus(options = {}) {
|
||||
if (serversStatusCountChip) serversStatusCountChip.textContent = String(nodes.length);
|
||||
if (!silent) {
|
||||
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Loading servers...");
|
||||
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-loading";
|
||||
serversStatusGrid.innerHTML = `<div class="hint">Loading servers...</div>`;
|
||||
}
|
||||
const rows = await Promise.all(nodes.map(loadSingleServerStatus));
|
||||
@@ -64,11 +85,23 @@ async function loadServersStatus(options = {}) {
|
||||
if (serversStatusPageStatus) {
|
||||
const online = rows.filter(r => r.ok).length;
|
||||
serversStatusPageStatus.textContent = `${online}/${rows.length} servers online - Updated ${new Date().toLocaleTimeString()}`;
|
||||
serversStatusPageStatus.className = `workspace-live-status ${online === rows.length ? "is-ok" : online > 0 ? "is-warn" : "is-error"}`;
|
||||
}
|
||||
const online = rows.filter(row => row.ok).length;
|
||||
const sessions = rows.reduce((total, row) => {
|
||||
const ssh = (Array.isArray(row.users) ? row.users : []).reduce((sum, user) => sum + Number(user.active_conns || 0), 0);
|
||||
let xray = 0;
|
||||
(Array.isArray(row.inbounds) ? row.inbounds : []).forEach(inbound => { xray += (inbound.clients || []).filter(client => !!client.online).length; });
|
||||
return total + ssh + xray;
|
||||
}, 0);
|
||||
if (fleetStatusOnline) fleetStatusOnline.textContent = String(online);
|
||||
if (fleetStatusOffline) fleetStatusOffline.textContent = String(Math.max(0, rows.length - online));
|
||||
if (fleetStatusSessions) fleetStatusSessions.textContent = String(sessions);
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else {
|
||||
serversStatusPageStatus && (serversStatusPageStatus.textContent = "Error loading server status: " + e.message);
|
||||
if (serversStatusPageStatus) serversStatusPageStatus.className = "workspace-live-status is-error";
|
||||
if (!silent) serversStatusGrid.innerHTML = `<div class="hint">Error loading server status.</div>`;
|
||||
}
|
||||
}
|
||||
@@ -321,7 +354,13 @@ async function testServerForm() {
|
||||
}
|
||||
|
||||
async function deleteServer(s) {
|
||||
if (!confirm(`Delete server "${s.name || s.base_url}"?`)) return;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Delete managed server"),
|
||||
message:t("Delete server \"{name}\"?", {name:s.name || s.base_url}),
|
||||
detail:t("The remote node is not erased, but it will be removed from this panel and can no longer receive managed actions."),
|
||||
confirmLabel:t("Delete server"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
const res = await api(`/api/servers?id=${encodeURIComponent(s.id)}`, { method:"DELETE" });
|
||||
if (!res.ok && res.status !== 204) throw new Error(await res.text());
|
||||
@@ -376,6 +415,7 @@ async function loadManagedServerConfig(id) {
|
||||
|
||||
document.getElementById("managedCfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
document.getElementById("managedCfgLimitDown").value = c.default_limit_mbps_down || 0;
|
||||
document.getElementById("managedCfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("managedCfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("managedCfgUserCount").checked = !!c.user_count;
|
||||
document.getElementById("managedCfgBanner").value = c.banner || "";
|
||||
@@ -444,6 +484,7 @@ function managedConfigFromForm() {
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("managedCfgLimitUp").value || "0", 10),
|
||||
default_limit_mbps_down: parseInt(document.getElementById("managedCfgLimitDown").value || "0", 10),
|
||||
ssh_idle_timeout: document.getElementById("managedCfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("managedCfgQuiet").checked,
|
||||
user_count: document.getElementById("managedCfgUserCount").checked,
|
||||
banner: document.getElementById("managedCfgBanner").value,
|
||||
@@ -663,5 +704,3 @@ async function loadManagedDnsttPubkey() {
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='stats']")?.addEventListener("click", loadStats);
|
||||
document.getElementById("refreshStatsBtn")?.addEventListener("click", loadStats);
|
||||
|
||||
async function loadDashboardStats() {
|
||||
try {
|
||||
@@ -91,7 +91,7 @@ async function loadDnsttHealth() {
|
||||
const a = rows[i];
|
||||
const b = rows[i + 1] || ["", ""];
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${a[0]}</td><td>${fmtInt(a[1])}</td><td>${b[0]}</td><td>${b[0] ? fmtInt(b[1]) : ""}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(a[0])}</td><td>${escapeHTML(fmtInt(a[1]))}</td><td>${escapeHTML(b[0])}</td><td>${b[0] ? escapeHTML(fmtInt(b[1])) : ""}</td>`;
|
||||
dnsttHealthBody.appendChild(tr);
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,10 @@ async function loadDnsttHealth() {
|
||||
|
||||
|
||||
async function loadStats() {
|
||||
if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Updating live status…");
|
||||
statsUpdated.className = "workspace-live-status is-loading";
|
||||
}
|
||||
try {
|
||||
const res = await api("/api/stats");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -135,28 +139,44 @@ async function loadStats() {
|
||||
totRx += Number(it.rx_bytes||0); totTx += Number(it.tx_bytes||0);
|
||||
if (!ifaceBody) return;
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${it.name}</td><td>${fmtMbps(it.rx_mbps)}</td><td>${fmtMbps(it.tx_mbps)}</td><td>${fmtBytes(it.rx_bytes)}</td><td>${fmtBytes(it.tx_bytes)}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(it.name)}</td><td>${escapeHTML(fmtMbps(it.rx_mbps))}</td><td>${escapeHTML(fmtMbps(it.tx_mbps))}</td><td>${escapeHTML(fmtBytes(it.rx_bytes))}</td><td>${escapeHTML(fmtBytes(it.tx_bytes))}</td>`;
|
||||
ifaceBody.appendChild(tr);
|
||||
});
|
||||
if (ifaceSummary) ifaceSummary.textContent = `Total: ${fmtBytes(totRx)} rx / ${fmtBytes(totTx)} tx`;
|
||||
if (statsUpdated) statsUpdated.textContent = "Updated: " + new Date().toLocaleTimeString();
|
||||
const currentNetwork = ifaces.reduce((sum, item) => sum + Number(item.rx_mbps || 0) + Number(item.tx_mbps || 0), 0);
|
||||
if (statsNetVal) statsNetVal.textContent = `${fmtMbps(currentNetwork)} Mb/s`;
|
||||
if (statsIfaceVal) statsIfaceVal.textContent = String(ifaces.length);
|
||||
if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Live · updated {time}", {time:new Date().toLocaleTimeString()});
|
||||
statsUpdated.className = "workspace-live-status is-ok";
|
||||
}
|
||||
await loadDnsttHealth();
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (statsUpdated) statsUpdated.textContent = "Erro ao carregar stats.";
|
||||
else if (statsUpdated) {
|
||||
statsUpdated.textContent = t("Error loading server status");
|
||||
statsUpdated.className = "workspace-live-status is-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetIfaceStatsBtn?.addEventListener("click", resetInterfaceStats);
|
||||
|
||||
async function resetInterfaceStats() {
|
||||
if (!confirm("Clean the live Interface totals now? This does not delete VnStat daily/monthly history.")) return;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"⇅", title:t("Clean live interface totals"),
|
||||
message:t("Clean the live Interface totals now?"),
|
||||
detail:t("VnStat daily and monthly history will be preserved."),
|
||||
confirmLabel:t("Clean totals"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resetIfaceStatsBtn.disabled = true;
|
||||
ifaceSummary.textContent = "Cleaning interface totals…";
|
||||
try {
|
||||
const res = await api("/api/stats/interfaces/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
ifaceSummary.textContent = "Interface totals cleaned. Auto-clean remains every 30 days.";
|
||||
showPanelToast(t("Live interface totals were cleaned."), "success", t("Traffic counters"));
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
@@ -167,7 +187,6 @@ async function resetInterfaceStats() {
|
||||
}
|
||||
|
||||
// ─── VnStat ───────────────────────────────────────────────────────────────────
|
||||
document.querySelector("[data-tab='vnstat']")?.addEventListener("click", loadVnstat);
|
||||
reloadVnstatBtn?.addEventListener("click", loadVnstat);
|
||||
resetVnstatBtn?.addEventListener("click", resetVnstatHistory);
|
||||
|
||||
@@ -175,19 +194,20 @@ function renderVnstatRows(body, rows, emptyLabel) {
|
||||
body.innerHTML = "";
|
||||
if (!rows.length) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${emptyLabel}</td>`;
|
||||
tr.innerHTML = `<td colspan="5" class="hint">${escapeHTML(emptyLabel)}</td>`;
|
||||
body.appendChild(tr);
|
||||
return;
|
||||
}
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML = `<td>${r.period || "--"}</td><td>${r.iface || "--"}</td><td>${fmtBytes(r.rx_bytes||0)}</td><td>${fmtBytes(r.tx_bytes||0)}</td><td>${fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0)))}</td>`;
|
||||
tr.innerHTML = `<td>${escapeHTML(r.period || "--")}</td><td>${escapeHTML(r.iface || "--")}</td><td>${escapeHTML(fmtBytes(r.rx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.tx_bytes||0))}</td><td>${escapeHTML(fmtBytes(r.total_bytes||((r.rx_bytes||0)+(r.tx_bytes||0))))}</td>`;
|
||||
body.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadVnstat() {
|
||||
vnstatStatus.textContent = "Loading VnStat usage…";
|
||||
vnstatStatus.className = "workspace-live-status is-loading";
|
||||
try {
|
||||
const res = await api("/api/vnstat?days=31&months=12");
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
@@ -208,21 +228,33 @@ async function loadVnstat() {
|
||||
vnTodayTotal.textContent = fmtBytes(todayTotal);
|
||||
vnMonthTotal.textContent = fmtBytes(monthTotal);
|
||||
vnIfaceCount.textContent = String(data.interface_count ?? ifaces.size ?? 0);
|
||||
if (vnLatestPeriod) vnLatestPeriod.textContent = daily[0]?.period || monthly[0]?.period || "--";
|
||||
vnstatStatus.textContent = "Updated: " + new Date().toLocaleTimeString() + " · history is kept until manually cleaned.";
|
||||
vnstatStatus.className = "workspace-live-status is-ok";
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
else vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
|
||||
else {
|
||||
vnstatStatus.textContent = "Error loading VnStat usage: " + e.message;
|
||||
vnstatStatus.className = "workspace-live-status is-error";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function resetVnstatHistory() {
|
||||
if (!confirm("Clean all VnStat daily/monthly usage history? This does not reset the live Interface totals.")) return;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Clean VnStat history"),
|
||||
message:t("Clean all daily and monthly traffic history?"),
|
||||
detail:t("Live interface totals are separate and will not be reset."),
|
||||
confirmLabel:t("Clean history"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
resetVnstatBtn.disabled = true;
|
||||
vnstatStatus.textContent = "Cleaning VnStat history…";
|
||||
try {
|
||||
const res = await api("/api/vnstat/reset", { method:"POST" });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
vnstatStatus.textContent = "VnStat history cleaned.";
|
||||
showPanelToast(t("VnStat history was cleaned."), "success", t("Traffic history"));
|
||||
loadVnstat();
|
||||
} catch (e) {
|
||||
if (e.message === "auth") doAuthError();
|
||||
@@ -260,7 +292,13 @@ async function loadSystemLogs() {
|
||||
|
||||
async function clearPanelLog() {
|
||||
const st = document.getElementById("systemLogStatus");
|
||||
if (!confirm("Clean the panel log now? Logs are already auto-cleaned after 1 MiB.")) return;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Clean panel log"),
|
||||
message:t("Clean the current panel log now?"),
|
||||
detail:t("This only clears the panel log file. Automatic size-based cleanup remains enabled."),
|
||||
confirmLabel:t("Clean log"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
st.textContent = "Cleaning panel log…";
|
||||
try {
|
||||
const res = await api("/api/system/logs/reset", { method:"POST" });
|
||||
@@ -273,4 +311,3 @@ async function clearPanelLog() {
|
||||
else st.textContent = "Error cleaning panel log: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,8 @@ async function loadServerConfig() {
|
||||
// SSH / general
|
||||
document.getElementById("cfgLimitUp").value = c.default_limit_mbps_up || 0;
|
||||
document.getElementById("cfgLimitDown").value = c.default_limit_mbps_down || 0;
|
||||
document.getElementById("cfgMaxTotalConns").value = c.max_total_connections || 0;
|
||||
document.getElementById("cfgSSHIdleTimeout").value = c.ssh_idle_timeout || "0s";
|
||||
document.getElementById("cfgQuiet").checked = !!c.quiet;
|
||||
document.getElementById("cfgUserCount").checked = !!c.user_count;
|
||||
|
||||
@@ -133,6 +135,7 @@ async function loadServerConfig() {
|
||||
const u = c.udpgw || {};
|
||||
document.getElementById("cfgUdpgwListen").value = u.listen || "";
|
||||
document.getElementById("cfgUdpgwMaxConns").value = u.max_client_conns || 0;
|
||||
document.getElementById("cfgUdpgwMaxClients").value = u.max_clients || 0;
|
||||
document.getElementById("cfgUdpgwIdle").value = u.idle_timeout || "";
|
||||
document.getElementById("cfgUdpgwMapTTL").value = u.map_ttl || "";
|
||||
document.getElementById("cfgUdpgwAutoRestart").value = u.auto_restart_interval || "";
|
||||
@@ -175,6 +178,8 @@ async function saveServerConfig() {
|
||||
admin_dir: "/opt/sshpanel/admin",
|
||||
default_limit_mbps_up: parseInt(document.getElementById("cfgLimitUp").value || "0", 10),
|
||||
default_limit_mbps_down: parseInt(document.getElementById("cfgLimitDown").value || "0", 10),
|
||||
max_total_connections: parseInt(document.getElementById("cfgMaxTotalConns").value || "0", 10),
|
||||
ssh_idle_timeout: document.getElementById("cfgSSHIdleTimeout").value.trim() || "0s",
|
||||
quiet: document.getElementById("cfgQuiet").checked,
|
||||
user_count: document.getElementById("cfgUserCount").checked,
|
||||
banner: document.getElementById("cfgBanner").value,
|
||||
@@ -204,6 +209,7 @@ async function saveServerConfig() {
|
||||
udpgw: document.getElementById("cfgUdpgwEnabled").checked ? {
|
||||
listen: document.getElementById("cfgUdpgwListen").value.trim(),
|
||||
max_client_conns: parseInt(document.getElementById("cfgUdpgwMaxConns").value || "0", 10),
|
||||
max_clients: parseInt(document.getElementById("cfgUdpgwMaxClients").value || "0", 10),
|
||||
idle_timeout: document.getElementById("cfgUdpgwIdle").value.trim(),
|
||||
map_ttl: document.getElementById("cfgUdpgwMapTTL").value.trim(),
|
||||
auto_restart_interval: document.getElementById("cfgUdpgwAutoRestart").value.trim(),
|
||||
@@ -257,8 +263,8 @@ function renderTLSForwarders() {
|
||||
tlsForwardersState.forEach((fw, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${fw.listen}</span>
|
||||
<span class="hint">${fw.cert_file ? fw.cert_file.split("/").pop() : "no cert"}</span>`;
|
||||
row.innerHTML = `<span style="flex:1;font-family:monospace;">${escapeHTML(fw.listen || "")}</span>
|
||||
<span class="hint">${escapeHTML(fw.cert_file ? fw.cert_file.split("/").pop() : "no cert")}</span>`;
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
@@ -440,4 +446,3 @@ async function loadDnsttPubkey() {
|
||||
else st.textContent = "Error: " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,11 @@ function openEditXrayClient(tag, client) {
|
||||
document.getElementById("editXrayName").value = client.name || "";
|
||||
document.getElementById("editXrayEmail").value = client.email || "";
|
||||
document.getElementById("editXrayExpiry").value = client.expires_at ? localFromISO(client.expires_at) : "";
|
||||
document.getElementById("editXrayMaxConns").value = client.max_conns || 0;
|
||||
const maxInput = document.getElementById("editXrayMaxConns");
|
||||
maxInput.value = client.max_conns || 0;
|
||||
maxInput.min = currentRole === "reseller" ? "1" : "0";
|
||||
maxInput.disabled = currentRole === "reseller" && currentQuotaMode === "credits";
|
||||
maxInput.title = maxInput.disabled ? "Em planos por crédito, o limite de conexões fica fixo." : "";
|
||||
document.getElementById("editXrayClientStatus").textContent = "";
|
||||
document.getElementById("editXrayClientPanel").classList.remove("hidden");
|
||||
document.getElementById("editXrayClientPanel").scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
@@ -24,7 +28,9 @@ async function saveEditXrayClient() {
|
||||
uuid: editingXrayClientId,
|
||||
name: document.getElementById("editXrayName").value.trim(),
|
||||
email: document.getElementById("editXrayEmail").value.trim(),
|
||||
expires_at: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
expires_at: currentRole === "reseller" && currentQuotaMode === "credits"
|
||||
? ""
|
||||
: isoFromLocal(document.getElementById("editXrayExpiry").value),
|
||||
max_connections: parseInt(document.getElementById("editXrayMaxConns").value || "0", 10),
|
||||
server_id: selectedXrayServer(),
|
||||
};
|
||||
@@ -82,7 +88,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedFullConfig = cloneJsonSafe(cfg);
|
||||
document.getElementById("wzLogLevel").value = cfg.log?.loglevel || "warning";
|
||||
wzInbounds = cloneJsonSafe((cfg.inbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Config loaded from ${target}.`;
|
||||
}).catch(e => {
|
||||
@@ -90,7 +98,9 @@ function loadWizardFromConfig() {
|
||||
wzLoadedConfigText = "";
|
||||
wzLoadedFullConfig = null;
|
||||
wzInbounds = [];
|
||||
wzEditingIndex = -1;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
if (e.message === "auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
});
|
||||
@@ -99,67 +109,597 @@ function loadWizardFromConfig() {
|
||||
function renderWzInbounds() {
|
||||
const list = document.getElementById("wzInboundsList");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!wzInbounds.length) {
|
||||
list.innerHTML = '<div class="hint" style="padding:4px 0;">No inbounds. Click + Add to create one.</div>';
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "hint visual-empty-state";
|
||||
empty.textContent = "Nenhum inbound configurado. Crie um endpoint compartilhado ou adicione um inbound.";
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
list.innerHTML = "";
|
||||
wzInbounds.forEach((ib, i) => {
|
||||
const row = document.createElement("div");
|
||||
row.style = "display:flex;align-items:center;gap:8px;padding:5px 0;border-bottom:1px solid var(--border);font-size:.73rem;";
|
||||
const row = document.createElement("article");
|
||||
row.className = "visual-inbound-card";
|
||||
const portStr = ib.port !== undefined ? `:${ib.port}` : "";
|
||||
const ss = ib.streamSettings || {};
|
||||
const net = ss.network || "";
|
||||
const sec = ss.security || "";
|
||||
const secLabel = sec === "tls" ? " TLS" : sec === "reality" ? " Reality" : "";
|
||||
const modeLabel = net === "xhttp" && ss.xhttpSettings?.mode ? " ("+ss.xhttpSettings.mode+")" : "";
|
||||
row.innerHTML = `<span class="chip">${ib.protocol}</span>
|
||||
<span style="font-family:monospace;">${ib.tag||"untagged"}${portStr}</span>
|
||||
<span class="hint" style="flex:1;">${ib.listen||"0.0.0.0"}${net?" · "+net:""}${modeLabel}${secLabel}</span>`;
|
||||
const transportSettings = ss.xhttpSettings || ss.splithttpSettings || ss.wsSettings || ss.httpupgradeSettings || ss.httpSettings || ss.grpcSettings || {};
|
||||
const head = document.createElement("div");
|
||||
head.className = "visual-inbound-card-head";
|
||||
const name = document.createElement("div");
|
||||
name.className = "visual-inbound-name";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = ib.tag || "untagged";
|
||||
const address = document.createElement("small");
|
||||
address.textContent = `${ib.listen || "0.0.0.0"}${portStr}`;
|
||||
name.append(title, address);
|
||||
const protocol = document.createElement("span");
|
||||
protocol.className = "chip";
|
||||
protocol.textContent = String(ib.protocol || "unknown").toUpperCase();
|
||||
head.append(name, protocol);
|
||||
row.appendChild(head);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "visual-inbound-meta";
|
||||
[net || "default", transportSettings.path || transportSettings.serviceName || "no path", sec || "no TLS"].forEach(value => {
|
||||
const item = document.createElement("span");
|
||||
item.textContent = value;
|
||||
meta.appendChild(item);
|
||||
});
|
||||
row.appendChild(meta);
|
||||
const clients = ib.settings?.clients;
|
||||
if (Array.isArray(clients) && clients.length) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "chip green";
|
||||
badge.textContent = clients.length + " client" + (clients.length!==1?"s":"");
|
||||
row.appendChild(badge);
|
||||
meta.appendChild(badge);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "visual-inbound-actions";
|
||||
const xhttp = visualXHTTPSettings(ib);
|
||||
const proxyProtocol = String(ib.protocol || "").toLowerCase();
|
||||
if (xhttp && ["vless", "vmess"].includes(proxyProtocol)) {
|
||||
const sshRoute = findSSHRouteForInbound(ib);
|
||||
const sshBtn = document.createElement("button");
|
||||
sshBtn.className = sshRoute ? "btn btn-soft btn-sm legacy-ssh-btn is-enabled" : "btn btn-sm legacy-ssh-btn";
|
||||
sshBtn.type = "button";
|
||||
sshBtn.textContent = t(sshRoute ? "SSH /ssh enabled" : "Enable SSH /ssh");
|
||||
sshBtn.disabled = !!sshRoute;
|
||||
sshBtn.title = sshRoute ? t("This endpoint already has an SSH /ssh route.") : t("Add SSH /ssh without rebuilding this inbound.");
|
||||
sshBtn.onclick = () => enableSSHForWzInbound(i, true, sshBtn);
|
||||
actions.appendChild(sshBtn);
|
||||
}
|
||||
const duplicateBtn = document.createElement("button");
|
||||
duplicateBtn.className = "btn btn-ghost btn-sm";
|
||||
duplicateBtn.type = "button";
|
||||
duplicateBtn.textContent = "Duplicar";
|
||||
duplicateBtn.onclick = () => duplicateWzInbound(i);
|
||||
const editBtn = document.createElement("button");
|
||||
editBtn.className = "btn btn-ghost btn-sm";
|
||||
editBtn.type = "button";
|
||||
editBtn.textContent = "Editar";
|
||||
editBtn.onclick = () => editWzInbound(i);
|
||||
const delBtn = document.createElement("button");
|
||||
delBtn.className = "btn btn-danger btn-sm";
|
||||
delBtn.textContent = "Remove";
|
||||
delBtn.onclick = () => { wzInbounds.splice(i,1); wzDirty = true; renderWzInbounds(); };
|
||||
row.appendChild(delBtn);
|
||||
delBtn.type = "button";
|
||||
delBtn.textContent = "Remover";
|
||||
delBtn.onclick = async () => {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"×", title:t("Remove inbound"),
|
||||
message:t("Remove inbound {name}?", {name:ib.tag || "untagged"}),
|
||||
detail:t("Clients attached only to this inbound will stop connecting after the configuration is saved."),
|
||||
confirmLabel:t("Remove inbound"),
|
||||
});
|
||||
if (!accepted) return;
|
||||
wzInbounds.splice(i,1);
|
||||
if (wzEditingIndex === i) wzCancelInbound();
|
||||
else if (wzEditingIndex > i) wzEditingIndex--;
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
};
|
||||
actions.append(duplicateBtn, editBtn, delBtn);
|
||||
row.appendChild(actions);
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function wzToggleAddInbound() {
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.toggle("hidden");
|
||||
if (!form.classList.contains("hidden")) {
|
||||
onWzProtoChange(document.getElementById("wzProtocol").value);
|
||||
onWzNetworkChange(document.getElementById("wzNetwork").value);
|
||||
onWzTLSChange(document.getElementById("wzTLS").value);
|
||||
if (!form.classList.contains("hidden") && wzEditingIndex < 0) return wzCancelInbound();
|
||||
resetWzInboundForm();
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"nearest" });
|
||||
}
|
||||
|
||||
function setWzValue(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = value ?? "";
|
||||
}
|
||||
|
||||
function resetWzInboundForm() {
|
||||
wzEditingIndex = -1;
|
||||
setWzValue("wzProtocol", "vless");
|
||||
setWzValue("wzPort", "10086");
|
||||
setWzValue("wzListenIP", "0.0.0.0");
|
||||
setWzValue("wzTag", "vless-in");
|
||||
setWzValue("wzNetwork", "tcp");
|
||||
setWzValue("wzWSPath", "/ws");
|
||||
setWzValue("wzXHTTPPath", "/xhttp");
|
||||
setWzValue("wzXHTTPHost", "");
|
||||
setWzValue("wzXHTTPMode", "auto");
|
||||
setWzValue("wzHUPath", "/upgrade");
|
||||
setWzValue("wzHUHost", "");
|
||||
setWzValue("wzH2Path", "/h2");
|
||||
setWzValue("wzH2Host", "");
|
||||
setWzValue("wzGRPCService", "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = false;
|
||||
setWzValue("wzTLS", "none");
|
||||
["wzTLSCert", "wzTLSKey", "wzTLSCertPath", "wzTLSKeyPath", "wzRealityDest", "wzRealitySNI", "wzRealityPriv", "wzRealityShortID", "wzTrojanPass", "wzSSPass"].forEach(id => setWzValue(id, ""));
|
||||
setWzValue("wzSSMethod", "chacha20-ietf-poly1305");
|
||||
document.getElementById("wzInboundFormTitle").textContent = "Novo inbound";
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Adicionar inbound";
|
||||
document.getElementById("wzEditingBadge").classList.add("hidden");
|
||||
onWzProtoChange("vless");
|
||||
onWzNetworkChange("tcp");
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
|
||||
function wzCancelInbound() {
|
||||
wzEditingIndex = -1;
|
||||
document.getElementById("wzAddInboundForm")?.classList.add("hidden");
|
||||
document.getElementById("wzEditingBadge")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function editWzInbound(index) {
|
||||
const ib = wzInbounds[index];
|
||||
if (!ib) return;
|
||||
resetWzInboundForm();
|
||||
wzEditingIndex = index;
|
||||
const proto = String(ib.protocol || "vless").toLowerCase();
|
||||
setWzValue("wzProtocol", proto);
|
||||
onWzProtoChange(proto);
|
||||
setWzValue("wzPort", ib.port ?? "");
|
||||
setWzValue("wzListenIP", ib.listen || (proto === "socks" ? "127.0.0.1" : "0.0.0.0"));
|
||||
setWzValue("wzTag", ib.tag || `${proto}-in`);
|
||||
|
||||
const ss = ib.streamSettings || {};
|
||||
const network = proto === "ssh" ? "xhttp" : (ss.network || "tcp");
|
||||
setWzValue("wzNetwork", network);
|
||||
onWzNetworkChange(network);
|
||||
const xh = ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
setWzValue("wzWSPath", ss.wsSettings?.path || "/ws");
|
||||
setWzValue("wzXHTTPPath", xh.path || "/xhttp");
|
||||
setWzValue("wzXHTTPHost", xh.host || "");
|
||||
setWzValue("wzXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("wzHUPath", ss.httpupgradeSettings?.path || "/upgrade");
|
||||
setWzValue("wzHUHost", ss.httpupgradeSettings?.host || "");
|
||||
setWzValue("wzH2Path", ss.httpSettings?.path || "/h2");
|
||||
setWzValue("wzH2Host", Array.isArray(ss.httpSettings?.host) ? (ss.httpSettings.host[0] || "") : (ss.httpSettings?.host || ""));
|
||||
setWzValue("wzGRPCService", ss.grpcSettings?.serviceName || "grpc-service");
|
||||
document.getElementById("wzGRPCMulti").checked = !!ss.grpcSettings?.multiMode;
|
||||
|
||||
const security = ss.security === "tls" ? "tls" : (ss.security === "reality" ? "reality" : "none");
|
||||
setWzValue("wzTLS", security);
|
||||
onWzTLSChange(security);
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("wzTLSCert", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKey", cert.keyFile || "");
|
||||
setWzValue("wzTLSCertPath", cert.certificateFile || "");
|
||||
setWzValue("wzTLSKeyPath", cert.keyFile || "");
|
||||
setWzValue("wzRealityDest", ss.realitySettings?.dest || "");
|
||||
setWzValue("wzRealitySNI", ss.realitySettings?.serverNames?.[0] || "");
|
||||
setWzValue("wzRealityPriv", ss.realitySettings?.privateKey || "");
|
||||
setWzValue("wzRealityShortID", ss.realitySettings?.shortIds?.[0] || "");
|
||||
setWzValue("wzTrojanPass", ib.settings?.clients?.[0]?.password || "");
|
||||
setWzValue("wzSSPass", ib.settings?.password || "");
|
||||
setWzValue("wzSSMethod", ib.settings?.method || "chacha20-ietf-poly1305");
|
||||
|
||||
document.getElementById("wzInboundFormTitle").textContent = `Editar ${ib.tag || "inbound"}`;
|
||||
document.getElementById("wzSaveInboundBtn").textContent = "Salvar alterações";
|
||||
document.getElementById("wzEditingBadge").classList.remove("hidden");
|
||||
const form = document.getElementById("wzAddInboundForm");
|
||||
form.classList.remove("hidden");
|
||||
form.scrollIntoView({ behavior:"smooth", block:"start" });
|
||||
}
|
||||
|
||||
function duplicateWzInbound(index) {
|
||||
const source = wzInbounds[index];
|
||||
if (!source) return;
|
||||
const copy = cloneJsonSafe(source);
|
||||
const tags = new Set(wzInbounds.map(ib => ib?.tag));
|
||||
let n = 2;
|
||||
let tag = `${source.tag || source.protocol || "inbound"}-copy`;
|
||||
while (tags.has(tag)) tag = `${source.tag || source.protocol || "inbound"}-copy-${n++}`;
|
||||
copy.tag = tag;
|
||||
const port = Number(copy.port || 0);
|
||||
if (port > 0 && port < 65535) copy.port = port + 1;
|
||||
wzInbounds.push(copy);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
editWzInbound(wzInbounds.length - 1);
|
||||
}
|
||||
|
||||
function normalizeVisualPath(value) {
|
||||
let path = String(value || "/").trim().split("?", 1)[0];
|
||||
if (!path.startsWith("/")) path = `/${path}`;
|
||||
path = path.replace(/\/+$/, "") || "/";
|
||||
return path;
|
||||
}
|
||||
|
||||
function visualXHTTPSettings(ib) {
|
||||
const ss = ib?.streamSettings || {};
|
||||
if (!["xhttp", "splithttp"].includes(String(ss.network || "").toLowerCase())) return null;
|
||||
return ss.xhttpSettings || ss.splithttpSettings || {};
|
||||
}
|
||||
|
||||
function sameVisualEndpoint(a, b) {
|
||||
return String(a?.listen || "0.0.0.0") === String(b?.listen || "0.0.0.0") && String(a?.port) === String(b?.port);
|
||||
}
|
||||
|
||||
function findSSHRouteForInbound(source) {
|
||||
return wzInbounds.find(candidate => {
|
||||
const xh = visualXHTTPSettings(candidate);
|
||||
return sameVisualEndpoint(source, candidate) && !!xh && String(candidate?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function uniqueLegacySSHTag(source) {
|
||||
const tags = new Set(wzInbounds.map(ib => String(ib?.tag || "")));
|
||||
const sourceTag = String(source?.tag || source?.port || "xhttp").toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 48) || "xhttp";
|
||||
let tag = `ssh-${sourceTag}`;
|
||||
let suffix = 2;
|
||||
while (tags.has(tag)) tag = `ssh-${sourceTag}-${suffix++}`;
|
||||
return tag;
|
||||
}
|
||||
|
||||
// Adds SSH to a legacy VLESS/VMess XHTTP listener without rebuilding or
|
||||
// modifying the original inbound. The companion inherits its listen address,
|
||||
// port, host, XHTTP mode, advanced transport options, TLS certificate, and key;
|
||||
// only its protocol, empty SSH settings, tag, and /ssh path differ.
|
||||
function reportSSHMigration(message, tone = "error") {
|
||||
const st = document.getElementById("wzStatus");
|
||||
if (st) st.textContent = message;
|
||||
if (typeof showPanelToast === "function") {
|
||||
showPanelToast(message, tone, tone === "success" ? t("SSH /ssh enabled") : tone === "warning" ? t("SSH migration attention") : t("Could not enable SSH /ssh"));
|
||||
}
|
||||
}
|
||||
|
||||
async function enableSSHForWzInbound(index, applyNow = true, trigger = null) {
|
||||
const st = document.getElementById("wzStatus");
|
||||
const source = wzInbounds[index];
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!source || !visualXHTTPSettings(source) || !["vless", "vmess"].includes(String(source.protocol || "").toLowerCase())) {
|
||||
reportSSHMigration(t("Select a VLESS/VMess inbound using XHTTP."));
|
||||
return false;
|
||||
}
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
reportSSHMigration(t("Load the selected server configuration before enabling SSH."));
|
||||
return false;
|
||||
}
|
||||
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
|
||||
reportSSHMigration(t("Shared SSH requires native Xray mode. Select Internal native emulator and save the mode first."));
|
||||
return false;
|
||||
}
|
||||
const security = String(source.streamSettings?.security || "").toLowerCase();
|
||||
if (security && security !== "none" && security !== "tls") {
|
||||
reportSSHMigration(t("Inbound {name} uses {security}, which native SSH XHTTP does not support. Use TLS or no TLS.", {name:source.tag || t("selected"), security}));
|
||||
return false;
|
||||
}
|
||||
const existingSSH = findSSHRouteForInbound(source);
|
||||
if (existingSSH) {
|
||||
reportSSHMigration(t("SSH is already enabled on /ssh by inbound {name}.", {name:existingSSH.tag}), "success");
|
||||
return true;
|
||||
}
|
||||
const pathConflict = wzInbounds.find(candidate => {
|
||||
const xh = visualXHTTPSettings(candidate);
|
||||
return sameVisualEndpoint(source, candidate) && !!xh && normalizeVisualPath(xh.path) === "/ssh";
|
||||
});
|
||||
if (pathConflict) {
|
||||
reportSSHMigration(t("Path /ssh is already used by inbound {name}. Edit that path first.", {name:pathConflict.tag || t("untagged")}));
|
||||
return false;
|
||||
}
|
||||
if (security === "tls") {
|
||||
const certificate = source.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
if (!certificate.certificateFile || !certificate.keyFile) {
|
||||
reportSSHMigration(t("This inbound uses TLS but has no reusable certificate and key file paths."));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (applyNow) {
|
||||
const xhttp = visualXHTTPSettings(source) || {};
|
||||
const clients = Array.isArray(source.settings?.clients) ? source.settings.clients.length : 0;
|
||||
const accepted = await panelConfirm({
|
||||
tone:"success", icon:"SSH", eyebrow:t("Safe XHTTP migration"), title:t("Enable SSH on /ssh"),
|
||||
message:t("Add SSH to the same endpoint without rebuilding {name}?", {name:source.tag || t("this inbound")}),
|
||||
detail:[
|
||||
`${t("Listener")}: ${source.listen || "0.0.0.0"}:${source.port}`,
|
||||
`${t("Existing path preserved")}: ${normalizeVisualPath(xhttp.path)}`,
|
||||
`${t("New SSH path")}: /ssh`,
|
||||
`${t("Clients preserved")}: ${clients}`,
|
||||
`${t("Security")}: ${security === "tls" ? "TLS" : t("No TLS")}`,
|
||||
].join("\n"),
|
||||
confirmLabel:t("Enable SSH /ssh"),
|
||||
});
|
||||
if (!accepted) return false;
|
||||
}
|
||||
if (trigger) {
|
||||
trigger.disabled = true;
|
||||
trigger.textContent = t("Enabling SSH…");
|
||||
}
|
||||
|
||||
const streamSettings = cloneJsonSafe(source.streamSettings || {});
|
||||
const settingsKey = streamSettings.xhttpSettings ? "xhttpSettings" : (streamSettings.splithttpSettings ? "splithttpSettings" : "xhttpSettings");
|
||||
streamSettings[settingsKey] = Object.assign({}, streamSettings[settingsKey] || {}, { path:"/ssh" });
|
||||
const sshInbound = {
|
||||
tag: uniqueLegacySSHTag(source),
|
||||
listen: source.listen || "0.0.0.0",
|
||||
port: cloneJsonSafe(source.port),
|
||||
protocol: "ssh",
|
||||
settings: {},
|
||||
streamSettings,
|
||||
};
|
||||
const previousDirty = wzDirty;
|
||||
const before = cloneJsonSafe(source);
|
||||
wzInbounds.push(sshInbound);
|
||||
try {
|
||||
validateVisualInbounds(wzInbounds);
|
||||
} catch (error) {
|
||||
wzInbounds.pop();
|
||||
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
|
||||
reportSSHMigration(t("Could not enable SSH: {error}", {error:error.message}));
|
||||
return false;
|
||||
}
|
||||
if (JSON.stringify(source) !== JSON.stringify(before)) {
|
||||
wzInbounds.pop();
|
||||
if (trigger) { trigger.disabled = false; trigger.textContent = t("Enable SSH /ssh"); }
|
||||
reportSSHMigration(t("Migration was cancelled because it would alter the old inbound."));
|
||||
return false;
|
||||
}
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
if (!applyNow) {
|
||||
if (st) st.textContent = t("SSH /ssh added to the draft without changing {name}.", {name:source.tag || t("the old inbound")});
|
||||
return true;
|
||||
}
|
||||
if (st) st.textContent = t("Enabling SSH /ssh without changing {name}…", {name:source.tag || t("the old inbound")});
|
||||
const result = await applyWizardConfig();
|
||||
if (!result?.saved) {
|
||||
const addedIndex = wzInbounds.indexOf(sshInbound);
|
||||
if (addedIndex >= 0) wzInbounds.splice(addedIndex, 1);
|
||||
wzDirty = previousDirty;
|
||||
renderWzInbounds();
|
||||
reportSSHMigration(result?.error || t("The SSH route could not be saved. The old inbound was not changed."));
|
||||
return false;
|
||||
}
|
||||
if (!result.restarted) {
|
||||
reportSSHMigration(t("SSH /ssh was saved, but Xray could not restart. Use the Restart button after checking the Xray log."), "warning");
|
||||
return true;
|
||||
}
|
||||
reportSSHMigration(t("SSH /ssh is active. The old inbound and all clients were preserved."), "success");
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateVisualInbounds(inbounds) {
|
||||
const tags = new Set();
|
||||
const binds = new Map();
|
||||
const nativeMode = (document.getElementById("xCoreMode")?.value || "native") === "native";
|
||||
for (const ib of inbounds || []) {
|
||||
const tag = String(ib?.tag || "").trim();
|
||||
if (!tag) throw new Error("every inbound needs a tag");
|
||||
if (tags.has(tag)) throw new Error(`duplicate inbound tag: ${tag}`);
|
||||
tags.add(tag);
|
||||
const port = Number(ib?.port || 0);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port on ${tag}`);
|
||||
const bind = `${String(ib?.listen || "0.0.0.0").trim()}:${port}`;
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
if (!binds.has(bind)) binds.set(bind, []);
|
||||
binds.get(bind).push({ ib, xh });
|
||||
if (String(ib?.protocol || "").toLowerCase() === "ssh") {
|
||||
if (!xh) throw new Error(`SSH inbound ${tag} requires XHTTP`);
|
||||
if (!nativeMode) throw new Error(`SSH inbound ${tag} requires native Xray mode`);
|
||||
}
|
||||
}
|
||||
for (const [bind, rows] of binds) {
|
||||
if (rows.length < 2) continue;
|
||||
if (!nativeMode || rows.some(row => !row.xh)) throw new Error(`multiple inbounds cannot share ${bind} unless all use native XHTTP`);
|
||||
const paths = new Set();
|
||||
const firstSecurity = String(rows[0].ib.streamSettings?.security || "none");
|
||||
const firstCert = rows[0].ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
for (const row of rows) {
|
||||
const path = normalizeVisualPath(row.xh.path);
|
||||
if (paths.has(path)) throw new Error(`duplicate XHTTP path ${path} on ${bind}`);
|
||||
paths.add(path);
|
||||
const security = String(row.ib.streamSettings?.security || "none");
|
||||
const cert = row.ib.streamSettings?.tlsSettings?.certificates?.[0] || {};
|
||||
if (security !== firstSecurity) throw new Error(`shared XHTTP inbounds on ${bind} must use the same TLS setting`);
|
||||
if (security === "tls" && (cert.certificateFile !== firstCert.certificateFile || cert.keyFile !== firstCert.keyFile)) {
|
||||
throw new Error(`shared XHTTP inbounds on ${bind} must use the same certificate`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findSharedEndpointPair() {
|
||||
const roots = wzInbounds.filter(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
});
|
||||
for (const proxy of roots) {
|
||||
const ssh = wzInbounds.find(ib => String(ib?.protocol || "").toLowerCase() === "ssh" &&
|
||||
String(ib.listen || "0.0.0.0") === String(proxy.listen || "0.0.0.0") && String(ib.port) === String(proxy.port) &&
|
||||
normalizeVisualPath(visualXHTTPSettings(ib)?.path) === "/ssh");
|
||||
if (ssh) return { proxy, ssh };
|
||||
}
|
||||
const proxy = wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || null;
|
||||
const ssh = wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || null;
|
||||
return proxy && ssh ? { proxy, ssh } : null;
|
||||
}
|
||||
|
||||
function loadSharedEndpointForm() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
if (!status) return;
|
||||
const pair = findSharedEndpointPair();
|
||||
if (!pair) {
|
||||
status.textContent = wzLoadedConfigText ? "Nenhum endpoint compartilhado detectado. Preencha os campos para criar um." : "Carregue a configuração para detectar um endpoint existente.";
|
||||
return;
|
||||
}
|
||||
const xh = visualXHTTPSettings(pair.proxy) || {};
|
||||
const ss = pair.proxy.streamSettings || {};
|
||||
const cert = ss.tlsSettings?.certificates?.[0] || {};
|
||||
setWzValue("sharedXHTTPProtocol", pair.proxy.protocol || "vless");
|
||||
setWzValue("sharedXHTTPPort", pair.proxy.port || 443);
|
||||
setWzValue("sharedXHTTPListen", pair.proxy.listen || "0.0.0.0");
|
||||
setWzValue("sharedXHTTPHost", xh.host || "");
|
||||
setWzValue("sharedXHTTPMode", xh.mode || "auto");
|
||||
setWzValue("sharedXHTTPSecurity", ss.security === "tls" ? "tls" : "none");
|
||||
setWzValue("sharedXHTTPCert", cert.certificateFile || "");
|
||||
setWzValue("sharedXHTTPKey", cert.keyFile || "");
|
||||
updateSharedEndpointControls();
|
||||
status.textContent = `Endpoint detectado em ${pair.proxy.listen || "0.0.0.0"}:${pair.proxy.port} — ${String(pair.proxy.protocol).toUpperCase()} / e SSH /ssh.`;
|
||||
}
|
||||
|
||||
function updateSharedEndpointControls() {
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol")?.value || "vless";
|
||||
const security = document.getElementById("sharedXHTTPSecurity")?.value || "none";
|
||||
document.getElementById("sharedProxyRouteLabel").textContent = protocol.toUpperCase();
|
||||
document.querySelectorAll(".shared-tls-field").forEach(el => el.classList.toggle("hidden", security !== "tls"));
|
||||
}
|
||||
|
||||
function applySharedXHTTPEndpoint() {
|
||||
const status = document.getElementById("sharedXHTTPStatus");
|
||||
const selectedID = selectedXrayServer() || "local";
|
||||
if (!wzLoadedConfigText || String(wzLoadedServerID || "") !== String(selectedID)) {
|
||||
status.textContent = "Carregue a configuração do servidor selecionado antes de editar.";
|
||||
return;
|
||||
}
|
||||
if ((document.getElementById("xCoreMode")?.value || "native") !== "native") {
|
||||
status.textContent = "O endpoint compartilhado requer o modo Xray nativo.";
|
||||
return;
|
||||
}
|
||||
const protocol = document.getElementById("sharedXHTTPProtocol").value;
|
||||
const port = Number(document.getElementById("sharedXHTTPPort").value || 0);
|
||||
const listen = document.getElementById("sharedXHTTPListen").value.trim() || "0.0.0.0";
|
||||
const host = document.getElementById("sharedXHTTPHost").value.trim();
|
||||
const mode = document.getElementById("sharedXHTTPMode").value || "auto";
|
||||
const security = document.getElementById("sharedXHTTPSecurity").value;
|
||||
const cert = document.getElementById("sharedXHTTPCert").value.trim();
|
||||
const key = document.getElementById("sharedXHTTPKey").value.trim();
|
||||
if (!["vless", "vmess"].includes(protocol) || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
status.textContent = "Escolha VLESS/VMess e uma porta válida.";
|
||||
return;
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${host}${cert}${key}`)) {
|
||||
status.textContent = "Os campos contêm caracteres de controle inválidos.";
|
||||
return;
|
||||
}
|
||||
if (security === "tls" && (!cert || !key)) {
|
||||
status.textContent = "Informe os arquivos do certificado e da chave para usar TLS.";
|
||||
return;
|
||||
}
|
||||
|
||||
const pair = findSharedEndpointPair();
|
||||
const sameEndpoint = ib => String(ib?.listen || "0.0.0.0") === listen && String(ib?.port) === String(port);
|
||||
const existingProxy = pair?.proxy || wzInbounds.find(ib => ib?.tag === "shared-proxy-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && ["vless", "vmess"].includes(String(ib?.protocol || "").toLowerCase()) && normalizeVisualPath(xh.path) === "/";
|
||||
}) || null;
|
||||
const existingSSH = pair?.ssh || wzInbounds.find(ib => ib?.tag === "shared-ssh-xhttp") || wzInbounds.find(ib => {
|
||||
const xh = visualXHTTPSettings(ib);
|
||||
return sameEndpoint(ib) && !!xh && String(ib?.protocol || "").toLowerCase() === "ssh" && normalizeVisualPath(xh.path) === "/ssh";
|
||||
}) || null;
|
||||
const removeSet = new Set([existingProxy, existingSSH].filter(Boolean));
|
||||
const others = wzInbounds.filter(ib => !removeSet.has(ib));
|
||||
const blocking = others.find(ib => String(ib.listen || "0.0.0.0") === listen && String(ib.port) === String(port) && !visualXHTTPSettings(ib));
|
||||
if (blocking) {
|
||||
status.textContent = `A porta já é usada pelo inbound não-XHTTP ${blocking.tag || "sem tag"}. Escolha outra porta.`;
|
||||
return;
|
||||
}
|
||||
|
||||
const buildSharedStream = (existing, path) => {
|
||||
const stream = cloneJsonSafe(existing?.streamSettings || {});
|
||||
stream.network = "xhttp";
|
||||
stream.xhttpSettings = Object.assign({}, stream.xhttpSettings || stream.splithttpSettings || {}, { path, mode });
|
||||
delete stream.splithttpSettings;
|
||||
if (host) stream.xhttpSettings.host = host;
|
||||
else delete stream.xhttpSettings.host;
|
||||
if (security === "tls") {
|
||||
stream.security = "tls";
|
||||
stream.tlsSettings = Object.assign({}, stream.tlsSettings || {}, { certificates:[{ certificateFile:cert, keyFile:key }] });
|
||||
} else {
|
||||
delete stream.security;
|
||||
delete stream.tlsSettings;
|
||||
}
|
||||
delete stream.realitySettings;
|
||||
return stream;
|
||||
};
|
||||
const previousClients = Array.isArray(existingProxy?.settings?.clients) ? cloneJsonSafe(existingProxy.settings.clients) : [];
|
||||
const proxyInbound = cloneJsonSafe(existingProxy || {});
|
||||
proxyInbound.tag = existingProxy?.tag || "shared-proxy-xhttp";
|
||||
proxyInbound.listen = listen;
|
||||
proxyInbound.port = port;
|
||||
proxyInbound.protocol = protocol;
|
||||
proxyInbound.settings = existingProxy?.protocol === protocol ? cloneJsonSafe(existingProxy.settings || {}) : {};
|
||||
proxyInbound.settings.clients = previousClients;
|
||||
if (protocol === "vless") proxyInbound.settings.decryption = "none";
|
||||
else delete proxyInbound.settings.decryption;
|
||||
proxyInbound.streamSettings = buildSharedStream(existingProxy, "/");
|
||||
const sshInbound = cloneJsonSafe(existingSSH || {});
|
||||
sshInbound.tag = existingSSH?.tag || "shared-ssh-xhttp";
|
||||
sshInbound.listen = listen;
|
||||
sshInbound.port = port;
|
||||
sshInbound.protocol = "ssh";
|
||||
sshInbound.settings = {};
|
||||
sshInbound.streamSettings = buildSharedStream(existingSSH, "/ssh");
|
||||
wzInbounds = [...others, proxyInbound, sshInbound];
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
loadSharedEndpointForm();
|
||||
status.textContent = "Endpoint atualizado no rascunho. Clique em Salvar configuração e reiniciar para aplicar.";
|
||||
}
|
||||
|
||||
document.getElementById("sharedXHTTPProtocol")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPSecurity")?.addEventListener("change", updateSharedEndpointControls);
|
||||
document.getElementById("sharedXHTTPApplyBtn")?.addEventListener("click", applySharedXHTTPEndpoint);
|
||||
updateSharedEndpointControls();
|
||||
|
||||
function onWzProtoChange(val) {
|
||||
const usesClientTransport = val === "vless" || val === "vmess";
|
||||
document.getElementById("wzVlessFields").style.display = usesClientTransport ? "grid" : "none";
|
||||
const isSSH = val === "ssh";
|
||||
// SSH tunnels reuse the VLESS/VMess transport block to expose the XHTTP
|
||||
// fields, but carry no proxy client list of their own.
|
||||
const usesTransportFields = val === "vless" || val === "vmess" || isSSH;
|
||||
document.getElementById("wzVlessFields").style.display = usesTransportFields ? "grid" : "none";
|
||||
document.getElementById("wzTrojanFields").style.display = val === "trojan" ? "" : "none";
|
||||
document.getElementById("wzSSFields").style.display = val === "shadowsocks" ? "grid" : "none";
|
||||
|
||||
// SSH runs only over XHTTP: force the network to xhttp and lock the dropdown
|
||||
// so the wizard can only emit a valid xhttp+ssh inbound.
|
||||
const netSel = document.getElementById("wzNetwork");
|
||||
if (isSSH) {
|
||||
netSel.value = "xhttp";
|
||||
netSel.disabled = true;
|
||||
onWzNetworkChange("xhttp");
|
||||
} else {
|
||||
netSel.disabled = false;
|
||||
}
|
||||
|
||||
const tlsSel = document.getElementById("wzTLS");
|
||||
const realityOpt = document.querySelector("#wzTLS option[value='reality']");
|
||||
if (realityOpt) {
|
||||
realityOpt.disabled = val === "vmess";
|
||||
if (val === "vmess" && tlsSel.value === "reality") {
|
||||
// REALITY is not wired for the native XHTTP listener (tls/none only) and is
|
||||
// unavailable for VMess, so disable it for both.
|
||||
const noReality = val === "vmess" || isSSH;
|
||||
realityOpt.disabled = noReality;
|
||||
if (noReality && tlsSel.value === "reality") {
|
||||
tlsSel.value = "none";
|
||||
onWzTLSChange("none");
|
||||
}
|
||||
}
|
||||
|
||||
const portMap = { vless:10086, vmess:10087, trojan:8443, shadowsocks:8388, socks:10808 };
|
||||
const tagMap = { vless:"vless-in", vmess:"vmess-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
|
||||
const portMap = { vless:10086, vmess:10087, ssh:2087, trojan:8443, shadowsocks:8388, socks:10808 };
|
||||
const tagMap = { vless:"vless-in", vmess:"vmess-in", ssh:"ssh-xhttp-in", trojan:"trojan-in", shadowsocks:"ss-in", socks:"socks-local" };
|
||||
const portEl = document.getElementById("wzPort");
|
||||
const tagEl = document.getElementById("wzTag");
|
||||
const lisEl = document.getElementById("wzListenIP");
|
||||
@@ -210,60 +750,115 @@ function wzSaveInbound() {
|
||||
const port = parseInt(document.getElementById("wzPort").value || "0", 10);
|
||||
const listen = document.getElementById("wzListenIP").value.trim() || "0.0.0.0";
|
||||
const tag = document.getElementById("wzTag").value.trim() || proto+"-in";
|
||||
if (!port) { alert("Port required."); return; }
|
||||
const ib = { tag, port, listen, protocol: proto, settings: {} };
|
||||
const st = document.getElementById("wzStatus");
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) { st.textContent = "Informe uma porta válida."; return; }
|
||||
if (/[\u0000-\u001f\u007f]/.test(`${listen}${tag}`)) { st.textContent = "Listen ou tag contém caracteres inválidos."; return; }
|
||||
if (wzInbounds.some((item, index) => index !== wzEditingIndex && item?.tag === tag)) { st.textContent = `A tag ${tag} já está em uso.`; return; }
|
||||
|
||||
const original = wzEditingIndex >= 0 ? cloneJsonSafe(wzInbounds[wzEditingIndex]) : null;
|
||||
const ib = original || {};
|
||||
const previousClients = Array.isArray(original?.settings?.clients) ? cloneJsonSafe(original.settings.clients) : [];
|
||||
ib.tag = tag;
|
||||
ib.port = port;
|
||||
ib.listen = listen;
|
||||
ib.protocol = proto;
|
||||
ib.settings = {};
|
||||
if (proto === "vless" || proto === "vmess") {
|
||||
ib.settings = proto === "vless" ? { clients: [], decryption: "none" } : { clients: [] };
|
||||
ib.settings = original?.protocol === proto && original.settings ? cloneJsonSafe(original.settings) : {};
|
||||
ib.settings.clients = previousClients;
|
||||
if (proto === "vless") ib.settings.decryption = "none";
|
||||
else delete ib.settings.decryption;
|
||||
const net = document.getElementById("wzNetwork").value;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
ib.streamSettings = { network: net };
|
||||
const previousStream = original?.protocol === proto && original?.streamSettings?.network === net ? cloneJsonSafe(original.streamSettings) : {};
|
||||
ib.streamSettings = previousStream || {};
|
||||
ib.streamSettings.network = net;
|
||||
["wsSettings", "xhttpSettings", "splithttpSettings", "httpupgradeSettings", "httpSettings", "grpcSettings"].forEach(key => {
|
||||
if (!((net === "ws" && key === "wsSettings") || (net === "xhttp" && (key === "xhttpSettings" || key === "splithttpSettings")) || (net === "httpupgrade" && key === "httpupgradeSettings") || (net === "h2" && key === "httpSettings") || (net === "grpc" && key === "grpcSettings"))) delete ib.streamSettings[key];
|
||||
});
|
||||
// Transport-specific settings
|
||||
switch (net) {
|
||||
case "ws":
|
||||
ib.streamSettings.wsSettings = { path: document.getElementById("wzWSPath").value.trim() || "/" };
|
||||
ib.streamSettings.wsSettings = Object.assign({}, ib.streamSettings.wsSettings || {}, { path: document.getElementById("wzWSPath").value.trim() || "/" });
|
||||
break;
|
||||
case "xhttp":
|
||||
ib.streamSettings.xhttpSettings = {
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
break;
|
||||
case "httpupgrade":
|
||||
ib.streamSettings.httpupgradeSettings = {
|
||||
ib.streamSettings.httpupgradeSettings = Object.assign({}, ib.streamSettings.httpupgradeSettings || {}, {
|
||||
path: document.getElementById("wzHUPath").value.trim() || "/",
|
||||
host: document.getElementById("wzHUHost").value.trim() || undefined,
|
||||
};
|
||||
});
|
||||
if (!ib.streamSettings.httpupgradeSettings.host) delete ib.streamSettings.httpupgradeSettings.host;
|
||||
break;
|
||||
case "h2":
|
||||
ib.streamSettings.httpSettings = {
|
||||
ib.streamSettings.httpSettings = Object.assign({}, ib.streamSettings.httpSettings || {}, {
|
||||
path: document.getElementById("wzH2Path").value.trim() || "/",
|
||||
host: [document.getElementById("wzH2Host").value.trim()].filter(Boolean),
|
||||
};
|
||||
});
|
||||
break;
|
||||
case "grpc":
|
||||
ib.streamSettings.grpcSettings = {
|
||||
ib.streamSettings.grpcSettings = Object.assign({}, ib.streamSettings.grpcSettings || {}, {
|
||||
serviceName: document.getElementById("wzGRPCService").value.trim() || "grpc",
|
||||
multiMode: document.getElementById("wzGRPCMulti").checked,
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
// TLS / Reality
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = {
|
||||
certificates: [{ certificateFile: document.getElementById("wzTLSCert").value.trim(), keyFile: document.getElementById("wzTLSKey").value.trim() }],
|
||||
};
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
delete ib.streamSettings.realitySettings;
|
||||
} else if (tlsVal === "reality" && proto === "vless") {
|
||||
ib.streamSettings.security = "reality";
|
||||
ib.streamSettings.realitySettings = {
|
||||
ib.streamSettings.realitySettings = Object.assign({}, ib.streamSettings.realitySettings || {}, {
|
||||
dest: document.getElementById("wzRealityDest").value.trim(),
|
||||
serverNames: [document.getElementById("wzRealitySNI").value.trim()].filter(Boolean),
|
||||
privateKey: document.getElementById("wzRealityPriv").value.trim(),
|
||||
shortIds: [document.getElementById("wzRealityShortID").value.trim()].filter(Boolean),
|
||||
};
|
||||
});
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
delete ib.streamSettings.realitySettings;
|
||||
}
|
||||
} else if (proto === "ssh") {
|
||||
// SSH tunnel over XHTTP: no proxy clients — the decoded stream is handed to
|
||||
// the SSH server, so authentication is an ordinary SSH account.
|
||||
ib.settings = {};
|
||||
ib.streamSettings = original?.protocol === "ssh" ? (cloneJsonSafe(original.streamSettings) || {}) : {};
|
||||
ib.streamSettings.network = "xhttp";
|
||||
ib.streamSettings.xhttpSettings = Object.assign({}, ib.streamSettings.xhttpSettings || ib.streamSettings.splithttpSettings || {}, {
|
||||
path: document.getElementById("wzXHTTPPath").value.trim() || "/xhttp",
|
||||
host: document.getElementById("wzXHTTPHost").value.trim() || undefined,
|
||||
mode: document.getElementById("wzXHTTPMode").value,
|
||||
});
|
||||
delete ib.streamSettings.splithttpSettings;
|
||||
if (!ib.streamSettings.xhttpSettings.host) delete ib.streamSettings.xhttpSettings.host;
|
||||
const tlsVal = document.getElementById("wzTLS").value;
|
||||
if (tlsVal === "tls") {
|
||||
const certificateFile = document.getElementById("wzTLSCert").value.trim();
|
||||
const keyFile = document.getElementById("wzTLSKey").value.trim();
|
||||
if (!certificateFile || !keyFile) { st.textContent = "TLS exige os arquivos de certificado e chave."; return; }
|
||||
ib.streamSettings.security = "tls";
|
||||
ib.streamSettings.tlsSettings = Object.assign({}, ib.streamSettings.tlsSettings || {}, {
|
||||
certificates: [{ certificateFile, keyFile }],
|
||||
});
|
||||
} else {
|
||||
delete ib.streamSettings.security;
|
||||
delete ib.streamSettings.tlsSettings;
|
||||
}
|
||||
} else if (proto === "trojan") {
|
||||
ib.settings = { clients: [{ password: document.getElementById("wzTrojanPass").value.trim() || "change-me" }] };
|
||||
@@ -274,13 +869,13 @@ function wzSaveInbound() {
|
||||
ib.settings = { auth: "noauth", udp: true };
|
||||
ib.streamSettings = { network: "tcp" };
|
||||
}
|
||||
wzInbounds.push(ib);
|
||||
if (wzEditingIndex >= 0) wzInbounds[wzEditingIndex] = ib;
|
||||
else wzInbounds.push(ib);
|
||||
wzDirty = true;
|
||||
renderWzInbounds();
|
||||
document.getElementById("wzAddInboundForm").classList.add("hidden");
|
||||
document.getElementById("wzPort").value = "";
|
||||
document.getElementById("wzTag").value = "";
|
||||
document.getElementById("wzListenIP").value = "";
|
||||
loadSharedEndpointForm();
|
||||
st.textContent = original ? `Inbound ${tag} atualizado no rascunho.` : `Inbound ${tag} adicionado ao rascunho.`;
|
||||
wzCancelInbound();
|
||||
}
|
||||
|
||||
|
||||
@@ -309,6 +904,7 @@ function buildConfigFromVisualEditor() {
|
||||
const existingInbounds = Array.isArray(cfg.inbounds) ? cfg.inbounds : [];
|
||||
const hiddenApiInbounds = existingInbounds.filter(ib => ib && ib.tag === "api");
|
||||
const visualInbounds = cloneJsonSafe((wzInbounds || []).filter(ib => ib && ib.tag !== "api")) || [];
|
||||
validateVisualInbounds(visualInbounds);
|
||||
cfg.inbounds = [...hiddenApiInbounds, ...visualInbounds];
|
||||
|
||||
return cfg;
|
||||
@@ -328,7 +924,7 @@ async function applyWizardConfig() {
|
||||
if (String(wzLoadedServerID || "") !== String(selectedID) || !wzLoadedConfigText) {
|
||||
if (st) st.textContent = `Reloading config from ${target} before saving...`;
|
||||
loadWizardFromConfig();
|
||||
return;
|
||||
return { saved:false, restarted:false, error:t("Configuration for this server was not loaded.") };
|
||||
}
|
||||
|
||||
let cfg;
|
||||
@@ -336,7 +932,7 @@ async function applyWizardConfig() {
|
||||
cfg = buildConfigFromVisualEditor();
|
||||
} catch(e) {
|
||||
if (st) st.textContent = `Invalid visual config: ${e.message}`;
|
||||
return;
|
||||
return { saved:false, restarted:false, error:t("Invalid visual config: {error}", {error:e.message}) };
|
||||
}
|
||||
|
||||
if (st) st.textContent = `Saving config to ${target}...`;
|
||||
@@ -349,12 +945,15 @@ async function applyWizardConfig() {
|
||||
wzLoadedServerID = selectedID;
|
||||
wzDirty = false;
|
||||
if (st) st.textContent = `Saved on ${target}. Restarting Xray...`;
|
||||
await xrayCtrl("restart");
|
||||
if (st) st.textContent = `Config saved on ${target} and Xray restarted.`;
|
||||
const restarted = await xrayCtrl("restart");
|
||||
if (st) st.textContent = restarted
|
||||
? `Config saved on ${target} and Xray restarted.`
|
||||
: `Config saved on ${target}, but Xray could not restart. Check Xray logs before editing again.`;
|
||||
setTimeout(() => { loadXrayStatus(); loadInbounds({ force: true }); }, 700);
|
||||
return { saved:true, restarted:!!restarted, error:restarted ? "" : t("Xray could not restart.") };
|
||||
} catch (e) {
|
||||
if (e.message==="auth") doAuthError();
|
||||
else if (st) st.textContent = "Error: " + e.message;
|
||||
return { saved:false, restarted:false, error:t("Could not save configuration: {error}", {error:e.message}) };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// ─── Auth error ───────────────────────────────────────────────────────────────
|
||||
function doAuthError() {
|
||||
sessionToken = "";
|
||||
localStorage.removeItem("SESSION_TOKEN");
|
||||
sessionStorage.removeItem("SESSION_TOKEN");
|
||||
clearTimers();
|
||||
mainApp.classList.add("hidden");
|
||||
loginOverlay.classList.remove("hidden");
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// ─── Git update status ───────────────────────────────────────────────────────
|
||||
const DRAGON_UPDATE_COMMAND = "sudo bash /opt/sshpanel/update.sh";
|
||||
|
||||
function updateStatusErrorText(error) {
|
||||
switch (String(error || "")) {
|
||||
case "remote update check timed out":
|
||||
return t("Update check timed out.");
|
||||
case "could not read the remote Git branch":
|
||||
return t("Could not reach the Git repository.");
|
||||
case "current build commit is unavailable":
|
||||
return t("Current build commit is unavailable.");
|
||||
default:
|
||||
return error || t("Unknown update-check error.");
|
||||
}
|
||||
}
|
||||
|
||||
function setUpdateState(label, tone = "") {
|
||||
const chip = document.getElementById("updateStateChip");
|
||||
if (!chip) return;
|
||||
chip.className = "chip" + (tone ? ` ${tone}` : "");
|
||||
chip.textContent = label;
|
||||
}
|
||||
|
||||
function setCommitValue(elementID, shortValue, fullValue) {
|
||||
const el = document.getElementById(elementID);
|
||||
if (!el) return;
|
||||
el.textContent = shortValue || "--";
|
||||
el.title = fullValue || "";
|
||||
}
|
||||
|
||||
function renderUpdateStatus(data) {
|
||||
setCommitValue("updateCurrentCommit", data.current_commit_short, data.current_commit);
|
||||
setCommitValue("updateLatestCommit", data.latest_commit_short, data.latest_commit);
|
||||
|
||||
const branch = document.getElementById("updateBranch");
|
||||
if (branch) branch.textContent = data.branch || "main";
|
||||
|
||||
const checked = document.getElementById("updateCheckedAt");
|
||||
if (checked) {
|
||||
const date = data.checked_at ? new Date(data.checked_at) : null;
|
||||
checked.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "--";
|
||||
}
|
||||
|
||||
const repoLink = document.getElementById("updateRepoLink");
|
||||
if (repoLink && data.repo_web_url) repoLink.href = data.repo_web_url;
|
||||
|
||||
const text = document.getElementById("updateStatusText");
|
||||
const commandWrap = document.getElementById("updateCommandWrap");
|
||||
commandWrap?.classList.toggle("hidden", !data.update_available);
|
||||
|
||||
if (data.status === "up_to_date") {
|
||||
setUpdateState(t("Up to date"), "green");
|
||||
if (text) text.textContent = t("The installed version matches the latest commit on {branch}.", { branch: data.branch || "main" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "update_available") {
|
||||
setUpdateState(t("Update available"), "warn");
|
||||
if (text) text.textContent = t("A newer commit is available on {branch}.", { branch: data.branch || "main" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "local_changes") {
|
||||
setUpdateState(t("Local changes"), "warn");
|
||||
if (text) text.textContent = t("This build contains local changes, so it cannot be compared safely.");
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateState(t("Unknown"), "red");
|
||||
if (text) {
|
||||
text.textContent = data.error
|
||||
? t("Could not check for updates: {error}", { error: updateStatusErrorText(data.error) })
|
||||
: t("The update status could not be determined.");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUpdateStatus(force = false) {
|
||||
if (currentRole !== "superadmin") return;
|
||||
|
||||
const button = document.getElementById("checkUpdateBtn");
|
||||
const text = document.getElementById("updateStatusText");
|
||||
if (button) button.disabled = true;
|
||||
setUpdateState(t("Checking…"));
|
||||
if (text) text.textContent = t("Checking repository…");
|
||||
|
||||
try {
|
||||
const path = "/api/system/update-status" + (force ? "?refresh=1" : "");
|
||||
const res = await api(path);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
renderUpdateStatus(await res.json());
|
||||
} catch (error) {
|
||||
setUpdateState(t("Unknown"), "red");
|
||||
if (text) text.textContent = t("Could not check for updates: {error}", { error: error.message || t("Network error.") });
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById("checkUpdateBtn")?.addEventListener("click", () => loadUpdateStatus(true));
|
||||
document.getElementById("copyUpdateCommandBtn")?.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(DRAGON_UPDATE_COMMAND);
|
||||
} catch {
|
||||
const input = document.createElement("textarea");
|
||||
input.value = DRAGON_UPDATE_COMMAND;
|
||||
input.style.position = "fixed";
|
||||
input.style.opacity = "0";
|
||||
document.body.appendChild(input);
|
||||
input.select();
|
||||
document.execCommand("copy");
|
||||
input.remove();
|
||||
}
|
||||
const button = document.getElementById("copyUpdateCommandBtn");
|
||||
if (button) {
|
||||
const oldText = button.textContent;
|
||||
button.textContent = t("Copied");
|
||||
setTimeout(() => { button.textContent = oldText; }, 1400);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,629 @@
|
||||
// Bot / Vendas — safe DOM rendering and sectioned management workspace.
|
||||
|
||||
const botState = {
|
||||
config: null,
|
||||
plans: [],
|
||||
packages: [],
|
||||
users: [],
|
||||
transactions: [],
|
||||
section: sessionStorage.getItem("BOT_SECTION") || "config",
|
||||
};
|
||||
|
||||
function botStatus(id, message, ok) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = message;
|
||||
if (id === "botConfigStatus") {
|
||||
element.classList.toggle("is-ok", ok === true);
|
||||
element.classList.toggle("is-error", ok === false);
|
||||
} else {
|
||||
element.style.color = ok === false ? "var(--danger)" : "";
|
||||
}
|
||||
}
|
||||
|
||||
function botBRL(cents) {
|
||||
return new Intl.NumberFormat("pt-BR", { style: "currency", currency: "BRL" }).format(Number(cents || 0) / 100);
|
||||
}
|
||||
|
||||
function botNode(tag, options = {}, children = []) {
|
||||
const element = document.createElement(tag);
|
||||
if (options.className) element.className = options.className;
|
||||
if (options.text != null) element.textContent = String(options.text);
|
||||
if (options.title) element.title = options.title;
|
||||
if (options.type) element.type = options.type;
|
||||
for (const child of children) if (child) element.appendChild(child);
|
||||
return element;
|
||||
}
|
||||
|
||||
function botCell(content, className = "") {
|
||||
const cell = document.createElement("td");
|
||||
if (className) cell.className = className;
|
||||
if (content instanceof Node) cell.appendChild(content);
|
||||
else cell.textContent = String(content == null || content === "" ? "—" : content);
|
||||
return cell;
|
||||
}
|
||||
|
||||
function botPrimaryCell(title, detail) {
|
||||
const wrapper = botNode("div", { className: "bot-primary-cell" });
|
||||
wrapper.appendChild(botNode("strong", { text: title || "—" }));
|
||||
if (detail) wrapper.appendChild(botNode("small", { text: detail }));
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function botBadge(label, tone) {
|
||||
return botNode("span", { className: "bot-status " + tone, text: label });
|
||||
}
|
||||
|
||||
function botButton(label, handler, className = "btn btn-ghost btn-sm") {
|
||||
const button = botNode("button", { className, text: label, type: "button" });
|
||||
button.addEventListener("click", handler);
|
||||
return button;
|
||||
}
|
||||
|
||||
function botActions(buttons) {
|
||||
return botNode("div", { className: "bot-row-actions" }, buttons);
|
||||
}
|
||||
|
||||
function botEmptyRow(body, columns, message) {
|
||||
const row = botNode("tr", { className: "bot-empty-row" });
|
||||
const cell = botCell(message);
|
||||
cell.colSpan = columns;
|
||||
row.appendChild(cell);
|
||||
body.replaceChildren(row);
|
||||
}
|
||||
|
||||
async function botRequest(path, options = {}) {
|
||||
const response = await api(path, options);
|
||||
if (!response.ok) {
|
||||
const message = (await response.text()).trim();
|
||||
throw new Error(message || `HTTP ${response.status}`);
|
||||
}
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function botHandleError(error, statusID, fallback) {
|
||||
if (error.message === "auth") {
|
||||
doAuthError();
|
||||
return;
|
||||
}
|
||||
botStatus(statusID, error.message || fallback, false);
|
||||
}
|
||||
|
||||
function botSetSection(section) {
|
||||
const allowedSections = new Set(["config", "plans", "packages", "messages", "users", "transactions"]);
|
||||
if (!allowedSections.has(section)) section = "config";
|
||||
botState.section = section;
|
||||
sessionStorage.setItem("BOT_SECTION", section);
|
||||
document.querySelectorAll("[data-bot-panel]").forEach(panel => panel.classList.toggle("active", panel.dataset.botPanel === section));
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.classList.toggle("active", button.dataset.botSection === section));
|
||||
const select = document.getElementById("botSection");
|
||||
if (select) select.value = section;
|
||||
}
|
||||
|
||||
function botUpdateMetrics() {
|
||||
const config = botState.config;
|
||||
const stateMetric = document.getElementById("botMetricState");
|
||||
if (stateMetric) stateMetric.textContent = config ? (config.enabled ? "Ativo" : "Pausado") : "Indisponível";
|
||||
const plansMetric = document.getElementById("botMetricPlans");
|
||||
if (plansMetric) plansMetric.textContent = String(botState.plans.filter(plan => plan.IsActive).length);
|
||||
const usersMetric = document.getElementById("botMetricUsers");
|
||||
if (usersMetric) usersMetric.textContent = String(botState.users.length);
|
||||
const pendingMetric = document.getElementById("botMetricPending");
|
||||
if (pendingMetric) pendingMetric.textContent = String(botState.transactions.filter(transaction => transaction.Status === "pending").length);
|
||||
}
|
||||
|
||||
async function loadBotTab() {
|
||||
botSetSection(botState.section);
|
||||
botStatus("botConfigStatus", "Atualizando dados…");
|
||||
await Promise.allSettled([
|
||||
loadBotConfig(), loadBotInbounds(), loadBotPlans(), loadBotPkgs(),
|
||||
loadBotUsers(), loadBotTxns(), loadBotSettings(),
|
||||
]);
|
||||
botUpdateMetrics();
|
||||
}
|
||||
|
||||
// Configuration
|
||||
async function loadBotConfig() {
|
||||
try {
|
||||
const config = await botRequest("/api/bot/config");
|
||||
botState.config = config;
|
||||
const setValue = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
const setChecked = (id, value) => { const field = document.getElementById(id); if (field) field.checked = !!value; };
|
||||
setChecked("botEnabled", config.enabled);
|
||||
setValue("botMPConfirmMode", config.mp_confirm_mode);
|
||||
setValue("botMPPollInterval", config.mp_poll_interval);
|
||||
setValue("botPixExp", config.pix_expiration_minutes);
|
||||
setChecked("botTrialEnabled", config.trial_enabled);
|
||||
setValue("botTrialHours", config.trial_hours);
|
||||
setValue("botTrialMaxConns", config.trial_max_connections);
|
||||
setValue("botTrialKind", config.trial_kind);
|
||||
setValue("botTrialInbound", config.trial_inbound_tag);
|
||||
setValue("botAdminIDs", (config.admin_telegram_ids || []).join(", "));
|
||||
setValue("botPublicHost", config.public_host);
|
||||
setValue("botXrayPublicHost", config.xray_public_host);
|
||||
botSetSecretState("botHasTgToken", config.has_telegram_token);
|
||||
botSetSecretState("botHasMpToken", config.has_mp_access_token);
|
||||
botSetSecretState("botHasMpSecret", config.has_mp_webhook_secret);
|
||||
botToggleMPWebhookBox();
|
||||
botStatus("botConfigStatus", config.enabled ? "Bot ativo" : "Bot pausado", true);
|
||||
botUpdateMetrics();
|
||||
return config;
|
||||
} catch (error) {
|
||||
botState.config = null;
|
||||
botHandleError(error, "botConfigStatus", "Erro ao carregar configuração.");
|
||||
botUpdateMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
function botSetSecretState(id, configured) {
|
||||
const element = document.getElementById(id);
|
||||
if (!element) return;
|
||||
element.textContent = configured ? "● protegido" : "○ não configurado";
|
||||
element.classList.toggle("is-set", !!configured);
|
||||
element.classList.toggle("is-missing", !configured);
|
||||
}
|
||||
|
||||
function botToggleMPWebhookBox() {
|
||||
const mode = document.getElementById("botMPConfirmMode")?.value;
|
||||
document.getElementById("botMPWebhookBox")?.classList.toggle("hidden", mode !== "webhook");
|
||||
const url = document.getElementById("botMPWebhookURL");
|
||||
if (url) url.textContent = location.origin + "/api/mp/webhook";
|
||||
}
|
||||
|
||||
async function saveBotConfig() {
|
||||
const value = id => (document.getElementById(id)?.value || "").trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id)?.value || "0", 10) || 0;
|
||||
const checked = id => !!document.getElementById(id)?.checked;
|
||||
const adminIDs = value("botAdminIDs").split(",").map(item => Number.parseInt(item.trim(), 10)).filter(Number.isSafeInteger);
|
||||
const payload = {
|
||||
enabled: checked("botEnabled"), telegram_token: value("botTelegramToken"),
|
||||
mp_access_token: value("botMPToken"), mp_confirm_mode: value("botMPConfirmMode"),
|
||||
mp_webhook_secret: value("botMPWebhookSecret"), mp_poll_interval: value("botMPPollInterval"),
|
||||
pix_expiration_minutes: number("botPixExp"), trial_enabled: checked("botTrialEnabled"),
|
||||
trial_hours: number("botTrialHours"), trial_max_connections: number("botTrialMaxConns"),
|
||||
trial_kind: value("botTrialKind"), trial_inbound_tag: value("botTrialInbound"),
|
||||
admin_telegram_ids: adminIDs, public_host: value("botPublicHost"), xray_public_host: value("botXrayPublicHost"),
|
||||
};
|
||||
botStatus("botConfigStatus", "Salvando e reiniciando…");
|
||||
try {
|
||||
await botRequest("/api/bot/config", { method: "POST", body: JSON.stringify(payload) });
|
||||
["botTelegramToken", "botMPToken", "botMPWebhookSecret"].forEach(id => { const field = document.getElementById(id); if (field) field.value = ""; });
|
||||
await loadBotConfig();
|
||||
botStatus("botConfigStatus", "Configuração salva", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao salvar configuração.");
|
||||
}
|
||||
}
|
||||
|
||||
async function testBot() {
|
||||
botStatus("botConfigStatus", "Testando Telegram e Mercado Pago…");
|
||||
const payload = {
|
||||
telegram_token: (document.getElementById("botTelegramToken")?.value || "").trim(),
|
||||
mp_access_token: (document.getElementById("botMPToken")?.value || "").trim(),
|
||||
};
|
||||
try {
|
||||
const result = await botRequest("/api/bot/test", { method: "POST", body: JSON.stringify(payload) });
|
||||
const telegram = result.telegram_ok ? `Telegram ${result.telegram_bot || "OK"}` : `Telegram: ${result.telegram_error || "falha"}`;
|
||||
const mercadoPago = result.mp_ok ? "Mercado Pago OK" : `Mercado Pago: ${result.mp_error || "falha"}`;
|
||||
botStatus("botConfigStatus", `${telegram} · ${mercadoPago}`, !!result.telegram_ok && !!result.mp_ok);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botConfigStatus", "Erro ao testar integrações.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botCopyWebhook() {
|
||||
const value = document.getElementById("botMPWebhookURL")?.textContent || "";
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
const button = document.getElementById("botCopyWebhookBtn");
|
||||
if (button) {
|
||||
button.textContent = "Copiado";
|
||||
setTimeout(() => { button.textContent = "Copiar"; }, 1400);
|
||||
}
|
||||
} catch {
|
||||
botStatus("botConfigStatus", "Não foi possível copiar a URL.", false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBotInbounds() {
|
||||
try {
|
||||
const inbounds = await botRequest("/api/xray/inbounds");
|
||||
const datalist = document.getElementById("botInboundList");
|
||||
if (!datalist) return;
|
||||
datalist.replaceChildren(...(inbounds || []).map(inbound => {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(inbound.tag || "");
|
||||
option.textContent = String(inbound.protocol || "");
|
||||
return option;
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error.message === "auth") doAuthError();
|
||||
}
|
||||
}
|
||||
|
||||
// Plans
|
||||
async function loadBotPlans() {
|
||||
try {
|
||||
const plans = await botRequest("/api/bot/plans");
|
||||
botState.plans = plans || [];
|
||||
renderBotPlans(botState.plans);
|
||||
botStatus("botPlansStatus", `${botState.plans.length} plano(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return plans;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao carregar planos.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotPlans(plans) {
|
||||
const body = document.getElementById("botPlansBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPlanCount").textContent = String(plans.length);
|
||||
if (!plans.length) return botEmptyRow(body, 6, "Nenhum plano cadastrado. Crie o primeiro ao lado.");
|
||||
const rows = plans.map(plan => {
|
||||
const row = document.createElement("tr");
|
||||
const delivery = plan.Kind === "xray" ? `Xray${plan.XrayProtocol ? " · " + plan.XrayProtocol.toUpperCase() : ""}` : "SSH";
|
||||
const price = botPrimaryCell(botBRL(plan.PriceCents), `${plan.CreditCost || 0} crédito(s)`);
|
||||
row.append(
|
||||
botCell(botPrimaryCell(plan.Name, `#${plan.ID}`)), botCell(delivery),
|
||||
botCell(`${plan.Days} dias`), botCell(price),
|
||||
botCell(botBadge(plan.IsActive ? "Ativo" : "Oculto", plan.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPlan(plan)),
|
||||
botButton("Excluir", () => botDeletePlan(plan.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
});
|
||||
body.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
function botEditPlan(plan) {
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value ?? ""; };
|
||||
set("planId", plan.ID); set("planName", plan.Name); set("planKind", plan.Kind); set("planDays", plan.Days);
|
||||
set("planMaxConns", plan.MaxConnections); set("planUpMbps", plan.LimitMbpsUp); set("planDownMbps", plan.LimitMbpsDown);
|
||||
set("planInbound", plan.XrayInboundTag); set("planProtocol", plan.XrayProtocol); set("planPrice", (Number(plan.PriceCents) / 100).toFixed(2));
|
||||
set("planCreditCost", plan.CreditCost); set("planServerId", plan.ServerID); set("planSort", plan.SortOrder);
|
||||
document.getElementById("planActive").checked = !!plan.IsActive;
|
||||
document.getElementById("botPlanFormTitle").textContent = `Editar ${plan.Name}`;
|
||||
document.getElementById("planName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPlanForm() {
|
||||
document.getElementById("botPlanForm")?.reset();
|
||||
document.getElementById("planId").value = "";
|
||||
document.getElementById("planActive").checked = true;
|
||||
document.getElementById("botPlanFormTitle").textContent = "Novo plano";
|
||||
}
|
||||
|
||||
async function botSavePlan(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("planId"), Name: value("planName"), Kind: value("planKind"), Days: number("planDays"),
|
||||
MaxConnections: number("planMaxConns"), LimitMbpsUp: number("planUpMbps"), LimitMbpsDown: number("planDownMbps"),
|
||||
XrayInboundTag: value("planInbound"), XrayProtocol: value("planProtocol"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("planPrice")) || 0) * 100), CreditCost: number("planCreditCost"),
|
||||
ServerID: value("planServerId"), IsActive: document.getElementById("planActive").checked, SortOrder: number("planSort"),
|
||||
};
|
||||
botStatus("botPlansStatus", "Salvando plano…");
|
||||
try {
|
||||
await botRequest("/api/bot/plans", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPlanForm();
|
||||
await loadBotPlans();
|
||||
botStatus("botPlansStatus", "Plano salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao salvar plano.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePlan(id) {
|
||||
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir plano", message:"Excluir este plano?", detail:"Esta ação não pode ser desfeita.", confirmLabel:"Excluir plano" });
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/plans?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPlans();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPlansStatus", "Erro ao excluir plano.");
|
||||
}
|
||||
}
|
||||
|
||||
// Credit packages
|
||||
async function loadBotPkgs() {
|
||||
try {
|
||||
const packages = await botRequest("/api/bot/credit-packages");
|
||||
botState.packages = packages || [];
|
||||
renderBotPackages(botState.packages);
|
||||
botStatus("botPkgStatus", `${botState.packages.length} pacote(s) carregado(s).`, true);
|
||||
return packages;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao carregar pacotes.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotPackages(packages) {
|
||||
const body = document.getElementById("botPkgsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botPkgCount").textContent = String(packages.length);
|
||||
if (!packages.length) return botEmptyRow(body, 5, "Nenhum pacote de créditos cadastrado.");
|
||||
body.replaceChildren(...packages.map(item => {
|
||||
const row = document.createElement("tr");
|
||||
row.append(
|
||||
botCell(botPrimaryCell(item.Name, `#${item.ID}`)), botCell(`${item.Credits} créditos`), botCell(botBRL(item.PriceCents)),
|
||||
botCell(botBadge(item.IsActive ? "Ativo" : "Oculto", item.IsActive ? "active" : "inactive")),
|
||||
botCell(botActions([
|
||||
botButton("Editar", () => botEditPkg(item)),
|
||||
botButton("Excluir", () => botDeletePkg(item.ID), "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botEditPkg(item) {
|
||||
const set = (id, value) => { document.getElementById(id).value = value ?? ""; };
|
||||
set("pkgId", item.ID); set("pkgName", item.Name); set("pkgCredits", item.Credits);
|
||||
set("pkgPrice", (Number(item.PriceCents) / 100).toFixed(2)); set("pkgSort", item.SortOrder);
|
||||
document.getElementById("pkgActive").checked = !!item.IsActive;
|
||||
document.getElementById("pkgName")?.focus();
|
||||
}
|
||||
|
||||
function botClearPkgForm() {
|
||||
document.getElementById("botPkgForm")?.reset();
|
||||
document.getElementById("pkgId").value = "";
|
||||
document.getElementById("pkgActive").checked = true;
|
||||
}
|
||||
|
||||
async function botSavePkg(event) {
|
||||
event.preventDefault();
|
||||
const value = id => document.getElementById(id).value.trim();
|
||||
const number = id => Number.parseInt(document.getElementById(id).value || "0", 10) || 0;
|
||||
const payload = {
|
||||
ID: number("pkgId"), Name: value("pkgName"), Credits: number("pkgCredits"),
|
||||
PriceCents: Math.round((Number.parseFloat(value("pkgPrice")) || 0) * 100),
|
||||
SortOrder: number("pkgSort"), IsActive: document.getElementById("pkgActive").checked,
|
||||
};
|
||||
botStatus("botPkgStatus", "Salvando pacote…");
|
||||
try {
|
||||
await botRequest("/api/bot/credit-packages", { method: "POST", body: JSON.stringify(payload) });
|
||||
botClearPkgForm();
|
||||
await loadBotPkgs();
|
||||
botStatus("botPkgStatus", "Pacote salvo.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao salvar pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botDeletePkg(id) {
|
||||
const accepted = await panelConfirm({ tone:"danger", icon:"×", title:"Excluir pacote", message:"Excluir este pacote de créditos?", confirmLabel:"Excluir pacote" });
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest(`/api/bot/credit-packages?id=${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
await loadBotPkgs();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botPkgStatus", "Erro ao excluir pacote.");
|
||||
}
|
||||
}
|
||||
|
||||
// Users
|
||||
async function loadBotUsers() {
|
||||
try {
|
||||
const users = await botRequest("/api/bot/users");
|
||||
botState.users = users || [];
|
||||
renderBotUsers(botState.users);
|
||||
botStatus("botUsersStatus", `${botState.users.length} cliente(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return users;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotUsers(users) {
|
||||
const body = document.getElementById("botUsersBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botUserCount").textContent = String(users.length);
|
||||
if (!users.length) return botEmptyRow(body, 6, "Nenhum cliente conversou com o bot ainda.");
|
||||
body.replaceChildren(...users.map(user => {
|
||||
const row = document.createElement("tr");
|
||||
const displayName = user.FirstName || user.Username || "Sem nome";
|
||||
const username = user.Username ? `@${user.Username}` : "Sem username";
|
||||
const isBlocked = user.Role === "blocked";
|
||||
row.append(
|
||||
botCell(botPrimaryCell(displayName, username)), botCell(user.TelegramID),
|
||||
botCell(botBadge(user.Role || "customer", user.Role || "customer")),
|
||||
botCell(user.LinkedAdminUsername || "—"), botCell(`${user.CreditBalance || 0} créditos`),
|
||||
botCell(botActions([
|
||||
botButton("Função", () => botOpenUserAction(user, "role")),
|
||||
botButton("Saldo", () => botOpenUserAction(user, "credits")),
|
||||
botButton(isBlocked ? "Desbloquear" : "Bloquear", () => botToggleBlock(user), isBlocked ? "btn btn-ghost btn-sm" : "btn btn-danger btn-sm"),
|
||||
])),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function botOpenUserAction(user, mode) {
|
||||
document.getElementById("botActionTelegramID").value = String(user.TelegramID);
|
||||
document.getElementById("botActionMode").value = mode;
|
||||
document.getElementById("botUserActionTitle").textContent = mode === "role" ? "Alterar função" : "Ajustar créditos";
|
||||
document.getElementById("botUserActionSubtitle").textContent = `${user.FirstName || user.Username || "Cliente"} · ID ${user.TelegramID}`;
|
||||
document.getElementById("botRoleFields").classList.toggle("hidden", mode !== "role");
|
||||
document.getElementById("botCreditFields").classList.toggle("hidden", mode !== "credits");
|
||||
document.getElementById("botActionRole").value = user.Role || "customer";
|
||||
document.getElementById("botActionLinked").value = user.LinkedAdminUsername || "";
|
||||
document.getElementById("botActionCredits").value = "";
|
||||
botToggleLinkedAdminField();
|
||||
document.getElementById("botUserActionModal").classList.remove("hidden");
|
||||
document.body.classList.add("bot-modal-open");
|
||||
setTimeout(() => (mode === "role" ? document.getElementById("botActionRole") : document.getElementById("botActionCredits"))?.focus(), 0);
|
||||
}
|
||||
|
||||
function botCloseUserAction() {
|
||||
document.getElementById("botUserActionModal")?.classList.add("hidden");
|
||||
document.body.classList.remove("bot-modal-open");
|
||||
}
|
||||
|
||||
function botToggleLinkedAdminField() {
|
||||
const show = document.getElementById("botActionRole")?.value === "reseller";
|
||||
document.getElementById("botActionLinkedField")?.classList.toggle("hidden", !show);
|
||||
}
|
||||
|
||||
async function botSaveUserAction(event) {
|
||||
event.preventDefault();
|
||||
const telegramID = Number.parseInt(document.getElementById("botActionTelegramID").value, 10);
|
||||
const mode = document.getElementById("botActionMode").value;
|
||||
const payload = mode === "role" ? {
|
||||
telegram_id: telegramID, action: "set_role", role: document.getElementById("botActionRole").value,
|
||||
linked_admin_username: document.getElementById("botActionLinked").value.trim(),
|
||||
} : {
|
||||
telegram_id: telegramID, action: "adjust_credits", credits: Number.parseInt(document.getElementById("botActionCredits").value, 10) || 0,
|
||||
};
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify(payload) });
|
||||
botCloseUserAction();
|
||||
await loadBotUsers();
|
||||
botStatus("botUsersStatus", "Cliente atualizado.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao atualizar cliente.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botToggleBlock(user) {
|
||||
const isBlocked = user.Role === "blocked";
|
||||
const accepted = await panelConfirm({
|
||||
tone:isBlocked ? "default" : "danger", icon:isBlocked ? "✓" : "!",
|
||||
title:isBlocked ? "Desbloquear cliente" : "Bloquear cliente",
|
||||
message:isBlocked ? "Desbloquear este cliente?" : "Bloquear este cliente no bot?",
|
||||
confirmLabel:isBlocked ? "Desbloquear" : "Bloquear",
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest("/api/bot/users", { method: "POST", body: JSON.stringify({ telegram_id: user.TelegramID, action: isBlocked ? "unblock" : "block" }) });
|
||||
await loadBotUsers();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botUsersStatus", "Erro ao alterar bloqueio.");
|
||||
}
|
||||
}
|
||||
|
||||
// Transactions
|
||||
async function loadBotTxns() {
|
||||
const filter = document.getElementById("botTxnFilter")?.value || "";
|
||||
try {
|
||||
const transactions = await botRequest(`/api/bot/transactions?limit=200&status=${encodeURIComponent(filter)}`);
|
||||
botState.transactions = transactions || [];
|
||||
renderBotTransactions(botState.transactions);
|
||||
botStatus("botTxnStatus", `${botState.transactions.length} pagamento(s) carregado(s).`, true);
|
||||
botUpdateMetrics();
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao carregar pagamentos.");
|
||||
}
|
||||
}
|
||||
|
||||
function botTransactionType(type) {
|
||||
return ({ plan_purchase: "Compra de plano", plan_renewal: "Renovação", credit_topup: "Recarga" })[type] || type || "—";
|
||||
}
|
||||
|
||||
function renderBotTransactions(transactions) {
|
||||
const body = document.getElementById("botTxnsBody");
|
||||
if (!body) return;
|
||||
document.getElementById("botTxnCount").textContent = String(transactions.length);
|
||||
if (!transactions.length) return botEmptyRow(body, 8, "Nenhum pagamento encontrado para este filtro.");
|
||||
body.replaceChildren(...transactions.map(transaction => {
|
||||
const row = document.createElement("tr");
|
||||
const createdAt = transaction.CreatedAt ? new Date(transaction.CreatedAt).toLocaleString("pt-BR", { dateStyle: "short", timeStyle: "short" }) : "—";
|
||||
const buttons = [];
|
||||
if (transaction.Status === "pending" || transaction.Status === "approved") buttons.push(botButton("Reprocessar", () => botReprocess(transaction.ID)));
|
||||
if (transaction.Status !== "refunded") buttons.push(botButton("Marcar estornado", () => botRefund(transaction.ID), "btn btn-danger btn-sm"));
|
||||
row.append(
|
||||
botCell(botPrimaryCell(`#${transaction.ID}`, transaction.MPPaymentID ? `MP ${transaction.MPPaymentID}` : "Sem ID Mercado Pago")),
|
||||
botCell(transaction.TelegramID), botCell(botTransactionType(transaction.Type)), botCell(botBRL(transaction.AmountCents)),
|
||||
botCell(botBadge(transaction.Status || "unknown", transaction.Status || "inactive")), botCell(transaction.TargetUsername || "Aguardando"),
|
||||
botCell(createdAt), botCell(botActions(buttons)),
|
||||
);
|
||||
return row;
|
||||
}));
|
||||
}
|
||||
|
||||
async function botReprocess(id) {
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "reprocess" }) });
|
||||
botStatus("botTxnStatus", `Pagamento #${id} enviado para reprocessamento.`, true);
|
||||
setTimeout(loadBotTxns, 1400);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao reprocessar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
async function botRefund(id) {
|
||||
const accepted = await panelConfirm({
|
||||
tone:"danger", icon:"!", title:"Marcar como estornado",
|
||||
message:`Marcar o pagamento #${id} como estornado no painel?`,
|
||||
detail:"Esta ação não envia um estorno financeiro ao Mercado Pago; ela altera somente o status interno.",
|
||||
confirmLabel:"Marcar estornado",
|
||||
});
|
||||
if (!accepted) return;
|
||||
try {
|
||||
await botRequest("/api/bot/transactions", { method: "POST", body: JSON.stringify({ id, action: "refund" }) });
|
||||
await loadBotTxns();
|
||||
} catch (error) {
|
||||
botHandleError(error, "botTxnStatus", "Erro ao atualizar pagamento.");
|
||||
}
|
||||
}
|
||||
|
||||
// Messages
|
||||
async function loadBotSettings() {
|
||||
try {
|
||||
const settings = await botRequest("/api/bot/settings");
|
||||
const set = (id, value) => { const field = document.getElementById(id); if (field) field.value = value || ""; };
|
||||
set("setWelcome", settings.welcome_text); set("setContact", settings.contact_text);
|
||||
set("setAppText", settings.app_text); set("setAppUrl", settings.app_url);
|
||||
botStatus("botSettingsStatus", "Mensagens carregadas.", true);
|
||||
return settings;
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao carregar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveBotSettings() {
|
||||
const value = id => document.getElementById(id)?.value || "";
|
||||
const payload = { welcome_text: value("setWelcome"), contact_text: value("setContact"), app_text: value("setAppText"), app_url: value("setAppUrl").trim() };
|
||||
botStatus("botSettingsStatus", "Salvando mensagens…");
|
||||
try {
|
||||
await botRequest("/api/bot/settings", { method: "POST", body: JSON.stringify(payload) });
|
||||
botStatus("botSettingsStatus", "Mensagens salvas.", true);
|
||||
} catch (error) {
|
||||
botHandleError(error, "botSettingsStatus", "Erro ao salvar mensagens.");
|
||||
}
|
||||
}
|
||||
|
||||
// Wiring
|
||||
document.querySelectorAll("[data-bot-section]").forEach(button => button.addEventListener("click", () => botSetSection(button.dataset.botSection)));
|
||||
document.getElementById("botSection")?.addEventListener("change", event => botSetSection(event.target.value));
|
||||
document.getElementById("botConfigSaveBtn")?.addEventListener("click", saveBotConfig);
|
||||
document.getElementById("botConfigReloadBtn")?.addEventListener("click", loadBotTab);
|
||||
document.getElementById("botTestBtn")?.addEventListener("click", testBot);
|
||||
document.getElementById("botMPConfirmMode")?.addEventListener("change", botToggleMPWebhookBox);
|
||||
document.getElementById("botCopyWebhookBtn")?.addEventListener("click", botCopyWebhook);
|
||||
document.getElementById("botReloadPlansBtn")?.addEventListener("click", loadBotPlans);
|
||||
document.getElementById("botNewPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botCancelPlanBtn")?.addEventListener("click", botClearPlanForm);
|
||||
document.getElementById("botPlanForm")?.addEventListener("submit", botSavePlan);
|
||||
document.getElementById("botReloadPkgsBtn")?.addEventListener("click", loadBotPkgs);
|
||||
document.getElementById("botNewPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botClearPkgBtn")?.addEventListener("click", botClearPkgForm);
|
||||
document.getElementById("botPkgForm")?.addEventListener("submit", botSavePkg);
|
||||
document.getElementById("botReloadUsersBtn")?.addEventListener("click", loadBotUsers);
|
||||
document.getElementById("botReloadTxnsBtn")?.addEventListener("click", loadBotTxns);
|
||||
document.getElementById("botTxnFilter")?.addEventListener("change", loadBotTxns);
|
||||
document.getElementById("botSaveSettingsBtn")?.addEventListener("click", saveBotSettings);
|
||||
document.getElementById("botReloadSettingsBtn")?.addEventListener("click", loadBotSettings);
|
||||
document.getElementById("botUserActionForm")?.addEventListener("submit", botSaveUserAction);
|
||||
document.getElementById("botActionRole")?.addEventListener("change", botToggleLinkedAdminField);
|
||||
document.querySelectorAll("[data-bot-modal-close]").forEach(element => element.addEventListener("click", botCloseUserAction));
|
||||
document.addEventListener("keydown", event => { if (event.key === "Escape") botCloseUserAction(); });
|
||||
|
||||
botSetSection(botState.section);
|
||||
+461
-126
@@ -16,7 +16,7 @@
|
||||
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
|
||||
})();
|
||||
</script>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260511visualsafesave1"/>
|
||||
<link rel="stylesheet" href="assets/app.css?v=20260713reseller10"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="app">
|
||||
@@ -51,12 +51,10 @@
|
||||
<button class="tab-btn" data-tab="ssh"><span class="nav-icon">👥</span><span>SSH / SlowDNS</span></button>
|
||||
<button class="tab-btn" data-tab="xray"><span class="nav-icon">◇</span><span>Xray Users</span></button>
|
||||
<div class="nav-group-label superadmin-only hidden">Administração</div>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Servidores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers-status"><span class="nav-icon">▥</span><span>Status Servidores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="stats"><span class="nav-icon">📊</span><span>Servidor</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="vnstat"><span class="nav-icon">⇅</span><span>Tráfego</span></button>
|
||||
<button class="tab-btn" data-tab="resellers"><span class="nav-icon">🏪</span><span>Revendedores</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="servers"><span class="nav-icon">▣</span><span>Infraestrutura</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="logs"><span class="nav-icon">☰</span><span>Logs</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="bot"><span class="nav-icon">🤖</span><span>Bot / Vendas</span></button>
|
||||
<button class="tab-btn superadmin-only hidden" data-tab="server"><span class="nav-icon">⚙</span><span>Configurações</span></button>
|
||||
</nav>
|
||||
</aside>
|
||||
@@ -158,6 +156,26 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card superadmin-only hidden" id="updateStatusCard" style="margin-top:18px;">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Panel Updates <span class="chip" id="updateStateChip">Checking…</span></div>
|
||||
<div class="card-actions">
|
||||
<a class="btn btn-ghost btn-sm" id="updateRepoLink" href="https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB" target="_blank" rel="noopener noreferrer">Open Git</a>
|
||||
<button class="btn btn-ghost btn-sm" type="button" id="checkUpdateBtn">Check now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="m-label">Installed version</div><div class="m-val update-commit" id="updateCurrentCommit">--</div></div>
|
||||
<div class="metric"><div class="m-label">Latest Git version</div><div class="m-val update-commit" id="updateLatestCommit">--</div></div>
|
||||
<div class="metric"><div class="m-label">Branch</div><div class="m-val update-commit" id="updateBranch">main</div></div>
|
||||
<div class="metric"><div class="m-label">Last checked</div><div class="m-val update-check-time" id="updateCheckedAt">--</div></div>
|
||||
</div>
|
||||
<div class="statusbar update-statusbar">
|
||||
<span id="updateStatusText">Comparing the installed version with the Git repository.</span>
|
||||
<span class="hint hidden" id="updateCommandWrap">To update: <code>sudo bash /opt/sshpanel/update.sh</code> <button class="btn btn-ghost btn-xs" type="button" id="copyUpdateCommandBtn">Copy command</button></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2 dashboard-lower">
|
||||
<div class="card hidden" id="dnsttDashboardCard">
|
||||
<div class="card-hdr">
|
||||
@@ -188,6 +206,26 @@
|
||||
|
||||
<!-- ═══════════ SSH Users Tab ═══════════ -->
|
||||
<div class="tab-pane" id="tab-ssh">
|
||||
<section class="page-hero status-hero" data-tone="green">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Access workspace</span><h2>Contas SSH</h2><p>Crie, limite e acompanhe acessos SSH e SlowDNS com segurança.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status" id="sshLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="sshHeroRefreshBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Operação</small><strong id="sshMetricState">Carregando</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">◆</span><div><small>Contas</small><strong id="sshMetricTotal">0</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">✓</span><div><small>Ativas</small><strong id="sshMetricActive">0</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">↗</span><div><small>Online agora</small><strong id="sshMetricOnline">0</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="workspace-nav-shell" data-tone="green">
|
||||
<nav class="workspace-section-nav" id="sshSectionNav" aria-label="Áreas SSH e SlowDNS" style="--workspace-nav-columns:2">
|
||||
<button class="active" type="button" data-workspace="ssh" data-workspace-section="users"><span>◎</span> Usuários</button>
|
||||
<button type="button" data-workspace="ssh" data-workspace-section="create"><span>+</span> Criar usuário</button>
|
||||
</nav>
|
||||
<select id="sshSection" class="workspace-section-select" data-workspace-select="ssh" aria-label="Área SSH e SlowDNS">
|
||||
<option value="users">Usuários</option><option value="create">Criar usuário</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Reseller info card (visible to resellers only) -->
|
||||
<div id="resellerInfoCard" class="card hidden" style="margin-bottom:12px;">
|
||||
@@ -220,7 +258,8 @@
|
||||
<div class="hint" id="sshServerHint">Servers with SSH enabled are available here.</div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section class="workspace-section active" data-workspace-panel="ssh" data-workspace-section-panel="users">
|
||||
<div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Usuários SSH e SlowDNS</h3><p>Consulte conexões, limites, validade e ações de cada conta.</p></div></div>
|
||||
<!-- Users list -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -241,16 +280,17 @@
|
||||
<tbody id="usersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="ssh" data-workspace-section-panel="create">
|
||||
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3 id="userFormHeading">Criar usuário</h3><p>Defina autenticação, validade, conexões e velocidade em uma tela dedicada.</p></div></div>
|
||||
<!-- Create / edit user form -->
|
||||
<div class="card">
|
||||
<div class="card workspace-form-card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Create / update user</div>
|
||||
<button class="btn btn-ghost btn-sm" id="toggleFormBtn">Show form</button>
|
||||
<div class="card-title" id="userFormTitle">Criar usuário SSH</div>
|
||||
</div>
|
||||
<div id="userFormWrap" class="collapsible collapsed">
|
||||
<div id="userFormWrap">
|
||||
<form id="userForm">
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="fUsername" required autocomplete="off"/></div>
|
||||
@@ -278,11 +318,49 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="statusbar workspace-section-status"><span id="userStatus">Ready.</span><span id="lastReload"></span></div>
|
||||
</div><!-- /tab-ssh -->
|
||||
|
||||
<!-- ═══════════ Xray Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-xray">
|
||||
<section class="page-hero status-hero" data-tone="purple">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Proxy studio</span><h2>Xray visual</h2><p>Gerencie clientes, inbounds e um endpoint XHTTP compartilhado sem editar JSON.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status" id="xrayChip">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="xRefreshBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid five">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Status</small><strong id="xRunning">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">↗</span><div><small>Online</small><strong id="xOnlineUsers">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>PID</small><strong id="xPID">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">◷</span><div><small>Uptime</small><strong id="xUptime">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">API</span><div><small>Contadores</small><strong id="xStatsConfig">--</strong></div></article>
|
||||
</div>
|
||||
<div class="workspace-hero-toolbar">
|
||||
<span id="xStatus" class="workspace-toolbar-status">Pronto.</span>
|
||||
<div class="workspace-toolbar-actions xray-admin-only">
|
||||
<select id="xCoreMode" class="input-sm" title="Xray runtime mode"><option value="native">Internal native emulator</option><option value="external">External xray binary</option></select>
|
||||
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Salvar modo</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xStartBtn">Iniciar</button>
|
||||
<button class="btn btn-danger btn-sm" id="xStopBtn">Parar</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Reiniciar</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRepairStatsBtn">Reparar contadores</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="workspace-nav-shell" data-tone="purple">
|
||||
<nav class="workspace-section-nav" id="xraySectionNav" aria-label="Áreas do Xray" style="--workspace-nav-columns:4">
|
||||
<button class="active" type="button" data-workspace="xray" data-workspace-section="users"><span>◎</span> Usuários</button>
|
||||
<button type="button" data-workspace="xray" data-workspace-section="create"><span>+</span> Criar usuário</button>
|
||||
<button class="xray-admin-only" type="button" data-workspace="xray" data-workspace-section="config"><span>⚙</span> Configuração</button>
|
||||
<button class="xray-admin-only" type="button" data-workspace="xray" data-workspace-section="logs"><span>☰</span> Logs</button>
|
||||
</nav>
|
||||
<select id="xraySection" class="workspace-section-select" data-workspace-select="xray" aria-label="Área do Xray">
|
||||
<option value="users">Usuários</option><option value="create">Criar usuário</option>
|
||||
<option class="xray-admin-only" value="config">Configuração</option><option class="xray-admin-only" value="logs">Logs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<section class="workspace-section active" data-workspace-panel="xray" data-workspace-section-panel="users">
|
||||
<!-- Edit Client Panel (hidden by default) -->
|
||||
<div id="editXrayClientPanel" class="card hidden" style="margin-bottom:12px;border-color:rgba(245,158,11,.4);">
|
||||
<div class="card-hdr">
|
||||
@@ -301,6 +379,7 @@
|
||||
</div>
|
||||
<div id="editXrayClientStatus" class="hint" style="margin-top:4px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="card reseller-only hidden reseller-helper-card">
|
||||
<div class="card-hdr"><div class="card-title">Área do revendedor</div><span class="chip green">cota única</span></div>
|
||||
<p class="hint">Crie clientes Xray com a mesma experiência do painel principal. Cada cliente Xray desconta do mesmo limite usado pelas contas SSH.</p>
|
||||
@@ -323,35 +402,10 @@
|
||||
<div class="hint hidden" id="xrayServerHint"></div>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Xray Core <span class="chip" id="xrayChip">--</span></div>
|
||||
<div class="card-actions xray-admin-only">
|
||||
<select id="xCoreMode" class="input-sm" title="Xray runtime mode">
|
||||
<option value="native">Internal native emulator</option>
|
||||
<option value="external">External xray binary</option>
|
||||
</select>
|
||||
<button class="btn btn-ghost btn-sm" id="xSaveModeBtn">Save mode</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xStartBtn">Start</button>
|
||||
<button class="btn btn-danger btn-sm" id="xStopBtn">Stop</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRestartBtn">Restart</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRepairStatsBtn">Repair counters</button>
|
||||
<button class="btn btn-ghost btn-sm" id="xRefreshBtn">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="m-label">Status</div><div class="m-val" id="xRunning">--</div></div>
|
||||
<div class="metric"><div class="m-label">Online</div><div class="m-val" id="xOnlineUsers">--</div></div>
|
||||
<div class="metric"><div class="m-label">PID</div><div class="m-val" id="xPID">--</div></div>
|
||||
<div class="metric"><div class="m-label">Uptime</div><div class="m-val" id="xUptime">--</div></div>
|
||||
<div class="metric"><div class="m-label">Counters API</div><div class="m-val" id="xStatsConfig">--</div></div>
|
||||
</div>
|
||||
<div class="statusbar"><span id="xStatus">Ready.</span></div>
|
||||
</div>
|
||||
|
||||
<section class="workspace-section active" data-workspace-panel="xray" data-workspace-section-panel="users">
|
||||
<div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Usuários Xray</h3><p>Consulte clientes, conexões, validade e tráfego separados por inbound.</p></div><button class="btn btn-sm" id="xrayOpenCreateBtn" type="button">Criar usuário</button></div>
|
||||
<!-- Inbounds & clients -->
|
||||
<div class="card" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Inbounds & Clients</div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadInboundsBtn">Reload</button></div>
|
||||
@@ -360,9 +414,31 @@
|
||||
<div class="hint" style="padding:8px 0;">Loading inbounds…</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="xray" data-workspace-section-panel="create">
|
||||
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3>Criar usuário Xray</h3><p>Escolha o inbound e cadastre o cliente sem abrir a tabela de usuários.</p></div></div>
|
||||
<div class="card workspace-form-card">
|
||||
<div class="card-hdr"><div class="card-title">Novo cliente Xray</div><span class="chip" id="xCreateProtocolChip">inbound</span></div>
|
||||
<form id="xCreateClientForm">
|
||||
<div class="form-grid">
|
||||
<div class="field" style="grid-column:1/-1"><label>Inbound</label><select id="xCreateInbound" required><option value="">Carregando inbounds…</option></select><span class="hint" id="xCreateInboundHint">Selecione onde o cliente será adicionado.</span></div>
|
||||
<div class="field"><label>UUID</label><div class="field-row"><input id="xCreateUUID" required autocomplete="off" placeholder="Gerado automaticamente"/><button class="btn btn-ghost btn-sm" id="xCreateUUIDBtn" type="button">Gerar</button></div></div>
|
||||
<div class="field"><label>Nome de exibição</label><input id="xCreateName" autocomplete="off" placeholder="Ex.: Cliente 01"/></div>
|
||||
<div class="field"><label>Email / identificação</label><input id="xCreateEmail" autocomplete="off" placeholder="cliente@example"/></div>
|
||||
<div class="field"><label>Expira em</label><input id="xCreateExpiry" type="datetime-local"/></div>
|
||||
<div class="field"><label>Máximo de conexões <span class="hint">0 = ilimitado</span></label><input id="xCreateMaxConns" type="number" min="0" value="0"/></div>
|
||||
</div>
|
||||
<div class="form-actions"><button class="btn" id="xCreateClientBtn" type="submit">Criar usuário</button><button class="btn btn-ghost" id="xCreateCancelBtn" type="button">Voltar aos usuários</button></div>
|
||||
<div class="statusbar"><span id="xCreateClientStatus">Preencha os dados do novo cliente.</span></div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section xray-admin-only" data-workspace-panel="xray" data-workspace-section-panel="config">
|
||||
<div class="workspace-section-heading"><div><span>03 · Serviço</span><h3>Configuração Xray</h3><p>Edite endpoint, inbounds, TLS e opções avançadas visualmente ou em JSON.</p></div></div>
|
||||
<!-- Config editor -->
|
||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Xray Config</div>
|
||||
<div class="card-actions">
|
||||
@@ -372,7 +448,38 @@
|
||||
</div>
|
||||
<!-- Wizard pane -->
|
||||
<div id="xrayWizardPane">
|
||||
<div class="form-grid" style="margin-bottom:8px;">
|
||||
<section class="shared-endpoint-card">
|
||||
<div class="shared-endpoint-head">
|
||||
<div><span class="page-kicker">Shared XHTTP endpoint</span><h3>Um domínio e uma porta</h3><p>O protocolo selecionado usa <code>/</code>; SSH usa <code>/ssh</code>. Disponível no modo Xray nativo.</p></div>
|
||||
<span class="chip green">path routing</span>
|
||||
</div>
|
||||
<div class="shared-route-preview" aria-label="Shared endpoint route preview">
|
||||
<span><strong id="sharedProxyRouteLabel">VLESS</strong><code>/</code></span>
|
||||
<i></i>
|
||||
<span><strong>SSH</strong><code>/ssh</code></span>
|
||||
</div>
|
||||
<div class="form-grid shared-endpoint-grid">
|
||||
<div class="field"><label>Protocolo em /</label><select id="sharedXHTTPProtocol"><option value="vless">VLESS</option><option value="vmess">VMess</option></select></div>
|
||||
<div class="field"><label>Porta compartilhada</label><input id="sharedXHTTPPort" type="number" min="1" max="65535" value="443"/></div>
|
||||
<div class="field"><label>IP de listen</label><input id="sharedXHTTPListen" value="0.0.0.0" placeholder="0.0.0.0"/></div>
|
||||
<div class="field"><label>Host HTTP <span class="hint">opcional</span></label><input id="sharedXHTTPHost" placeholder="vpn.seudominio.com"/></div>
|
||||
<div class="field"><label>Modo XHTTP</label><select id="sharedXHTTPMode"><option value="auto">auto</option><option value="packet-up">packet-up</option><option value="stream-up">stream-up</option><option value="stream-down">stream-down</option><option value="stream-one">stream-one</option></select></div>
|
||||
<div class="field"><label>Segurança</label><select id="sharedXHTTPSecurity"><option value="none">Sem TLS</option><option value="tls">TLS</option></select></div>
|
||||
<div class="field shared-tls-field hidden"><label>Arquivo do certificado</label><input id="sharedXHTTPCert" placeholder="/opt/sshpanel/certs/domain/cert.pem"/></div>
|
||||
<div class="field shared-tls-field hidden"><label>Arquivo da chave</label><input id="sharedXHTTPKey" placeholder="/opt/sshpanel/certs/domain/key.pem"/></div>
|
||||
</div>
|
||||
<div class="shared-endpoint-actions"><span id="sharedXHTTPStatus" class="hint">Carregue a configuração para detectar um endpoint existente.</span><button class="btn" id="sharedXHTTPApplyBtn" type="button">Criar / atualizar endpoint</button></div>
|
||||
</section>
|
||||
|
||||
<aside class="legacy-xhttp-migration" aria-label="Migração de configuração XHTTP antiga">
|
||||
<span class="legacy-xhttp-icon" aria-hidden="true">SSH+</span>
|
||||
<div>
|
||||
<strong>Configuração XHTTP antiga?</strong>
|
||||
<p>Use “Ativar SSH /ssh” no cartão do inbound existente. O painel mantém o inbound, os clientes e todas as opções atuais; adiciona somente a rota SSH e reinicia o Xray.</p>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="visual-config-toolbar">
|
||||
<div class="field">
|
||||
<label>Log Level</label>
|
||||
<select id="wzLogLevel">
|
||||
@@ -383,20 +490,20 @@
|
||||
<option value="debug">debug</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="visual-config-toolbar-copy"><strong>Inbounds configurados</strong><span>Edite qualquer cartão visualmente ou use JSON para campos avançados.</span></div>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Novo inbound</button>
|
||||
</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
|
||||
<span style="font-size:.8rem;font-weight:600;">Inbounds</span>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="wzToggleAddInbound()">+ Add</button>
|
||||
</div>
|
||||
<div id="wzInboundsList" style="margin-bottom:8px;"></div>
|
||||
<div id="wzInboundsList" class="visual-inbound-list"></div>
|
||||
<!-- Add inbound form -->
|
||||
<div id="wzAddInboundForm" class="hidden" style="border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px;">
|
||||
<div id="wzAddInboundForm" class="visual-inbound-editor hidden">
|
||||
<div class="visual-editor-heading"><div><span class="page-kicker">Visual editor</span><h3 id="wzInboundFormTitle">Novo inbound</h3></div><span id="wzEditingBadge" class="chip hidden">editing</span></div>
|
||||
<div class="form-grid">
|
||||
<div class="field">
|
||||
<label>Protocol</label>
|
||||
<select id="wzProtocol" onchange="onWzProtoChange(this.value)">
|
||||
<option value="vless">VLESS</option>
|
||||
<option value="vmess">VMess</option>
|
||||
<option value="ssh">SSH Tunnel (over XHTTP)</option>
|
||||
<option value="trojan">Trojan</option>
|
||||
<option value="shadowsocks">Shadowsocks</option>
|
||||
<option value="socks">SOCKS5 (local)</option>
|
||||
@@ -423,7 +530,7 @@
|
||||
<div class="field" id="wzWSPathField" style="display:none;"><label>Path</label><input type="text" id="wzWSPath" placeholder="/ws"/></div>
|
||||
<!-- XHTTP -->
|
||||
<div class="field" id="wzXHTTPPathField" style="display:none;"><label>Path</label><input type="text" id="wzXHTTPPath" placeholder="/xhttp" value="/xhttp"/></div>
|
||||
<div class="field" id="wzXHTTPHostField" style="display:none;"><label>Host <span class="hint">(SNI)</span></label><input type="text" id="wzXHTTPHost" placeholder="example.com"/></div>
|
||||
<div class="field" id="wzXHTTPHostField" style="display:none;"><label>HTTP Host <span class="hint">(separate from TLS SNI)</span></label><input type="text" id="wzXHTTPHost" placeholder="example.com"/></div>
|
||||
<div class="field" id="wzXHTTPModeField" style="display:none;">
|
||||
<label>Mode</label>
|
||||
<select id="wzXHTTPMode">
|
||||
@@ -515,13 +622,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="wzSaveInbound()">Add Inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" type="button" onclick="document.getElementById('wzAddInboundForm').classList.add('hidden')">Cancel</button>
|
||||
<button class="btn btn-sm" id="wzSaveInboundBtn" type="button" onclick="wzSaveInbound()">Adicionar inbound</button>
|
||||
<button class="btn btn-ghost btn-sm" id="wzCancelInboundBtn" type="button" onclick="wzCancelInbound()">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-actions" style="margin-top:8px;border-top:1px solid var(--border);padding-top:8px;">
|
||||
<button class="btn btn-sm" type="button" onclick="applyWizardConfig()">Save Config</button>
|
||||
<span class="hint" id="wzStatus" style="margin-left:8px;"></span>
|
||||
<div class="visual-save-bar">
|
||||
<span class="hint" id="wzStatus"></span>
|
||||
<button class="btn" type="button" onclick="applyWizardConfig()">Salvar configuração e reiniciar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- JSON pane (hidden by default) -->
|
||||
@@ -534,20 +641,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section xray-admin-only" data-workspace-panel="xray" data-workspace-section-panel="logs">
|
||||
<div class="workspace-section-heading"><div><span>04 · Diagnóstico</span><h3>Logs do Xray</h3><p>Acompanhe as últimas mensagens do serviço em uma tela focada.</p></div></div>
|
||||
<!-- Logs -->
|
||||
<div class="card xray-admin-only" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Logs <span class="chip">last 200 lines</span></div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="xLoadLogsBtn">Refresh</button></div>
|
||||
</div>
|
||||
<pre class="log-box" id="xLogsBox"></pre>
|
||||
</div>
|
||||
</section>
|
||||
</div><!-- /tab-xray -->
|
||||
|
||||
<!-- ═══════════ Resellers Tab (superadmin only) ═══════════ -->
|
||||
<!-- ═══════════ Hierarchical reseller management ═══════════ -->
|
||||
<div class="tab-pane" id="tab-resellers">
|
||||
<div class="grid2">
|
||||
<section class="page-hero status-hero" data-tone="amber">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Partner operations</span><h2>Revendedores</h2><p>Controle hierarquia, créditos, cotas, validade e acesso dos parceiros em um só lugar.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status is-loading" id="resellerLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="resellerHeroReloadBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">◎</span><div><small>Revendedores</small><strong id="resellerMetricTotal">--</strong><span class="workspace-card-note">parceiros diretos</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Ativos</small><strong id="resellerMetricActive">--</strong><span class="workspace-card-note">acesso liberado</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">#</span><div><small>Alocação</small><strong id="resellerMetricAllocated">--</strong><span class="workspace-card-note">slots reservados</span></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">Cr</span><div><small>Créditos</small><strong id="resellerMetricCredits">--</strong><span class="workspace-card-note">saldo nos parceiros</span></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="workspace-nav-shell" data-tone="amber">
|
||||
<nav class="workspace-section-nav" id="resellerSectionNav" aria-label="Áreas de revendedores" style="--workspace-nav-columns:3">
|
||||
<button class="active" type="button" data-workspace="resellers" data-workspace-section="users"><span>◎</span> Revendedores</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="create"><span>+</span> Criar revendedor</button>
|
||||
<button type="button" data-workspace="resellers" data-workspace-section="audit"><span>☰</span> Atividade</button>
|
||||
</nav>
|
||||
<select id="resellerSection" class="workspace-section-select" data-workspace-select="resellers" aria-label="Área de revendedores">
|
||||
<option value="users">Revendedores</option><option value="create">Criar revendedor</option><option value="audit">Atividade</option>
|
||||
</select>
|
||||
</div>
|
||||
<section class="workspace-section active" data-workspace-panel="resellers" data-workspace-section-panel="users">
|
||||
<div class="workspace-section-heading"><div><span>01 · Gestão</span><h3>Revendedores cadastrados</h3><p>Consulte cotas, consumo compartilhado, validade e situação de cada parceiro.</p></div></div>
|
||||
<!-- Resellers list -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -560,16 +692,18 @@
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Username</th><th>Users (used/max)</th><th>Expires</th><th>Status</th><th>Actions</th>
|
||||
<th>Conta</th><th>Plano e uso</th><th>Validade</th><th>Status</th><th>Ações</th>
|
||||
</tr></thead>
|
||||
<tbody id="resellersBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="statusbar"><span id="resellerStatus">Ready.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="create">
|
||||
<div class="workspace-section-heading"><div><span>02 · Cadastro</span><h3 id="resellerFormHeading">Criar revendedor</h3><p>Defina login, limite compartilhado, validade e acesso em uma tela dedicada.</p></div></div>
|
||||
<!-- Create / edit reseller form -->
|
||||
<div class="card">
|
||||
<div class="card workspace-form-card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title" id="resellerFormTitle">Create Reseller</div>
|
||||
<button class="btn btn-ghost btn-sm" id="cancelResellerBtn">Cancel</button>
|
||||
@@ -578,26 +712,49 @@
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Username</label><input id="rUsername" required autocomplete="off"/></div>
|
||||
<div class="field"><label>Password <span class="hint">(blank = keep)</span></label><input id="rPassword" type="password" autocomplete="new-password"/></div>
|
||||
<div class="field"><label>Max SSH users (0 = unlimited)</label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>Active</label><input id="rActive" type="checkbox" checked style="width:16px;height:16px;margin-top:10px;"/></div>
|
||||
<div class="field superadmin-only" id="rParentField"><label>Revendedor pai <span class="hint">(vazio = principal)</span></label><select id="rParent"><option value="">Principal / sem pai</option></select></div>
|
||||
<div class="field"><label>Modo do plano</label><select id="rQuotaMode"><option value="slots">Validade / slots</option><option value="credits">Créditos</option></select><span class="hint" id="rQuotaModeHint">Sub-revendedores herdam o modo da conta pai.</span></div>
|
||||
<div class="field" id="rSlotsField"><label>Limite compartilhado <span class="hint">(0 = ilimitado só para principal)</span></label><input id="rMaxUsers" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field hidden" id="rCreditsField"><label>Saldo de créditos</label><input id="rCredits" type="number" min="0" placeholder="30"/></div>
|
||||
<div class="field" id="rExpiresField"><label>Expires at</label><input id="rExpires" type="datetime-local"/></div>
|
||||
<div class="field"><label>WhatsApp</label><input id="rWhatsApp" autocomplete="tel" placeholder="+5511999999999"/></div>
|
||||
<div class="field"><label>Valor mensal</label><input id="rMonthlyPrice" type="number" min="0" step="0.01" placeholder="0,00"/></div>
|
||||
<label class="bot-check-field"><input id="rActive" type="checkbox" checked/> Acesso ativo</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="btn" type="submit" id="saveResellerBtn">Save reseller</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="resellers" data-workspace-section-panel="audit">
|
||||
<div class="workspace-section-heading"><div><span>03 · Auditoria</span><h3>Atividade das revendas</h3><p>Veja quem criou, alterou, renovou, suspendeu ou removeu cada conta.</p></div><button class="btn btn-ghost btn-sm" id="reloadResellerAuditBtn" type="button">Atualizar</button></div>
|
||||
<div class="card">
|
||||
<div class="tbl-wrap"><table class="reseller-audit-table"><thead><tr><th>Quando</th><th>Responsável</th><th>Revendedor</th><th>Ação</th><th>Detalhes</th></tr></thead><tbody id="resellerAuditBody"></tbody></table></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="statusbar workspace-section-status"><span id="resellerStatus">Ready.</span></div>
|
||||
</div><!-- /tab-resellers -->
|
||||
|
||||
<!-- ═══════════ Servers Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers">
|
||||
<section class="page-hero status-hero" data-tone="blue">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Fleet control</span><h2>Infraestrutura</h2><p>Gerencie nós, acompanhe a saúde do sistema e consulte tráfego em um único espaço.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status" id="fleetLiveStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="refreshServersBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">▣</span><div><small>Nós</small><strong id="fleetMetricNodes">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Ativos</small><strong id="fleetMetricActive">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">SSH</span><div><small>SSH habilitado</small><strong id="fleetMetricSSH">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">◇</span><div><small>Xray habilitado</small><strong id="fleetMetricXray">--</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
<div id="serversListView">
|
||||
<div class="grid2 servers-grid">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Managed servers <span class="chip" id="serversCountChip">0</span></div>
|
||||
<div class="card-actions"><button class="btn btn-ghost btn-sm" id="refreshServersBtn" type="button">Reload</button></div>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
@@ -669,6 +826,7 @@
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Default Upload Limit (Mbps)</label><input type="number" id="managedCfgLimitUp" min="0" placeholder="0"/></div>
|
||||
<div class="field"><label>Default Download Limit (Mbps)</label><input type="number" id="managedCfgLimitDown" min="0" placeholder="0"/></div>
|
||||
<div class="field"><label>SSH Idle Timeout <span class="hint">0s/off = disabled</span></label><input type="text" id="managedCfgSSHIdleTimeout" placeholder="0s" title="Keep disabled for VPN/XHTTP connections."/></div>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgQuiet"/> Quiet Logs</label>
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;"><input type="checkbox" id="managedCfgUserCount"/> User Count Display</label>
|
||||
</div>
|
||||
@@ -818,38 +976,33 @@
|
||||
|
||||
<!-- ═══════════ Servers Status Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-servers-status">
|
||||
<div class="card servers-status-toolbar">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Servers Status <span class="chip" id="serversStatusCountChip">0</span></div>
|
||||
<div class="card-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="refreshServersStatusBtn" type="button">Refresh</button>
|
||||
</div>
|
||||
<section class="page-hero status-hero" data-tone="green">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Live fleet</span><h2>Status dos servidores</h2><p>Saúde, carga e sessões ativas de cada nó gerenciado.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status" id="serversStatusPageStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="refreshServersStatusBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon blue">▣</span><div><small>Total</small><strong id="serversStatusCountChip">0</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">●</span><div><small>Online</small><strong id="fleetStatusOnline">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon red">●</span><div><small>Offline</small><strong id="fleetStatusOffline">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">↗</span><div><small>Sessões</small><strong id="fleetStatusSessions">--</strong></div></article>
|
||||
</div>
|
||||
<div class="statusbar">
|
||||
<span id="serversStatusPageStatus">Ready.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
<div class="servers-status-grid" id="serversStatusGrid"></div>
|
||||
</div><!-- /tab-servers-status -->
|
||||
|
||||
<!-- ═══════════ Stats Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-stats">
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-hdr"><div class="card-title">Server Load</div><span class="hint" id="statsUpdated">--</span></div>
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="m-label">CPU</div>
|
||||
<div class="m-val"><span id="cpuVal">--%</span></div>
|
||||
<div class="bar"><div class="bar-inner" id="cpuBar" style="width:0%"></div></div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="m-label">RAM</div>
|
||||
<div class="m-val"><span id="memVal">--%</span> <span class="hint" id="memDetail"></span></div>
|
||||
<div class="bar"><div class="bar-inner" id="memBar" style="width:0%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="page-hero status-hero" data-tone="cyan">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Observability</span><h2>Monitoramento</h2><p>Recursos, interfaces e capacidade do servidor em tempo real.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status is-ok" id="statsUpdated">Atualização ao vivo</span><button class="btn btn-ghost btn-sm" id="refreshStatsBtn" type="button">Atualizar</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card has-meter"><span class="workspace-overview-icon cyan">CPU</span><div><small>Processador</small><strong id="cpuVal">--%</strong><div class="workspace-mini-meter"><span id="cpuBar"></span></div></div></article>
|
||||
<article class="workspace-overview-card has-meter"><span class="workspace-overview-icon purple">RAM</span><div><small>Memória</small><strong id="memVal">--%</strong><span class="workspace-card-note" id="memDetail">--</span><div class="workspace-mini-meter"><span id="memBar"></span></div></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">⇅</span><div><small>Rede agora</small><strong id="statsNetVal">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">#</span><div><small>Interfaces</small><strong id="statsIfaceVal">--</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">Interfaces <span class="chip">rx/tx Mbps</span><span class="chip warn">30-day rolling</span></div>
|
||||
@@ -863,26 +1016,21 @@
|
||||
</div>
|
||||
<div class="statusbar"><span id="ifaceSummary"></span><span class="hint">Totals can be cleaned here and auto-clean every 30 days. VnStat history is separate.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-stats -->
|
||||
|
||||
<!-- ═══════════ VnStat Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-vnstat">
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">VnStat Usage <span class="chip">daily / monthly</span></div>
|
||||
<div class="form-actions" style="margin-top:0">
|
||||
<button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Refresh</button>
|
||||
<button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Clean VnStat history</button>
|
||||
</div>
|
||||
<section class="page-hero status-hero" data-tone="purple">
|
||||
<div class="page-hero-copy"><span class="page-kicker">Traffic intelligence</span><h2>Tráfego de rede</h2><p>Histórico diário e mensal para entender o consumo da infraestrutura.</p></div>
|
||||
<div class="workspace-hero-actions"><span class="workspace-live-status" id="vnstatStatus">Aguardando dados</span><button class="btn btn-ghost btn-sm" id="reloadVnstatBtn" type="button">Atualizar</button><button class="btn btn-danger btn-sm" id="resetVnstatBtn" type="button">Limpar histórico</button></div>
|
||||
<div class="workspace-overview-grid">
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon cyan">D</span><div><small>Hoje</small><strong id="vnTodayTotal">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon purple">M</span><div><small>Este mês</small><strong id="vnMonthTotal">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon green">⇅</span><div><small>Interfaces</small><strong id="vnIfaceCount">--</strong></div></article>
|
||||
<article class="workspace-overview-card"><span class="workspace-overview-icon amber">◷</span><div><small>Período recente</small><strong id="vnLatestPeriod">--</strong></div></article>
|
||||
</div>
|
||||
<div class="metrics">
|
||||
<div class="metric"><div class="m-label">Today total</div><div class="m-val" id="vnTodayTotal">--</div></div>
|
||||
<div class="metric"><div class="m-label">This month total</div><div class="m-val" id="vnMonthTotal">--</div></div>
|
||||
<div class="metric"><div class="m-label">Interfaces tracked</div><div class="m-val" id="vnIfaceCount">--</div></div>
|
||||
</div>
|
||||
<div class="statusbar"><span id="vnstatStatus">VnStat history does not auto-clean. Use the button when you want to reset it.</span></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="infra-nav-mount"></div>
|
||||
|
||||
<div class="grid2" style="margin-top:12px;">
|
||||
<div class="card">
|
||||
@@ -908,6 +1056,7 @@
|
||||
|
||||
<!-- ═══════════ Logs Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-logs">
|
||||
<section class="page-hero" data-tone="amber"><div class="page-hero-copy"><span class="page-kicker">Diagnostics</span><h2>Logs do sistema</h2><p>Investigue painel, DNSTT e Xray com uma visualização focada.</p></div><div class="page-hero-pills"><span>Panel</span><span>DNSTT</span><span>Xray</span></div></section>
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">System Logs</div>
|
||||
@@ -926,12 +1075,161 @@
|
||||
</div>
|
||||
</div><!-- /tab-logs -->
|
||||
|
||||
<!-- ═══════════ Bot / Vendas Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-bot">
|
||||
<section class="bot-hero">
|
||||
<div class="bot-hero-copy">
|
||||
<span class="bot-eyebrow">Central de automação</span>
|
||||
<h2>Bot de vendas</h2>
|
||||
<p>Telegram, PIX, planos e clientes em um único fluxo.</p>
|
||||
</div>
|
||||
<div class="bot-hero-actions">
|
||||
<span id="botConfigStatus" class="bot-live-status">Aguardando dados</span>
|
||||
<button class="btn btn-ghost btn-sm" id="botTestBtn" type="button">Testar integrações</button>
|
||||
<button class="btn btn-ghost btn-sm" id="botConfigReloadBtn" type="button">Atualizar tudo</button>
|
||||
</div>
|
||||
<div class="bot-overview-grid">
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon">●</span><div><small>Operação</small><strong id="botMetricState">Carregando</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-purple">◆</span><div><small>Planos</small><strong id="botMetricPlans">0</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-green">↗</span><div><small>Clientes</small><strong id="botMetricUsers">0</strong></div></article>
|
||||
<article class="bot-overview-card"><span class="bot-overview-icon bot-amber">$</span><div><small>PIX pendentes</small><strong id="botMetricPending">0</strong></div></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="bot-nav-shell">
|
||||
<nav class="bot-section-nav" id="botSectionNav" aria-label="Áreas do bot">
|
||||
<button class="active" type="button" data-bot-section="config"><span>⚙</span> Integrações</button>
|
||||
<button type="button" data-bot-section="plans"><span>◆</span> Planos</button>
|
||||
<button type="button" data-bot-section="packages"><span>▤</span> Créditos</button>
|
||||
<button type="button" data-bot-section="messages"><span>✉</span> Mensagens</button>
|
||||
<button type="button" data-bot-section="users"><span>◎</span> Clientes</button>
|
||||
<button type="button" data-bot-section="transactions"><span>$</span> Pagamentos</button>
|
||||
</nav>
|
||||
<select id="botSection" class="bot-section-select" aria-label="Área do bot">
|
||||
<option value="config">Integrações</option><option value="plans">Planos</option><option value="packages">Créditos</option>
|
||||
<option value="messages">Mensagens</option><option value="users">Clientes</option><option value="transactions">Pagamentos</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<section class="bot-section active" id="botSec-config" data-bot-panel="config">
|
||||
<div class="bot-section-heading"><div><span>01 · Integrações</span><h3>Conecte os serviços</h3><p>Tokens ficam criptografados. Campos vazios mantêm o valor atual.</p></div><button class="btn btn-sm" id="botConfigSaveBtn" type="button">Salvar e reiniciar bot</button></div>
|
||||
<div class="bot-config-grid">
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon telegram">✈</div><div><h4>Telegram</h4><p>Canal de atendimento e entrega</p></div><label class="bot-switch"><input id="botEnabled" type="checkbox"/><span></span></label></div>
|
||||
<div class="field"><label>Token do @BotFather <span class="bot-secret-state" id="botHasTgToken"></span></label><input id="botTelegramToken" type="password" autocomplete="new-password" placeholder="Em branco para manter o token salvo"/></div>
|
||||
<div class="field"><label>IDs dos administradores</label><input id="botAdminIDs" inputmode="numeric" placeholder="111111111, 222222222"/><span class="hint">Separe mais de um ID por vírgula.</span></div>
|
||||
</article>
|
||||
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon mercado">MP</div><div><h4>Mercado Pago</h4><p>Recebimentos PIX automáticos</p></div></div>
|
||||
<div class="field"><label>Access Token <span class="bot-secret-state" id="botHasMpToken"></span></label><input id="botMPToken" type="password" autocomplete="new-password" placeholder="APP_USR-… (vazio mantém o atual)"/></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Confirmação</label><select id="botMPConfirmMode"><option value="polling">Polling — recomendado</option><option value="webhook">Webhook assinado</option></select></div>
|
||||
<div class="field"><label>Intervalo</label><input id="botMPPollInterval" value="20s" placeholder="20s"/></div>
|
||||
<div class="field"><label>Validade do PIX</label><div class="bot-input-suffix"><input id="botPixExp" type="number" min="5" max="1440" value="30"/><span>min</span></div></div>
|
||||
</div>
|
||||
<div id="botMPWebhookBox" class="bot-webhook-box hidden">
|
||||
<div class="field"><label>Webhook Secret <span class="bot-secret-state" id="botHasMpSecret"></span></label><input id="botMPWebhookSecret" type="password" autocomplete="new-password" placeholder="Mínimo de 16 caracteres"/></div>
|
||||
<div class="bot-copy-row"><code id="botMPWebhookURL">https://SEU_HOST/api/mp/webhook</code><button class="btn btn-ghost btn-sm" id="botCopyWebhookBtn" type="button">Copiar</button></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon trial">◷</div><div><h4>Teste grátis</h4><p>Entrada controlada para novos clientes</p></div><label class="bot-switch"><input id="botTrialEnabled" type="checkbox"/><span></span></label></div>
|
||||
<div class="form-grid">
|
||||
<div class="field"><label>Duração</label><div class="bot-input-suffix"><input id="botTrialHours" type="number" min="1" max="720" value="1"/><span>h</span></div></div>
|
||||
<div class="field"><label>Conexões</label><input id="botTrialMaxConns" type="number" min="1" max="1000" value="1"/></div>
|
||||
<div class="field"><label>Tipo</label><select id="botTrialKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div>
|
||||
<div class="field"><label>Inbound Xray</label><input id="botTrialInbound" list="botInboundList" placeholder="Somente para Xray"/></div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="card bot-integration-card">
|
||||
<div class="bot-card-heading"><div class="bot-service-icon host">⌁</div><div><h4>Entrega</h4><p>Endereços enviados após a compra</p></div></div>
|
||||
<div class="field"><label>Host/IP para SSH</label><input id="botPublicHost" placeholder="vpn.seudominio.com ou IP"/></div>
|
||||
<div class="field"><label>Host para links Xray</label><input id="botXrayPublicHost" placeholder="xray.seudominio.com"/></div>
|
||||
<div class="bot-note">Esses endereços aparecem nas credenciais entregues ao cliente.</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-plans" data-bot-panel="plans">
|
||||
<div class="bot-section-heading"><div><span>02 · Catálogo</span><h3>Planos de acesso</h3><p>Configure preço, validade, protocolo e limites.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPlansBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPlanBtn" type="button">Novo plano</button></div></div>
|
||||
<div class="bot-master-detail">
|
||||
<article class="card"><div class="bot-list-heading"><strong>Planos cadastrados</strong><span class="chip" id="botPlanCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Plano</th><th>Entrega</th><th>Validade</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPlansBody"></tbody></table></div><div class="statusbar"><span id="botPlansStatus">Aguardando.</span></div></article>
|
||||
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title" id="botPlanFormTitle">Novo plano</div><p class="hint">Defina como a conta será criada.</p></div><button class="btn btn-ghost btn-sm" id="botCancelPlanBtn" type="button">Limpar</button></div>
|
||||
<form id="botPlanForm"><input type="hidden" id="planId"/><div class="form-grid">
|
||||
<div class="field bot-span-2"><label>Nome do plano</label><input id="planName" maxlength="120" required placeholder="Ex.: Premium 30 dias"/></div>
|
||||
<div class="field"><label>Tipo</label><select id="planKind"><option value="ssh">SSH</option><option value="xray">Xray</option></select></div><div class="field"><label>Dias</label><input id="planDays" type="number" min="1" max="3650" value="30"/></div>
|
||||
<div class="field"><label>Máx. conexões</label><input id="planMaxConns" type="number" min="0" max="10000" value="1"/></div><div class="field"><label>Preço (R$)</label><input id="planPrice" type="number" step="0.01" min="0" value="0"/></div>
|
||||
<div class="field"><label>Upload Mbps</label><input id="planUpMbps" type="number" min="0" value="0"/></div><div class="field"><label>Download Mbps</label><input id="planDownMbps" type="number" min="0" value="0"/></div>
|
||||
<div class="field"><label>Inbound Xray</label><input id="planInbound" list="botInboundList" placeholder="tag do inbound"/><datalist id="botInboundList"></datalist></div><div class="field"><label>Protocolo Xray</label><select id="planProtocol"><option value="">Automático</option><option value="vless">VLESS</option><option value="vmess">VMess</option><option value="trojan">Trojan</option></select></div>
|
||||
<div class="field"><label>Custo em créditos</label><input id="planCreditCost" type="number" min="0" value="1"/></div><div class="field"><label>Servidor</label><input id="planServerId" placeholder="Vazio = local"/></div>
|
||||
<div class="field"><label>Ordem</label><input id="planSort" type="number" value="0"/></div><label class="bot-check-field"><input id="planActive" type="checkbox" checked/><span>Plano visível no bot</span></label>
|
||||
</div><div class="form-actions"><button class="btn" type="submit">Salvar plano</button></div></form>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-packages" data-bot-panel="packages">
|
||||
<div class="bot-section-heading"><div><span>03 · Revendedores</span><h3>Pacotes de créditos</h3><p>Crie recargas para revendedores comprarem planos.</p></div><div class="card-actions"><button class="btn btn-ghost btn-sm" id="botReloadPkgsBtn" type="button">Atualizar</button><button class="btn btn-sm" id="botNewPkgBtn" type="button">Novo pacote</button></div></div>
|
||||
<div class="bot-master-detail">
|
||||
<article class="card"><div class="bot-list-heading"><strong>Pacotes cadastrados</strong><span class="chip" id="botPkgCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pacote</th><th>Créditos</th><th>Preço</th><th>Status</th><th></th></tr></thead><tbody id="botPkgsBody"></tbody></table></div><div class="statusbar"><span id="botPkgStatus">Aguardando.</span></div></article>
|
||||
<article class="card bot-editor-card"><div class="card-hdr"><div><div class="card-title">Editar pacote</div><p class="hint">Defina o saldo entregue e o preço PIX.</p></div></div><form id="botPkgForm"><input type="hidden" id="pkgId"/><div class="form-grid">
|
||||
<div class="field bot-span-2"><label>Nome</label><input id="pkgName" maxlength="120" required placeholder="Ex.: Recarga 50 créditos"/></div><div class="field"><label>Créditos</label><input id="pkgCredits" type="number" min="1" value="10"/></div><div class="field"><label>Preço (R$)</label><input id="pkgPrice" type="number" step="0.01" min="0" value="0"/></div><div class="field"><label>Ordem</label><input id="pkgSort" type="number" value="0"/></div><label class="bot-check-field"><input id="pkgActive" type="checkbox" checked/><span>Pacote visível</span></label>
|
||||
</div><div class="form-actions"><button class="btn" type="submit">Salvar pacote</button><button class="btn btn-ghost" id="botClearPkgBtn" type="button">Limpar</button></div></form></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-messages" data-bot-panel="messages">
|
||||
<div class="bot-section-heading"><div><span>04 · Conteúdo</span><h3>Mensagens do bot</h3><p>Personalize os textos vistos pelo cliente.</p></div><button class="btn btn-ghost btn-sm" id="botReloadSettingsBtn" type="button">Restaurar salvo</button></div>
|
||||
<article class="card bot-message-editor"><div class="bot-message-grid"><div class="field"><label>Mensagem de boas-vindas</label><textarea id="setWelcome" rows="7" maxlength="4096" placeholder="Olá, {name}! Escolha uma opção abaixo."></textarea><span class="hint">Use <code>{name}</code> para inserir o primeiro nome.</span></div><div class="field"><label>Contato e suporte</label><textarea id="setContact" rows="7" maxlength="4096" placeholder="Explique como falar com o suporte."></textarea></div><div class="field"><label>Texto para baixar o app</label><textarea id="setAppText" rows="5" maxlength="4096" placeholder="Baixe nosso aplicativo para conectar."></textarea></div><div class="field"><label>Link do aplicativo</label><input id="setAppUrl" type="url" maxlength="2048" placeholder="https://..."/><span class="hint">Somente links HTTP ou HTTPS.</span></div></div><div class="bot-save-row"><span id="botSettingsStatus" class="hint">Aguardando.</span><button class="btn" id="botSaveSettingsBtn" type="button">Salvar mensagens</button></div></article>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-users" data-bot-panel="users">
|
||||
<div class="bot-section-heading"><div><span>05 · Relacionamento</span><h3>Clientes do Telegram</h3><p>Gerencie funções, vínculos e saldo sem sair da lista.</p></div><button class="btn btn-ghost btn-sm" id="botReloadUsersBtn" type="button">Atualizar clientes</button></div>
|
||||
<article class="card"><div class="bot-list-heading"><div><strong>Base de clientes</strong><span class="hint"> Ações administrativas são registradas no livro de créditos.</span></div><span class="chip" id="botUserCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Cliente</th><th>Telegram ID</th><th>Função</th><th>Revenda vinculada</th><th>Saldo</th><th></th></tr></thead><tbody id="botUsersBody"></tbody></table></div><div class="statusbar"><span id="botUsersStatus">Aguardando.</span></div></article>
|
||||
</section>
|
||||
|
||||
<section class="bot-section" id="botSec-transactions" data-bot-panel="transactions">
|
||||
<div class="bot-section-heading"><div><span>06 · Financeiro</span><h3>Pagamentos PIX</h3><p>Acompanhe confirmações e reprocessamentos.</p></div><div class="card-actions"><select id="botTxnFilter" class="input-sm"><option value="">Todos os status</option><option value="pending">Pendentes</option><option value="approved">Aprovados</option><option value="expired">Expirados</option><option value="refunded">Marcados como estornados</option><option value="error">Com erro</option></select><button class="btn btn-ghost btn-sm" id="botReloadTxnsBtn" type="button">Atualizar</button></div></div>
|
||||
<article class="card"><div class="bot-list-heading"><strong>Histórico recente</strong><span class="chip" id="botTxnCount">0</span></div><div class="tbl-wrap"><table class="bot-table"><thead><tr><th>Pagamento</th><th>Cliente</th><th>Tipo</th><th>Valor</th><th>Status</th><th>Entrega</th><th>Data</th><th></th></tr></thead><tbody id="botTxnsBody"></tbody></table></div><div class="statusbar"><span id="botTxnStatus">Aguardando.</span></div></article>
|
||||
</section>
|
||||
|
||||
<div class="bot-modal hidden" id="botUserActionModal" role="dialog" aria-modal="true" aria-labelledby="botUserActionTitle">
|
||||
<div class="bot-modal-backdrop" data-bot-modal-close></div>
|
||||
<div class="bot-modal-card">
|
||||
<div class="card-hdr"><div><div class="card-title" id="botUserActionTitle">Editar cliente</div><p class="hint" id="botUserActionSubtitle"></p></div><button class="btn btn-ghost btn-sm" type="button" data-bot-modal-close>Fechar</button></div>
|
||||
<form id="botUserActionForm"><input type="hidden" id="botActionTelegramID"/><input type="hidden" id="botActionMode"/>
|
||||
<div id="botRoleFields"><div class="field"><label>Função</label><select id="botActionRole"><option value="customer">Cliente</option><option value="reseller">Revendedor</option><option value="blocked">Bloqueado</option></select></div><div class="field" id="botActionLinkedField"><label>Usuário da revenda vinculada</label><input id="botActionLinked" placeholder="username em Revendedores"/></div></div>
|
||||
<div id="botCreditFields" class="hidden"><div class="field"><label>Ajuste de créditos</label><input id="botActionCredits" type="number" placeholder="Ex.: 10 ou -5"/><span class="hint">Use número negativo para remover saldo.</span></div></div>
|
||||
<div class="form-actions"><button class="btn" type="submit">Confirmar alteração</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /tab-bot -->
|
||||
|
||||
<!-- ═══════════ Server Config Tab (superadmin only) ═══════════ -->
|
||||
<div class="tab-pane" id="tab-server">
|
||||
<section class="page-hero" data-tone="blue"><div class="page-hero-copy"><span class="page-kicker">System studio</span><h2>Configurações</h2><p>Rede, SSH, túneis e TLS organizados em blocos visuais e aplicados ao vivo.</p></div></section>
|
||||
|
||||
<div class="grid2">
|
||||
<!-- ── Left column ── -->
|
||||
<div>
|
||||
<div class="workspace-nav-shell" data-tone="blue">
|
||||
<nav class="workspace-section-nav" id="configSectionNav" aria-label="Áreas de configuração" style="--workspace-nav-columns:5">
|
||||
<button class="active" type="button" data-workspace="config" data-workspace-section="general"><span>⌁</span> Rede e SSH</button>
|
||||
<button type="button" data-workspace="config" data-workspace-section="slowdns"><span>DNS</span> SlowDNS</button>
|
||||
<button type="button" data-workspace="config" data-workspace-section="udp"><span>⇅</span> UDP Gateway</button>
|
||||
<button type="button" data-workspace="config" data-workspace-section="tls"><span>◇</span> TLS</button>
|
||||
<button type="button" data-workspace="config" data-workspace-section="xray"><span>◆</span> Xray</button>
|
||||
</nav>
|
||||
<select id="configSection" class="workspace-section-select" data-workspace-select="config" aria-label="Área de configuração">
|
||||
<option value="general">Rede e SSH</option><option value="slowdns">SlowDNS</option><option value="udp">UDP Gateway</option><option value="tls">TLS</option><option value="xray">Xray</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="settings-workspace">
|
||||
<section class="workspace-section active" data-workspace-panel="config" data-workspace-section-panel="general">
|
||||
<div class="workspace-section-heading"><div><span>01 · Base</span><h3>Rede e SSH</h3><p>Configure listeners, limites padrão, tempo ocioso e o banner de conexão.</p></div></div>
|
||||
<div class="settings-panel-grid">
|
||||
|
||||
<!-- Network -->
|
||||
<div class="card">
|
||||
@@ -976,6 +1274,14 @@
|
||||
<label>Default Download Limit (Mbps)</label>
|
||||
<input type="number" id="cfgLimitDown" min="0" placeholder="0 = unlimited"/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Max Total SSH Connections <span class="hint">0 = default (10000) · -1 = unlimited</span></label>
|
||||
<input type="number" id="cfgMaxTotalConns" min="-1" placeholder="10000" title="Global cap across all users. New connections past this are rejected before the SSH handshake, so a surge cannot exhaust memory/CPU. Raise it if the server has resources for more; set -1 to remove the cap entirely."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>SSH Idle Timeout <span class="hint">0s/off = disabled</span></label>
|
||||
<input type="text" id="cfgSSHIdleTimeout" placeholder="0s" title="Closes an authenticated SSH connection only after this period with no bytes in either direction. Keep disabled for VPN/XHTTP connections."/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:16px;margin-top:8px;flex-wrap:wrap;">
|
||||
<label style="font-size:.73rem;display:flex;align-items:center;gap:5px;cursor:pointer;">
|
||||
@@ -988,7 +1294,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card settings-span-all" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
<div class="card-title">SSH Banner</div>
|
||||
<span class="chip green">live</span>
|
||||
@@ -1000,11 +1306,11 @@
|
||||
<div class="hint" style="margin-top:6px;">Banner file: /opt/sshpanel/banner.txt</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /left -->
|
||||
|
||||
<!-- ── Right column ── -->
|
||||
<div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="slowdns">
|
||||
<div class="workspace-section-heading"><div><span>02 · Túnel DNS</span><h3>SlowDNS / DNSTT</h3><p>Gerencie domínios, DNS local, capacidade, filas e reinício controlado.</p></div></div>
|
||||
<!-- DNSTT -->
|
||||
<div class="card">
|
||||
<div class="card-hdr">
|
||||
@@ -1086,6 +1392,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="udp">
|
||||
<div class="workspace-section-heading"><div><span>03 · UDP</span><h3>UDP Gateway</h3><p>Defina listener, capacidade, expiração de mapa e reinício do serviço.</p></div></div>
|
||||
<!-- UDPGW -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1104,6 +1414,10 @@
|
||||
<label>Max UDP Sessions Per Client <span class="hint">(not total server users)</span></label>
|
||||
<input type="number" id="cfgUdpgwMaxConns" min="0" placeholder="10" title="Per connected client. This is not the total number of UDPGW users allowed on the server."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Max Total Clients <span class="hint">0 = default (10000)</span></label>
|
||||
<input type="number" id="cfgUdpgwMaxClients" min="0" placeholder="10000" title="Total concurrent UDPGW client connections allowed on the server. New clients past this are rejected. Raise it if the server has resources for more."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Idle Timeout</label>
|
||||
<input type="text" id="cfgUdpgwIdle" placeholder="2m"/>
|
||||
@@ -1127,6 +1441,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="tls">
|
||||
<div class="workspace-section-heading"><div><span>04 · Segurança</span><h3>Encaminhadores TLS</h3><p>Crie listeners TLS com certificado automático, colado ou armazenado em arquivo.</p></div></div>
|
||||
<!-- TLS Forwarders -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1177,6 +1495,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section class="workspace-section" data-workspace-panel="config" data-workspace-section-panel="xray">
|
||||
<div class="workspace-section-heading"><div><span>05 · Core</span><h3>Xray</h3><p>Ative o core, escolha o runtime e aplique os ajustes nativos seguros.</p></div></div>
|
||||
<!-- Xray -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-hdr">
|
||||
@@ -1203,8 +1525,8 @@
|
||||
</details>
|
||||
</div>
|
||||
|
||||
</div><!-- /right -->
|
||||
</div><!-- /grid2 -->
|
||||
</section>
|
||||
</div><!-- /settings-workspace -->
|
||||
|
||||
<!-- Save bar -->
|
||||
<div class="save-bar">
|
||||
@@ -1224,18 +1546,31 @@
|
||||
</div><!-- /shell -->
|
||||
</div><!-- /app -->
|
||||
|
||||
<div class="panel-dialog hidden" id="panelConfirmDialog" aria-hidden="true">
|
||||
<div class="panel-dialog-backdrop" data-panel-dialog-close></div>
|
||||
<section class="panel-dialog-card" role="dialog" aria-modal="true" aria-labelledby="panelDialogTitle" aria-describedby="panelDialogMessage">
|
||||
<div class="panel-dialog-head"><span class="panel-dialog-icon" id="panelDialogIcon" aria-hidden="true">?</span><div><span class="page-kicker" id="panelDialogEyebrow">Confirmação</span><h3 id="panelDialogTitle">Confirmar ação</h3></div></div>
|
||||
<p id="panelDialogMessage"></p>
|
||||
<div class="panel-dialog-detail hidden" id="panelDialogDetail"></div>
|
||||
<div class="panel-dialog-actions"><button class="btn btn-ghost" id="panelDialogCancelBtn" type="button">Cancelar</button><button class="btn" id="panelDialogConfirmBtn" type="button">Confirmar</button></div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="panel-toast-stack" id="panelToastStack" aria-live="polite" aria-atomic="false"></div>
|
||||
|
||||
<!-- app.js was split into ordered modules for maintainability. They are plain
|
||||
classic scripts sharing one global scope; `defer` preserves execution order,
|
||||
so behavior is identical to the old single file. Keep this load order. -->
|
||||
<script defer src="assets/js/01-core.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260704ytquicfix1"></script>
|
||||
<script defer src="assets/js/01-core.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/02-shell.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/03-ssh-users.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/04-xray.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/05-resellers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/06-servers.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/07-stats-logs.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/08-server-config.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/09-xray-wizard.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/11-update-status.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/12-bot.js?v=20260713reseller10"></script>
|
||||
<script defer src="assets/js/10-boot.js?v=20260713reseller10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -4,33 +4,50 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
QuotaModeSlots = "slots"
|
||||
QuotaModeCredit = "credits"
|
||||
sessionTTL = 12 * time.Hour
|
||||
adminBcryptCost = 12
|
||||
)
|
||||
|
||||
var adminUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
|
||||
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ParentUsername string
|
||||
QuotaMode string
|
||||
CreditBalance int
|
||||
WhatsApp string
|
||||
MonthlyPriceCents int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Session store (in-memory) ----------
|
||||
@@ -50,9 +67,11 @@ type sessionStoreT struct {
|
||||
|
||||
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)}
|
||||
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession {
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) (*AdminSession, error) {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return nil, fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
tok := hex.EncodeToString(b)
|
||||
sess := &AdminSession{
|
||||
Token: tok,
|
||||
@@ -64,7 +83,7 @@ func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession
|
||||
s.mu.Lock()
|
||||
s.m[tok] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
return sess, nil
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Get(token string) *AdminSession {
|
||||
@@ -86,6 +105,16 @@ func (s *sessionStoreT) Delete(token string) {
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) DeleteUser(userID int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for token, sess := range s.m {
|
||||
if sess.UserID == userID {
|
||||
delete(s.m, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
@@ -170,6 +199,14 @@ func sessionMiddleware(next http.Handler) http.Handler {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
// Re-check the account on every request. This immediately revokes sessions
|
||||
// after an account is suspended, expired, deleted, or has its role changed.
|
||||
u, ok := adminUsers.get(s.Username)
|
||||
if !ok || u.ID != s.UserID || u.Role != s.Role || adminAccountChainActive(s.Username) != nil {
|
||||
sessions.Delete(token)
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
@@ -194,11 +231,112 @@ func saSession(next http.Handler) http.Handler {
|
||||
|
||||
// ---------- Password hashing ----------
|
||||
|
||||
func hashAdminPassword(pw string) string {
|
||||
func legacyAdminPasswordHash(pw string) string {
|
||||
h := sha256.Sum256([]byte(pw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
func hashAdminPassword(pw string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(pw), adminBcryptCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash admin password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
// verifyAdminPassword accepts bcrypt and the legacy unsalted SHA-256 format.
|
||||
// Legacy hashes are upgraded immediately after a successful login.
|
||||
func verifyAdminPassword(storedHash, password string) (valid bool, needsUpgrade bool) {
|
||||
if strings.HasPrefix(storedHash, "$2a$") || strings.HasPrefix(storedHash, "$2b$") || strings.HasPrefix(storedHash, "$2y$") {
|
||||
if bcrypt.CompareHashAndPassword([]byte(storedHash), []byte(password)) != nil {
|
||||
return false, false
|
||||
}
|
||||
cost, err := bcrypt.Cost([]byte(storedHash))
|
||||
return true, err != nil || cost < adminBcryptCost
|
||||
}
|
||||
if len(storedHash) != sha256.Size*2 {
|
||||
return false, false
|
||||
}
|
||||
expected := legacyAdminPasswordHash(password)
|
||||
return subtle.ConstantTimeCompare([]byte(storedHash), []byte(expected)) == 1, true
|
||||
}
|
||||
|
||||
func validateAdminPassword(password string) error {
|
||||
if len(password) < 10 {
|
||||
return fmt.Errorf("password must contain at least 10 characters")
|
||||
}
|
||||
if len(password) > 1024 {
|
||||
return fmt.Errorf("password is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAdminUsername(username string) error {
|
||||
if !adminUsernamePattern.MatchString(username) {
|
||||
return fmt.Errorf("username must be 1-64 characters using letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Login throttling ----------
|
||||
|
||||
type loginAttempt struct {
|
||||
Failures int
|
||||
FirstSeen time.Time
|
||||
BlockedTo time.Time
|
||||
}
|
||||
|
||||
type loginThrottleT struct {
|
||||
mu sync.Mutex
|
||||
attempts map[string]loginAttempt
|
||||
}
|
||||
|
||||
var loginThrottle = &loginThrottleT{attempts: make(map[string]loginAttempt)}
|
||||
|
||||
func loginAttemptKey(r *http.Request, username string) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
return host + "\x00" + strings.ToLower(username)
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) retryAfter(key string, now time.Time) time.Duration {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a, ok := l.attempts[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
if !a.BlockedTo.IsZero() && now.Before(a.BlockedTo) {
|
||||
return time.Until(a.BlockedTo)
|
||||
}
|
||||
if now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
delete(l.attempts, key)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) fail(key string, now time.Time) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.attempts[key]
|
||||
if a.FirstSeen.IsZero() || now.Sub(a.FirstSeen) > 15*time.Minute {
|
||||
a = loginAttempt{FirstSeen: now}
|
||||
}
|
||||
a.Failures++
|
||||
if a.Failures >= 5 {
|
||||
a.BlockedTo = now.Add(15 * time.Minute)
|
||||
}
|
||||
l.attempts[key] = a
|
||||
}
|
||||
|
||||
func (l *loginThrottleT) success(key string) {
|
||||
l.mu.Lock()
|
||||
delete(l.attempts, key)
|
||||
l.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- DB methods on Store ----------
|
||||
|
||||
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
@@ -213,7 +351,47 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS parent_username TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS quota_mode TEXT NOT NULL DEFAULT 'slots'`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS credit_balance INT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS whatsapp TEXT NOT NULL DEFAULT ''`,
|
||||
`ALTER TABLE admin_users ADD COLUMN IF NOT EXISTS monthly_price_cents INT NOT NULL DEFAULT 0`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_admin_users_parent ON admin_users(parent_username)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_audit_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
actor_username TEXT NOT NULL,
|
||||
target_username TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
details TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_created ON reseller_audit_log(created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_actor ON reseller_audit_log(actor_username, created_at DESC)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_audit_target ON reseller_audit_log(target_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_credit_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
reseller_username TEXT NOT NULL,
|
||||
actor_username TEXT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
balance_after INT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_reseller_credit_ledger_owner ON reseller_credit_ledger(reseller_username, created_at DESC)`,
|
||||
`CREATE TABLE IF NOT EXISTS reseller_runtime_state (
|
||||
owner_username TEXT PRIMARY KEY,
|
||||
parent_username TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
expires_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
// Older reseller-owned accounts used zero to mean "unlimited". The
|
||||
// reseller quota model charges at least one slot per account, so normalize
|
||||
// those rows once during schema setup instead of leaving a quota bypass.
|
||||
`UPDATE ssh_users SET max_connections = 1
|
||||
WHERE owner_username <> '' AND max_connections < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -223,45 +401,51 @@ func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
const adminUserSelectColumns = `id, username, password_hash, role, max_users,
|
||||
COALESCE(parent_username, ''), COALESCE(quota_mode, 'slots'), COALESCE(credit_balance, 0),
|
||||
COALESCE(whatsapp, ''), COALESCE(monthly_price_cents, 0), expires_at, is_active, created_at`
|
||||
|
||||
func scanAdminUser(scanner interface{ Scan(...interface{}) error }) (*AdminUser, error) {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users WHERE username = $1`, username,
|
||||
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
err := scanner.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role, &u.MaxUsers,
|
||||
&u.ParentUsername, &u.QuotaMode, &u.CreditBalance, &u.WhatsApp, &u.MonthlyPriceCents,
|
||||
&expiresAt, &u.IsActive, &u.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
u.QuotaMode = normalizeQuotaMode(u.QuotaMode)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*AdminUser, error) {
|
||||
u, err := scanAdminUser(s.db.QueryRowContext(ctx,
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users WHERE username = $1`, username))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAdminUsers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users ORDER BY role, username`)
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users ORDER BY role, username`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -274,15 +458,26 @@ func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
}
|
||||
if u.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive,
|
||||
`INSERT INTO admin_users (username, password_hash, role, max_users, parent_username,
|
||||
quota_mode, credit_balance, whatsapp, monthly_price_cents, expires_at, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id`,
|
||||
u.Username, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive,
|
||||
).Scan(&u.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4,
|
||||
expires_at=$5, is_active=$6 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, expiresAt, u.IsActive)
|
||||
`UPDATE admin_users SET password_hash=$2, role=$3, max_users=$4, parent_username=$5,
|
||||
quota_mode=$6, credit_balance=$7, whatsapp=$8, monthly_price_cents=$9,
|
||||
expires_at=$10, is_active=$11 WHERE id=$1`,
|
||||
u.ID, u.PasswordHash, u.Role, u.MaxUsers, u.ParentUsername,
|
||||
normalizeQuotaMode(u.QuotaMode), u.CreditBalance, u.WhatsApp, u.MonthlyPriceCents,
|
||||
expiresAt, u.IsActive)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpdateAdminPasswordHash(ctx context.Context, id int, passwordHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE admin_users SET password_hash=$2 WHERE id=$1`, id, passwordHash)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -298,8 +493,7 @@ func (s *Store) SetAdminUserActive(ctx context.Context, username string, active
|
||||
|
||||
func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -311,8 +505,7 @@ func (s *Store) ListExpiredResellers(ctx context.Context) ([]*AdminUser, error)
|
||||
|
||||
func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, username, password_hash, role, max_users, expires_at, is_active, created_at
|
||||
FROM admin_users
|
||||
`SELECT `+adminUserSelectColumns+` FROM admin_users
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
@@ -325,15 +518,10 @@ func (s *Store) ListInactiveButRenewedResellers(ctx context.Context) ([]*AdminUs
|
||||
func scanAdminUsers(rows *sql.Rows) ([]*AdminUser, error) {
|
||||
var out []*AdminUser
|
||||
for rows.Next() {
|
||||
u := &AdminUser{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Role,
|
||||
&u.MaxUsers, &expiresAt, &u.IsActive, &u.CreatedAt); err != nil {
|
||||
u, err := scanAdminUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -352,11 +540,17 @@ func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
b := make([]byte, 10)
|
||||
_, _ = rand.Read(b)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("generate bootstrap password: %w", err)
|
||||
}
|
||||
pw := hex.EncodeToString(b)
|
||||
passwordHash, err := hashAdminPassword(pw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
u := &AdminUser{
|
||||
Username: "admin",
|
||||
PasswordHash: hashAdminPassword(pw),
|
||||
PasswordHash: passwordHash,
|
||||
Role: RoleSuperAdmin,
|
||||
MaxUsers: 0,
|
||||
IsActive: true,
|
||||
@@ -373,7 +567,12 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
states, err := store.ListResellerRuntimeStates(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
resellerRuntimeStates.replaceAll(states)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -381,20 +580,10 @@ func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
|
||||
// ownerIsActive returns nil if an SSH user's reseller owner is active, or an error if suspended/expired.
|
||||
func ownerIsActive(ownerUsername string) error {
|
||||
if ownerUsername == "" {
|
||||
return nil
|
||||
if _, replicated := resellerRuntimeStates.get(ownerUsername); replicated {
|
||||
return resellerRuntimeChainActive(ownerUsername)
|
||||
}
|
||||
u, ok := adminUsers.get(ownerUsername)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
if !u.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
return nil
|
||||
return adminAccountChainActive(ownerUsername)
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
@@ -436,29 +625,38 @@ func startResellerExpiryChecker(store *Store) {
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
resellerLifecycleMu.Lock()
|
||||
all, listErr := store.ListAdminUsers(ctx)
|
||||
if listErr != nil {
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry hierarchy for %s: %v", u.Username, listErr)
|
||||
continue
|
||||
}
|
||||
quotaUnlock := lockResellerQuotaSet(resellerSubtreeUsernames(listResellerSubtree(all, u.Username)))
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
sessions.DeleteUser(u.ID)
|
||||
if err := applyResellerSubtreeRuntime(ctx, store, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry runtime for %s: %v", u.Username, err)
|
||||
}
|
||||
quotaUnlock()
|
||||
resellerLifecycleMu.Unlock()
|
||||
}
|
||||
|
||||
// Reactivate resellers that have been renewed (inactive but expiry now in future/nil)
|
||||
renewed, err := store.ListInactiveButRenewedResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller renewal check: %v", err)
|
||||
}
|
||||
for _, u := range renewed {
|
||||
log.Printf("reseller %s renewed — reactivating", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, true); err != nil {
|
||||
log.Printf("reseller renewal: %v", err)
|
||||
continue
|
||||
// Replicated owner records on managed nodes also enforce expiration and
|
||||
// inherited parent suspension without contacting the master on each login.
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) != nil {
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, "suspend"); err != nil {
|
||||
log.Printf("replicated reseller expiry runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
@@ -474,18 +672,33 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.Username = strings.TrimSpace(req.Username)
|
||||
if req.Username == "" || req.Password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := loginAttemptKey(r, req.Username)
|
||||
now := time.Now()
|
||||
if retry := loginThrottle.retryAfter(key, now); retry > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(max(1, int(retry.Seconds()))))
|
||||
http.Error(w, "too many login attempts", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
@@ -493,20 +706,41 @@ func handleLogin(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u == nil || u.PasswordHash != hashAdminPassword(req.Password) {
|
||||
valid := false
|
||||
needsUpgrade := false
|
||||
if u != nil {
|
||||
valid, needsUpgrade = verifyAdminPassword(u.PasswordHash, req.Password)
|
||||
} else {
|
||||
// Keep roughly the same CPU cost for unknown users to reduce account probing.
|
||||
_, _ = hashAdminPassword(req.Password)
|
||||
}
|
||||
if !valid {
|
||||
loginThrottle.fail(key, now)
|
||||
http.Error(w, "invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if !u.IsActive {
|
||||
http.Error(w, "account suspended", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt) {
|
||||
http.Error(w, "account expired", http.StatusForbidden)
|
||||
if adminAccountChainActive(u.Username) != nil {
|
||||
http.Error(w, "account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if needsUpgrade {
|
||||
if upgradedHash, hashErr := hashAdminPassword(req.Password); hashErr == nil {
|
||||
if updateErr := store.UpdateAdminPasswordHash(r.Context(), u.ID, upgradedHash); updateErr != nil {
|
||||
log.Printf("upgrade admin password hash for %s: %v", u.Username, updateErr)
|
||||
} else {
|
||||
u.PasswordHash = upgradedHash
|
||||
adminUsers.set(u)
|
||||
}
|
||||
}
|
||||
}
|
||||
loginThrottle.success(key)
|
||||
sess, err := sessions.Create(u.ID, u.Username, u.Role)
|
||||
if err != nil {
|
||||
log.Printf("create admin session: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": sess.Token,
|
||||
@@ -537,158 +771,35 @@ func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
childAllocation, childCount := 0, 0
|
||||
if statsStore != nil {
|
||||
childAllocation, _ = statsStore.directChildAllocation(r.Context(), s.Username, "")
|
||||
childCount = statsStore.directChildCount(r.Context(), s.Username)
|
||||
}
|
||||
resp["max_users"] = u.MaxUsers
|
||||
resp["used_users"] = countOwnedQuota(r.Context(), statsStore, s.Username)
|
||||
resp["used_ssh_users"] = countOwnedUsers(s.Username)
|
||||
resp["used_xray_users"] = countOwnedXrayClients(r.Context(), statsStore, s.Username)
|
||||
usage, usageErr := ownedQuotaUsageAcrossManagedServers(r.Context(), statsStore, s.Username)
|
||||
if usageErr != nil {
|
||||
usage = resellerQuotaUsage{
|
||||
Weighted: countOwnedQuota(r.Context(), statsStore, s.Username),
|
||||
SSHAccounts: countOwnedUsers(s.Username),
|
||||
XrayAccounts: countOwnedXrayClients(r.Context(), statsStore, s.Username),
|
||||
}
|
||||
}
|
||||
resp["used_users"] = usage.Weighted
|
||||
resp["used_ssh_users"] = usage.SSHAccounts
|
||||
resp["used_xray_users"] = usage.XrayAccounts
|
||||
resp["parent_username"] = u.ParentUsername
|
||||
resp["quota_mode"] = normalizeQuotaMode(u.QuotaMode)
|
||||
resp["credit_balance"] = u.CreditBalance
|
||||
resp["child_allocation"] = childAllocation
|
||||
resp["child_count"] = childCount
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
resp["effective_active"] = adminAccountChainActive(u.Username) == nil
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ---------- Reseller management (superadmin only) ----------
|
||||
|
||||
type ResellerDTO struct {
|
||||
ID int `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
UsedUsers int `json:"used_users"`
|
||||
UsedSSH int `json:"used_ssh_users"`
|
||||
UsedXray int `json:"used_xray_users"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func handleListResellers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
users, err := store.ListAdminUsers(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
out := make([]ResellerDTO, 0, len(users))
|
||||
for _, u := range users {
|
||||
out = append(out, ResellerDTO{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Role: u.Role,
|
||||
MaxUsers: u.MaxUsers,
|
||||
UsedUsers: countOwnedQuota(r.Context(), store, u.Username),
|
||||
UsedSSH: countOwnedUsers(u.Username),
|
||||
UsedXray: countOwnedXrayClients(r.Context(), store, u.Username),
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
IsActive: u.IsActive,
|
||||
CreatedAt: u.CreatedAt,
|
||||
})
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
type ResellerPayload struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxUsers int `json:"max_users"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
|
||||
func handleCreateReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var p ResellerPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
existing, err := store.GetAdminUserByUsername(ctx, p.Username)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
var u *AdminUser
|
||||
if existing != nil {
|
||||
u = existing
|
||||
} else {
|
||||
if p.Password == "" {
|
||||
http.Error(w, "password required for new account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u = &AdminUser{Username: p.Username, Role: RoleReseller}
|
||||
}
|
||||
|
||||
if p.Password != "" {
|
||||
u.PasswordHash = hashAdminPassword(p.Password)
|
||||
}
|
||||
u.MaxUsers = p.MaxUsers
|
||||
u.IsActive = p.IsActive
|
||||
u.ExpiresAt = nil
|
||||
if p.ExpiresAt != "" {
|
||||
t, err := time.Parse(time.RFC3339, p.ExpiresAt)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid expires_at (RFC3339 required)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.ExpiresAt = &t
|
||||
}
|
||||
|
||||
if err := store.UpsertAdminUser(ctx, u); err != nil {
|
||||
log.Printf("upsert reseller: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
adminUsers.set(u)
|
||||
|
||||
if u.Role == RoleReseller {
|
||||
if !u.IsActive || (u.ExpiresAt != nil && time.Now().After(*u.ExpiresAt)) {
|
||||
disconnectOwnerUsers(u.Username)
|
||||
removeOwnerXrayClients(ctx, store, u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeleteReseller(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
if err := store.DeleteAdminUser(ctx, username); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
disconnectOwnerUsers(username)
|
||||
removeOwnerXrayClients(ctx, store, username)
|
||||
adminUsers.delete(username)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
// Reseller management handlers live in reseller_management.go.
|
||||
|
||||
+583
@@ -0,0 +1,583 @@
|
||||
package main
|
||||
|
||||
// bot_api.go — /api/bot/* admin endpoints (superadmin).
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var botSettingKeys = map[string]int{
|
||||
"welcome_text": 4096,
|
||||
"contact_text": 4096,
|
||||
"app_text": 4096,
|
||||
"app_url": 2048,
|
||||
}
|
||||
|
||||
func botHasControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, func(r rune) bool {
|
||||
return unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t'
|
||||
}) >= 0
|
||||
}
|
||||
|
||||
func botHasAnyControlCharacters(value string) bool {
|
||||
return strings.IndexFunc(value, unicode.IsControl) >= 0
|
||||
}
|
||||
|
||||
func validateBotPlan(p *BotPlan) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
p.Kind = strings.ToLower(strings.TrimSpace(p.Kind))
|
||||
p.XrayProtocol = strings.ToLower(strings.TrimSpace(p.XrayProtocol))
|
||||
p.XrayInboundTag = strings.TrimSpace(p.XrayInboundTag)
|
||||
p.ServerID = strings.TrimSpace(p.ServerID)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("plan name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Kind != "ssh" && p.Kind != "xray" {
|
||||
return fmt.Errorf("plan kind must be ssh or xray")
|
||||
}
|
||||
if p.Days < 1 || p.Days > 3650 || p.MaxConnections < 0 || p.MaxConnections > 10000 {
|
||||
return fmt.Errorf("invalid plan duration or connection limit")
|
||||
}
|
||||
if p.LimitMbpsUp < 0 || p.LimitMbpsUp > 1000000 || p.LimitMbpsDown < 0 || p.LimitMbpsDown > 1000000 {
|
||||
return fmt.Errorf("invalid bandwidth limit")
|
||||
}
|
||||
if p.PriceCents < 0 || p.PriceCents > 1000000000 || p.CreditCost < 0 || p.CreditCost > 1000000000 {
|
||||
return fmt.Errorf("invalid plan price or credit cost")
|
||||
}
|
||||
if p.Kind == "xray" && p.XrayProtocol != "" && p.XrayProtocol != "vless" && p.XrayProtocol != "vmess" && p.XrayProtocol != "trojan" {
|
||||
return fmt.Errorf("invalid Xray protocol")
|
||||
}
|
||||
if len(p.XrayInboundTag) > 128 || len(p.ServerID) > 128 {
|
||||
return fmt.Errorf("inbound tag or server id is too long")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBotPackage(p *BotCreditPackage) error {
|
||||
p.Name = strings.TrimSpace(p.Name)
|
||||
if p.Name == "" || len(p.Name) > 120 || botHasControlCharacters(p.Name) {
|
||||
return fmt.Errorf("package name must contain 1-120 safe characters")
|
||||
}
|
||||
if p.Credits < 1 || p.Credits > 1000000000 || p.PriceCents < 0 || p.PriceCents > 1000000000 {
|
||||
return fmt.Errorf("invalid package credits or price")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func botWriteJSON(w http.ResponseWriter, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func botStoreReady(w http.ResponseWriter, store *Store) bool {
|
||||
if store == nil {
|
||||
http.Error(w, "database not configured", http.StatusServiceUnavailable)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- Config ----------
|
||||
|
||||
type botConfigDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
MPConfirmMode string `json:"mp_confirm_mode"`
|
||||
MPPollInterval string `json:"mp_poll_interval"`
|
||||
PixExpirationMinutes int `json:"pix_expiration_minutes"`
|
||||
TrialEnabled bool `json:"trial_enabled"`
|
||||
TrialHours int `json:"trial_hours"`
|
||||
TrialMaxConnections int `json:"trial_max_connections"`
|
||||
TrialKind string `json:"trial_kind"`
|
||||
TrialInboundTag string `json:"trial_inbound_tag"`
|
||||
AdminTelegramIDs []int64 `json:"admin_telegram_ids"`
|
||||
Currency string `json:"currency"`
|
||||
PublicHost string `json:"public_host"`
|
||||
XrayPublicHost string `json:"xray_public_host"`
|
||||
HasTelegramToken bool `json:"has_telegram_token"`
|
||||
HasMPAccessToken bool `json:"has_mp_access_token"`
|
||||
HasMPWebhookSecret bool `json:"has_mp_webhook_secret"`
|
||||
// Write-only secret fields (empty on GET; empty on POST = keep existing).
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
MPAccessToken string `json:"mp_access_token"`
|
||||
MPWebhookSecret string `json:"mp_webhook_secret"`
|
||||
}
|
||||
|
||||
func handleBotConfig(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
cfg, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
writeInternalError(w, "load bot configuration", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, botConfigDTO{
|
||||
Enabled: cfg.Enabled,
|
||||
MPConfirmMode: cfg.MPConfirmMode,
|
||||
MPPollInterval: cfg.MPPollInterval,
|
||||
PixExpirationMinutes: cfg.PixExpirationMinutes,
|
||||
TrialEnabled: cfg.TrialEnabled,
|
||||
TrialHours: cfg.TrialHours,
|
||||
TrialMaxConnections: cfg.TrialMaxConnections,
|
||||
TrialKind: cfg.TrialKind,
|
||||
TrialInboundTag: cfg.TrialInboundTag,
|
||||
AdminTelegramIDs: cfg.AdminTelegramIDs,
|
||||
Currency: cfg.Currency,
|
||||
PublicHost: cfg.PublicHost,
|
||||
XrayPublicHost: cfg.XrayPublicHost,
|
||||
HasTelegramToken: cfg.TelegramToken != "",
|
||||
HasMPAccessToken: cfg.MPAccessToken != "",
|
||||
HasMPWebhookSecret: cfg.MPWebhookSecret != "",
|
||||
})
|
||||
case http.MethodPost:
|
||||
var dto botConfigDTO
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&dto); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.MPConfirmMode = strings.ToLower(strings.TrimSpace(dto.MPConfirmMode))
|
||||
if dto.MPConfirmMode != "polling" && dto.MPConfirmMode != "webhook" {
|
||||
http.Error(w, "confirmation mode must be polling or webhook", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollInterval, err := time.ParseDuration(strings.TrimSpace(dto.MPPollInterval))
|
||||
if err != nil || pollInterval < 5*time.Second || pollInterval > 5*time.Minute {
|
||||
http.Error(w, "poll interval must be between 5s and 5m", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if dto.PixExpirationMinutes < 5 || dto.PixExpirationMinutes > 1440 || dto.TrialHours < 1 || dto.TrialHours > 720 || dto.TrialMaxConnections < 1 || dto.TrialMaxConnections > 1000 {
|
||||
http.Error(w, "invalid PIX expiration or trial limits", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
dto.TrialKind = strings.ToLower(strings.TrimSpace(dto.TrialKind))
|
||||
if dto.TrialKind != "ssh" && dto.TrialKind != "xray" {
|
||||
http.Error(w, "trial kind must be ssh or xray", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(dto.AdminTelegramIDs) > 100 {
|
||||
http.Error(w, "too many admin Telegram IDs", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, id := range dto.AdminTelegramIDs {
|
||||
if id <= 0 {
|
||||
http.Error(w, "admin Telegram IDs must be positive", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, value := range []string{dto.TelegramToken, dto.MPAccessToken, dto.MPWebhookSecret, dto.PublicHost, dto.XrayPublicHost, dto.TrialInboundTag} {
|
||||
if len(value) > 2048 || botHasAnyControlCharacters(value) {
|
||||
http.Error(w, "configuration contains an invalid value", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
existing, err := LoadBotConfig(ctx, store)
|
||||
if err != nil {
|
||||
http.Error(w, "load existing config", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
effectiveTelegramToken := strings.TrimSpace(dto.TelegramToken)
|
||||
if effectiveTelegramToken == "" {
|
||||
effectiveTelegramToken = existing.TelegramToken
|
||||
}
|
||||
if dto.Enabled && effectiveTelegramToken == "" {
|
||||
http.Error(w, "Telegram token is required before enabling the bot", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
effectiveWebhookSecret := strings.TrimSpace(dto.MPWebhookSecret)
|
||||
if effectiveWebhookSecret == "" {
|
||||
effectiveWebhookSecret = existing.MPWebhookSecret
|
||||
}
|
||||
if dto.MPConfirmMode == "webhook" && len(effectiveWebhookSecret) < 16 {
|
||||
http.Error(w, "a webhook secret of at least 16 characters is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
cfg := &BotConfig{
|
||||
Enabled: dto.Enabled,
|
||||
TelegramToken: strings.TrimSpace(dto.TelegramToken),
|
||||
MPAccessToken: strings.TrimSpace(dto.MPAccessToken),
|
||||
MPConfirmMode: dto.MPConfirmMode,
|
||||
MPWebhookSecret: strings.TrimSpace(dto.MPWebhookSecret),
|
||||
MPPollInterval: dto.MPPollInterval,
|
||||
PixExpirationMinutes: dto.PixExpirationMinutes,
|
||||
TrialEnabled: dto.TrialEnabled,
|
||||
TrialHours: dto.TrialHours,
|
||||
TrialMaxConnections: dto.TrialMaxConnections,
|
||||
TrialKind: dto.TrialKind,
|
||||
TrialInboundTag: strings.TrimSpace(dto.TrialInboundTag),
|
||||
AdminTelegramIDs: dto.AdminTelegramIDs,
|
||||
Currency: dto.Currency,
|
||||
PublicHost: strings.TrimSpace(dto.PublicHost),
|
||||
XrayPublicHost: strings.TrimSpace(dto.XrayPublicHost),
|
||||
}
|
||||
if err := SaveBotConfig(ctx, store, cfg); err != nil {
|
||||
writeInternalError(w, "save bot configuration", err)
|
||||
return
|
||||
}
|
||||
reloadBotService(store)
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Plans ----------
|
||||
|
||||
func handleBotPlans(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
plans, err := store.ListPlans(ctx, false)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, plans)
|
||||
case http.MethodPost:
|
||||
var p BotPlan
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Kind == "" {
|
||||
p.Kind = "ssh"
|
||||
}
|
||||
if err := validateBotPlan(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertPlan(ctx, &p); err != nil {
|
||||
writeInternalError(w, "save bot plan", err)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
case http.MethodDelete:
|
||||
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeletePlan(ctx, id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Credit packages ----------
|
||||
|
||||
func handleBotCreditPackages(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
pkgs, err := store.ListCreditPackages(ctx, false)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, pkgs)
|
||||
case http.MethodPost:
|
||||
var p BotCreditPackage
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := validateBotPackage(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.UpsertCreditPackage(ctx, &p); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, p)
|
||||
case http.MethodDelete:
|
||||
id, _ := strconv.Atoi(r.URL.Query().Get("id"))
|
||||
if id <= 0 {
|
||||
http.Error(w, "invalid id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := store.DeleteCreditPackage(ctx, id); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Bot users ----------
|
||||
|
||||
func handleBotUsers(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
users, err := store.ListBotUsers(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, users)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
TelegramID int64 `json:"telegram_id"`
|
||||
Action string `json:"action"`
|
||||
Role string `json:"role"`
|
||||
LinkedAdminUsername string `json:"linked_admin_username"`
|
||||
Credits int `json:"credits"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.TelegramID == 0 {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "set_role":
|
||||
if req.Role == "" {
|
||||
req.Role = "customer"
|
||||
}
|
||||
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||
if req.Role != "customer" && req.Role != "reseller" && req.Role != "blocked" {
|
||||
http.Error(w, "role must be customer, reseller, or blocked", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
req.LinkedAdminUsername = strings.TrimSpace(req.LinkedAdminUsername)
|
||||
if req.Role == "reseller" {
|
||||
linked, err := store.GetAdminUserByUsername(ctx, req.LinkedAdminUsername)
|
||||
if err != nil || linked == nil || linked.Role != RoleReseller {
|
||||
http.Error(w, "linked reseller account not found", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
req.LinkedAdminUsername = ""
|
||||
}
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, req.Role, req.LinkedAdminUsername); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "block":
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, "blocked", ""); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "unblock":
|
||||
if err := store.SetBotUserRole(ctx, req.TelegramID, "customer", ""); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "adjust_credits":
|
||||
if req.Credits == 0 || req.Credits < -1000000000 || req.Credits > 1000000000 {
|
||||
http.Error(w, "invalid credit adjustment", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := store.AdjustCredits(ctx, req.TelegramID, req.Credits, "admin_adjust", nil); err != nil {
|
||||
http.Error(w, "adjust: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Transactions ----------
|
||||
|
||||
func handleBotTransactions(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
status := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("status")))
|
||||
if status != "" && status != "pending" && status != "approved" && status != "expired" && status != "refunded" && status != "error" {
|
||||
http.Error(w, "invalid status", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
txns, err := store.ListTransactions(ctx, status, limit)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, txns)
|
||||
case http.MethodPost:
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID == 0 {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "refund":
|
||||
if err := store.SetTransactionStatus(ctx, req.ID, "refunded"); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
case "reprocess":
|
||||
if b := currentBot(); b != nil {
|
||||
go b.tryFulfill(req.ID)
|
||||
} else {
|
||||
http.Error(w, "bot not running", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Settings (bot texts) ----------
|
||||
|
||||
func handleBotSettings(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
all, err := store.AllSettings(ctx)
|
||||
if err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
botWriteJSON(w, all)
|
||||
case http.MethodPost:
|
||||
var kv map[string]string
|
||||
if err := json.NewDecoder(r.Body).Decode(&kv); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for k, v := range kv {
|
||||
maxLen, ok := botSettingKeys[k]
|
||||
if !ok || len(v) > maxLen || botHasControlCharacters(v) {
|
||||
http.Error(w, "invalid bot setting", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if k == "app_url" && strings.TrimSpace(v) != "" {
|
||||
u, err := url.ParseRequestURI(strings.TrimSpace(v))
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
http.Error(w, "app_url must be an http or https URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := store.SetSetting(ctx, k, v); err != nil {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
botWriteJSON(w, map[string]bool{"ok": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Connectivity test ----------
|
||||
|
||||
func handleBotTest(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !botStoreReady(w, store) {
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
var req struct {
|
||||
TelegramToken string `json:"telegram_token"`
|
||||
MPAccessToken string `json:"mp_access_token"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
cfg, _ := LoadBotConfig(ctx, store)
|
||||
tgToken := strings.TrimSpace(req.TelegramToken)
|
||||
mpToken := strings.TrimSpace(req.MPAccessToken)
|
||||
if cfg != nil {
|
||||
if tgToken == "" {
|
||||
tgToken = cfg.TelegramToken
|
||||
}
|
||||
if mpToken == "" {
|
||||
mpToken = cfg.MPAccessToken
|
||||
}
|
||||
}
|
||||
out := map[string]interface{}{}
|
||||
if tgToken != "" {
|
||||
name, err := newTGClient(tgToken).getMe(ctx)
|
||||
if err != nil {
|
||||
out["telegram_ok"] = false
|
||||
out["telegram_error"] = err.Error()
|
||||
} else {
|
||||
out["telegram_ok"] = true
|
||||
out["telegram_bot"] = "@" + name
|
||||
}
|
||||
} else {
|
||||
out["telegram_ok"] = false
|
||||
out["telegram_error"] = "no token configured"
|
||||
}
|
||||
if mpToken != "" {
|
||||
_, err := newMPClient(mpToken).do(ctx, http.MethodGet, "/v1/payment_methods", nil, "")
|
||||
if err != nil {
|
||||
out["mp_ok"] = false
|
||||
out["mp_error"] = err.Error()
|
||||
} else {
|
||||
out["mp_ok"] = true
|
||||
}
|
||||
} else {
|
||||
out["mp_ok"] = false
|
||||
out["mp_error"] = "no token configured"
|
||||
}
|
||||
botWriteJSON(w, out)
|
||||
}
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// bot_config.go — in-memory bot configuration loaded from the bot_config table.
|
||||
// Secrets are decrypted here and never persisted in plaintext.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
var errInsufficientCredits = errors.New("insufficient credits")
|
||||
|
||||
func botItoa(n int) string { return strconv.Itoa(n) }
|
||||
|
||||
// BotConfig is the decrypted, ready-to-use bot configuration.
|
||||
// Telegram always uses long-polling (no webhook). The webhook/polling toggle
|
||||
// applies only to Mercado Pago payment confirmation (MPConfirmMode).
|
||||
type BotConfig struct {
|
||||
Enabled bool
|
||||
TelegramToken string
|
||||
MPAccessToken string
|
||||
MPConfirmMode string // webhook | polling
|
||||
MPWebhookSecret string
|
||||
MPPollInterval string
|
||||
PixExpirationMinutes int
|
||||
TrialEnabled bool
|
||||
TrialHours int
|
||||
TrialMaxConnections int
|
||||
TrialKind string // ssh | xray
|
||||
TrialInboundTag string
|
||||
AdminTelegramIDs []int64
|
||||
Currency string
|
||||
PublicHost string // SSH connection host shown to buyers
|
||||
XrayPublicHost string // host used to build vless/vmess links
|
||||
}
|
||||
|
||||
// LoadBotConfig reads the config row and decrypts secrets into a BotConfig.
|
||||
func LoadBotConfig(ctx context.Context, store *Store) (*BotConfig, error) {
|
||||
r, err := store.getBotConfigRow(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tok, err := decryptSecret(r.TelegramTokenEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mp, err := decryptSecret(r.MPAccessTokenEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mpSec, err := decryptSecret(r.MPWebhookSecretEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &BotConfig{
|
||||
Enabled: r.Enabled,
|
||||
TelegramToken: tok,
|
||||
MPAccessToken: mp,
|
||||
MPConfirmMode: r.MPConfirmMode,
|
||||
MPWebhookSecret: mpSec,
|
||||
MPPollInterval: r.MPPollInterval,
|
||||
PixExpirationMinutes: r.PixExpirationMinutes,
|
||||
TrialEnabled: r.TrialEnabled,
|
||||
TrialHours: r.TrialHours,
|
||||
TrialMaxConnections: r.TrialMaxConnections,
|
||||
TrialKind: r.TrialKind,
|
||||
TrialInboundTag: r.TrialInboundTag,
|
||||
AdminTelegramIDs: r.AdminTelegramIDs,
|
||||
Currency: r.Currency,
|
||||
PublicHost: r.PublicHost,
|
||||
XrayPublicHost: r.XrayPublicHost,
|
||||
}
|
||||
cfg.applyDefaults()
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c *BotConfig) applyDefaults() {
|
||||
if c.MPConfirmMode == "" {
|
||||
c.MPConfirmMode = "polling"
|
||||
}
|
||||
if c.MPPollInterval == "" {
|
||||
c.MPPollInterval = "20s"
|
||||
}
|
||||
if c.PixExpirationMinutes <= 0 {
|
||||
c.PixExpirationMinutes = 30
|
||||
}
|
||||
if c.TrialHours <= 0 {
|
||||
c.TrialHours = 1
|
||||
}
|
||||
if c.TrialMaxConnections <= 0 {
|
||||
c.TrialMaxConnections = 1
|
||||
}
|
||||
if c.TrialKind == "" {
|
||||
c.TrialKind = "ssh"
|
||||
}
|
||||
if c.Currency == "" {
|
||||
c.Currency = "BRL"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *BotConfig) isAdmin(telegramID int64) bool {
|
||||
for _, id := range c.AdminTelegramIDs {
|
||||
if id == telegramID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SaveBotConfig persists a BotConfig. Empty secret fields preserve the stored
|
||||
// value (nil blob → column left unchanged).
|
||||
func SaveBotConfig(ctx context.Context, store *Store, cfg *BotConfig) error {
|
||||
cfg.applyDefaults()
|
||||
row := &botConfigRow{
|
||||
Enabled: cfg.Enabled,
|
||||
TelegramMode: "polling", // Telegram is always long-polling
|
||||
TelegramWebhookURL: "",
|
||||
MPConfirmMode: cfg.MPConfirmMode,
|
||||
MPPollInterval: cfg.MPPollInterval,
|
||||
PixExpirationMinutes: cfg.PixExpirationMinutes,
|
||||
TrialEnabled: cfg.TrialEnabled,
|
||||
TrialHours: cfg.TrialHours,
|
||||
TrialMaxConnections: cfg.TrialMaxConnections,
|
||||
TrialKind: cfg.TrialKind,
|
||||
TrialInboundTag: cfg.TrialInboundTag,
|
||||
AdminTelegramIDs: cfg.AdminTelegramIDs,
|
||||
Currency: cfg.Currency,
|
||||
PublicHost: cfg.PublicHost,
|
||||
XrayPublicHost: cfg.XrayPublicHost,
|
||||
}
|
||||
var tokEnc, mpEnc, mpSecEnc []byte
|
||||
var err error
|
||||
if cfg.TelegramToken != "" {
|
||||
if tokEnc, err = encryptSecret(cfg.TelegramToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.MPAccessToken != "" {
|
||||
if mpEnc, err = encryptSecret(cfg.MPAccessToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cfg.MPWebhookSecret != "" {
|
||||
if mpSecEnc, err = encryptSecret(cfg.MPWebhookSecret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// tgSecEnc is always nil now (no Telegram webhook secret).
|
||||
return store.saveBotConfigRow(ctx, row, tokEnc, nil, mpEnc, mpSecEnc)
|
||||
}
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
// bot_core.go — Bot lifecycle, Telegram update dispatch, and shared helpers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Global bot instance (nil when disabled). Guarded by botMgrMu.
|
||||
var (
|
||||
botMgr *Bot
|
||||
botMgrMu sync.Mutex
|
||||
)
|
||||
|
||||
func currentBot() *Bot {
|
||||
botMgrMu.Lock()
|
||||
defer botMgrMu.Unlock()
|
||||
return botMgr
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
store *Store
|
||||
cfg *BotConfig
|
||||
tg *tgClient
|
||||
mp *mpClient
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func newBot(store *Store, cfg *BotConfig) *Bot {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
b := &Bot{
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
tg: newTGClient(cfg.TelegramToken),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
if cfg.MPAccessToken != "" {
|
||||
b.mp = newMPClient(cfg.MPAccessToken)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// ---------- Lifecycle (called from main.go boot and bot_api.go on save) ----------
|
||||
|
||||
// startBotService loads config from the DB and starts the bot if enabled.
|
||||
func startBotService(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
cfg, err := LoadBotConfig(context.Background(), store)
|
||||
if err != nil {
|
||||
log.Printf("[bot] load config: %v", err)
|
||||
return
|
||||
}
|
||||
botMgrMu.Lock()
|
||||
defer botMgrMu.Unlock()
|
||||
if botMgr != nil {
|
||||
botMgr.cancel()
|
||||
botMgr = nil
|
||||
}
|
||||
if !cfg.Enabled {
|
||||
log.Printf("[bot] disabled")
|
||||
return
|
||||
}
|
||||
if cfg.TelegramToken == "" {
|
||||
log.Printf("[bot] enabled but no telegram token configured; not starting")
|
||||
return
|
||||
}
|
||||
b := newBot(store, cfg)
|
||||
botMgr = b
|
||||
b.start()
|
||||
}
|
||||
|
||||
// reloadBotService restarts the bot after a config change.
|
||||
func reloadBotService(store *Store) { startBotService(store) }
|
||||
|
||||
func (b *Bot) start() {
|
||||
log.Printf("[bot] starting (telegram=long-polling, mp_confirm=%s)", b.cfg.MPConfirmMode)
|
||||
// Telegram uses long-polling only. Clearing any stale webhook + polling both
|
||||
// hit the network, so run off the goroutine that holds botMgrMu.
|
||||
go func() {
|
||||
_ = b.tg.deleteWebhook(b.ctx)
|
||||
b.runPolling()
|
||||
}()
|
||||
if b.mp != nil && b.cfg.MPConfirmMode == "polling" {
|
||||
go b.runPaymentPoller()
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) stop() { b.cancel() }
|
||||
|
||||
// ---------- Update polling ----------
|
||||
|
||||
func (b *Bot) runPolling() {
|
||||
var offset int64
|
||||
log.Printf("[bot] long-polling started")
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Printf("[bot] polling stopped")
|
||||
return
|
||||
default:
|
||||
}
|
||||
ups, err := b.tg.getUpdates(b.ctx, offset, 50)
|
||||
if err != nil {
|
||||
if b.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
log.Printf("[bot] getUpdates: %v", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
for i := range ups {
|
||||
u := ups[i]
|
||||
if u.UpdateID >= offset {
|
||||
offset = u.UpdateID + 1
|
||||
}
|
||||
b.handleUpdate(&u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Dispatch ----------
|
||||
|
||||
func (b *Bot) handleUpdate(u *tgUpdate) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Printf("[bot] panic handling update: %v", r)
|
||||
}
|
||||
}()
|
||||
switch {
|
||||
case u.CallbackQuery != nil:
|
||||
b.handleCallback(u.CallbackQuery)
|
||||
case u.Message != nil && u.Message.From != nil:
|
||||
b.handleMessage(u.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleMessage(m *tgMessage) {
|
||||
b.touchUser(m.From)
|
||||
text := strings.TrimSpace(m.Text)
|
||||
switch {
|
||||
case text == "/start" || text == "/menu" || text == "start":
|
||||
b.showMainMenu(m.Chat.ID, m.From, 0)
|
||||
case strings.HasPrefix(text, "/stats") && b.cfg.isAdmin(m.From.ID):
|
||||
b.cmdAdminStats(m.Chat.ID)
|
||||
case strings.HasPrefix(text, "/addcredit") && b.cfg.isAdmin(m.From.ID):
|
||||
b.cmdAdminAddCredit(m.Chat.ID, text)
|
||||
default:
|
||||
b.showMainMenu(m.Chat.ID, m.From, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) handleCallback(cb *tgCallbackQuery) {
|
||||
b.touchUser(&cb.From)
|
||||
_ = b.tg.answerCallback(b.ctx, cb.ID, "")
|
||||
if cb.Message == nil {
|
||||
return
|
||||
}
|
||||
chatID := cb.Message.Chat.ID
|
||||
msgID := cb.Message.MessageID
|
||||
data := cb.Data
|
||||
|
||||
switch {
|
||||
case data == "menu:main":
|
||||
b.showMainMenu(chatID, &cb.From, msgID)
|
||||
case data == "buy":
|
||||
b.showPlanList(chatID, msgID, "ssh_or_xray", "buy")
|
||||
case strings.HasPrefix(data, "buy:"):
|
||||
b.startPlanPurchase(chatID, &cb.From, data[len("buy:"):], false)
|
||||
case data == "renew":
|
||||
b.showRenewList(chatID, &cb.From, msgID)
|
||||
case strings.HasPrefix(data, "renew:"):
|
||||
b.startRenew(chatID, &cb.From, msgID, data[len("renew:"):])
|
||||
case strings.HasPrefix(data, "rnw:"):
|
||||
b.startRenewPayment(chatID, &cb.From, data[len("rnw:"):])
|
||||
case data == "trial":
|
||||
b.handleTrial(chatID, &cb.From)
|
||||
case data == "purchases":
|
||||
b.showPurchases(chatID, &cb.From, msgID)
|
||||
case data == "app":
|
||||
b.showText(chatID, msgID, "app_text", "📥 App: (configure em bot_settings)")
|
||||
case data == "contact":
|
||||
b.showText(chatID, msgID, "contact_text", "👤 Contato: (configure em bot_settings)")
|
||||
case data == "res:menu":
|
||||
b.showResellerMenu(chatID, &cb.From, msgID)
|
||||
case data == "res:topup":
|
||||
b.showCreditPackages(chatID, msgID)
|
||||
case strings.HasPrefix(data, "res:topup:"):
|
||||
b.startTopup(chatID, &cb.From, data[len("res:topup:"):])
|
||||
case data == "res:create":
|
||||
b.showPlanList(chatID, msgID, "ssh_or_xray", "res:create")
|
||||
case strings.HasPrefix(data, "res:create:"):
|
||||
b.resellerCreateAccount(chatID, &cb.From, data[len("res:create:"):])
|
||||
case data == "res:clients":
|
||||
b.showResellerClients(chatID, &cb.From, msgID)
|
||||
case strings.HasPrefix(data, "pay:check:"):
|
||||
b.checkPaymentButton(chatID, &cb.From, data[len("pay:check:"):])
|
||||
default:
|
||||
// unknown — refresh menu
|
||||
b.showMainMenu(chatID, &cb.From, msgID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- User helpers ----------
|
||||
|
||||
func (b *Bot) touchUser(u *tgUser) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
_ = b.store.UpsertBotUser(b.ctx, &BotUser{
|
||||
TelegramID: u.ID,
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Bot) botUser(telegramID int64) *BotUser {
|
||||
u, err := b.store.GetBotUser(b.ctx, telegramID)
|
||||
if err != nil {
|
||||
return &BotUser{TelegramID: telegramID, Role: "customer"}
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
// ---------- Message helpers ----------
|
||||
|
||||
func (b *Bot) send(chatID int64, text string, kb *tgInlineKeyboard) {
|
||||
if _, err := b.tg.sendMessage(b.ctx, chatID, text, kb); err != nil {
|
||||
log.Printf("[bot] sendMessage: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// sendOrEdit edits an existing message if msgID>0, else sends a new one.
|
||||
func (b *Bot) sendOrEdit(chatID, msgID int64, text string, kb *tgInlineKeyboard) {
|
||||
if msgID > 0 {
|
||||
if err := b.tg.editMessageText(b.ctx, chatID, msgID, text, kb); err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
b.send(chatID, text, kb)
|
||||
}
|
||||
|
||||
func (b *Bot) showText(chatID, msgID int64, key, def string) {
|
||||
txt := b.store.GetSetting(b.ctx, key, def)
|
||||
b.sendOrEdit(chatID, msgID, txt, backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Keyboard builders ----------
|
||||
|
||||
func kb(rows ...[]tgInlineButton) *tgInlineKeyboard {
|
||||
return &tgInlineKeyboard{InlineKeyboard: rows}
|
||||
}
|
||||
|
||||
func btn(text, data string) tgInlineButton { return tgInlineButton{Text: text, CallbackData: data} }
|
||||
|
||||
func urlBtn(text, u string) tgInlineButton { return tgInlineButton{Text: text, URL: u} }
|
||||
|
||||
func backKeyboard() *tgInlineKeyboard {
|
||||
return kb([]tgInlineButton{btn("⬅️ Voltar", "menu:main")})
|
||||
}
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
package main
|
||||
|
||||
// bot_crypto.go — secret encryption for the Telegram/Mercado Pago bot.
|
||||
//
|
||||
// Bot tokens (Telegram bot token, Mercado Pago access token, webhook secrets)
|
||||
// are stored in PostgreSQL encrypted with AES-256-GCM. The 32-byte master key
|
||||
// lives OUTSIDE the database, so a DB dump alone never reveals the secrets:
|
||||
// 1) env BOT_MASTER_KEY (64 hex chars), if set; otherwise
|
||||
// 2) a 0600 key file next to config.json (bot_master.key); otherwise
|
||||
// 3) generated with crypto/rand on first use and written to that key file.
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
botKeyOnce sync.Once
|
||||
botKey []byte
|
||||
botKeyErr error
|
||||
)
|
||||
|
||||
// botMasterKeyPath returns the on-disk location of the AES master key.
|
||||
func botMasterKeyPath() string {
|
||||
if globalCfgPath != "" {
|
||||
return filepath.Join(filepath.Dir(globalCfgPath), "bot_master.key")
|
||||
}
|
||||
return "/opt/sshpanel/bot_master.key"
|
||||
}
|
||||
|
||||
// loadBotMasterKey resolves the 32-byte master key (env → file → generate).
|
||||
func loadBotMasterKey() ([]byte, error) {
|
||||
botKeyOnce.Do(func() {
|
||||
if env := strings.TrimSpace(os.Getenv("BOT_MASTER_KEY")); env != "" {
|
||||
k, err := hex.DecodeString(env)
|
||||
if err != nil {
|
||||
botKeyErr = fmt.Errorf("BOT_MASTER_KEY invalid hex: %w", err)
|
||||
return
|
||||
}
|
||||
if len(k) != 32 {
|
||||
botKeyErr = fmt.Errorf("BOT_MASTER_KEY must be 32 bytes (64 hex chars), got %d", len(k))
|
||||
return
|
||||
}
|
||||
botKey = k
|
||||
return
|
||||
}
|
||||
|
||||
path := botMasterKeyPath()
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
k, derr := hex.DecodeString(strings.TrimSpace(string(data)))
|
||||
if derr == nil && len(k) == 32 {
|
||||
botKey = k
|
||||
return
|
||||
}
|
||||
// Refuse to overwrite a bad key file — overwriting would make
|
||||
// existing ciphertext undecryptable and silently lose secrets.
|
||||
botKeyErr = fmt.Errorf("bot master key file %s is invalid; refusing to overwrite", path)
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
botKeyErr = fmt.Errorf("read bot master key: %w", err)
|
||||
return
|
||||
}
|
||||
|
||||
k := make([]byte, 32)
|
||||
if _, e := rand.Read(k); e != nil {
|
||||
botKeyErr = fmt.Errorf("generate bot master key: %w", e)
|
||||
return
|
||||
}
|
||||
if e := os.WriteFile(path, []byte(hex.EncodeToString(k)), 0o600); e != nil {
|
||||
botKeyErr = fmt.Errorf("write bot master key %s: %w", path, e)
|
||||
return
|
||||
}
|
||||
botKey = k
|
||||
})
|
||||
return botKey, botKeyErr
|
||||
}
|
||||
|
||||
// encryptSecret encrypts a plaintext secret. Empty input returns nil (no blob).
|
||||
func encryptSecret(plain string) ([]byte, error) {
|
||||
if plain == "" {
|
||||
return nil, nil
|
||||
}
|
||||
key, err := loadBotMasterKey()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Output is nonce || ciphertext(+tag).
|
||||
return gcm.Seal(nonce, nonce, []byte(plain), nil), nil
|
||||
}
|
||||
|
||||
// decryptSecret reverses encryptSecret. Empty/nil input returns "".
|
||||
func decryptSecret(enc []byte) (string, error) {
|
||||
if len(enc) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
key, err := loadBotMasterKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(enc) < gcm.NonceSize() {
|
||||
return "", fmt.Errorf("ciphertext too short")
|
||||
}
|
||||
nonce, ct := enc[:gcm.NonceSize()], enc[gcm.NonceSize():]
|
||||
pt, err := gcm.Open(nil, nonce, ct, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decrypt secret: %w", err)
|
||||
}
|
||||
return string(pt), nil
|
||||
}
|
||||
|
||||
// maskSecret returns a log-safe representation of a secret.
|
||||
func maskSecret(s string) string {
|
||||
if s == "" {
|
||||
return "(empty)"
|
||||
}
|
||||
if len(s) <= 6 {
|
||||
return "***"
|
||||
}
|
||||
return s[:3] + "***" + s[len(s)-2:]
|
||||
}
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
package main
|
||||
|
||||
// bot_flows.go — customer + reseller conversation flows and admin commands.
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Main menu ----------
|
||||
|
||||
func (b *Bot) showMainMenu(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role == "blocked" {
|
||||
b.sendOrEdit(chatID, msgID, "🚫 Seu acesso foi bloqueado.", nil)
|
||||
return
|
||||
}
|
||||
welcome := b.store.GetSetting(b.ctx, "welcome_text", "")
|
||||
var text string
|
||||
if welcome != "" {
|
||||
text = strings.ReplaceAll(welcome, "{name}", htmlEscape(from.FirstName))
|
||||
} else {
|
||||
text = fmt.Sprintf("😉 Olá <b>%s</b>, seja bem-vindo!\n\n🚀 Aqui você encontra os melhores planos <b>SSH</b> e <b>Xray</b> Premium.\nSelecione uma das opções abaixo:", htmlEscape(from.FirstName))
|
||||
}
|
||||
|
||||
var rows [][]tgInlineButton
|
||||
if b.cfg.TrialEnabled {
|
||||
rows = append(rows, []tgInlineButton{btn("⏳ Teste Grátis", "trial"), btn("🛍️ Minhas Compras", "purchases")})
|
||||
} else {
|
||||
rows = append(rows, []tgInlineButton{btn("🛍️ Minhas Compras", "purchases")})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("💎 Comprar Premium", "buy")})
|
||||
rows = append(rows, []tgInlineButton{btn("🔄 Renovar", "renew")})
|
||||
|
||||
appURL := b.store.GetSetting(b.ctx, "app_url", "")
|
||||
appRow := []tgInlineButton{}
|
||||
if appURL != "" {
|
||||
appRow = append(appRow, urlBtn("📥 Baixar APP", appURL))
|
||||
} else {
|
||||
appRow = append(appRow, btn("📥 Baixar APP", "app"))
|
||||
}
|
||||
appRow = append(appRow, btn("👤 Contato", "contact"))
|
||||
rows = append(rows, appRow)
|
||||
|
||||
if bu.Role == "reseller" {
|
||||
rows = append(rows, []tgInlineButton{btn("👑 Área do Revendedor", "res:menu")})
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, text, kb(rows...))
|
||||
}
|
||||
|
||||
// ---------- Plan list (buy / reseller create) ----------
|
||||
|
||||
func (b *Bot) showPlanList(chatID, msgID int64, _ string, action string) {
|
||||
plans, err := b.store.ListPlans(b.ctx, true)
|
||||
if err != nil || len(plans) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum plano disponível no momento.", backKeyboard())
|
||||
return
|
||||
}
|
||||
isReseller := action == "res:create"
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range plans {
|
||||
icon := "🔒"
|
||||
if p.Kind == "xray" {
|
||||
icon = "⚡"
|
||||
}
|
||||
var label string
|
||||
if isReseller {
|
||||
label = fmt.Sprintf("%s %s — %d créd.", icon, p.Name, p.CreditCost)
|
||||
} else {
|
||||
label = fmt.Sprintf("%s %s — %s", icon, p.Name, centsToBRL(p.PriceCents))
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn(label, action+":"+botItoa(p.ID))})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", backTarget(action))})
|
||||
title := "💎 Escolha um plano:"
|
||||
if isReseller {
|
||||
title = "➕ Escolha um plano para criar (custo em créditos):"
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, title, kb(rows...))
|
||||
}
|
||||
|
||||
func backTarget(action string) string {
|
||||
if strings.HasPrefix(action, "res:") {
|
||||
return "res:menu"
|
||||
}
|
||||
return "menu:main"
|
||||
}
|
||||
|
||||
// ---------- Buy ----------
|
||||
|
||||
func (b *Bot) startPlanPurchase(chatID int64, from *tgUser, planIDStr string, _ bool) {
|
||||
id, _ := strconv.Atoi(planIDStr)
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "plan_purchase", "Compra: "+p.Name, p.PriceCents, &pid, nil, 0, "")
|
||||
}
|
||||
|
||||
// ---------- Renew ----------
|
||||
|
||||
func (b *Bot) showRenewList(chatID int64, from *tgUser, msgID int64) {
|
||||
txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 100)
|
||||
seen := map[string]bool{}
|
||||
var rows [][]tgInlineButton
|
||||
for _, t := range txns {
|
||||
if t.Status != "approved" || t.TargetUsername == "" || t.PlanID == nil {
|
||||
continue
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *t.PlanID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
key := p.Kind + ":" + t.TargetUsername
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
short := "s"
|
||||
if p.Kind == "xray" {
|
||||
short = "x"
|
||||
}
|
||||
disp := t.TargetUsername
|
||||
if len(disp) > 16 {
|
||||
disp = disp[:8] + "…"
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("🔄 "+disp+" ("+p.Kind+")", "renew:"+short+":"+t.TargetUsername)})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Você não tem contas para renovar.", backKeyboard())
|
||||
return
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "menu:main")})
|
||||
b.sendOrEdit(chatID, msgID, "🔄 Selecione a conta para renovar:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startRenew(chatID int64, from *tgUser, msgID int64, target string) {
|
||||
kind := "ssh"
|
||||
if strings.HasPrefix(target, "x:") {
|
||||
kind = "xray"
|
||||
}
|
||||
plans, _ := b.store.ListPlans(b.ctx, true)
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range plans {
|
||||
if p.Kind != kind {
|
||||
continue
|
||||
}
|
||||
label := fmt.Sprintf("%s — %s", p.Name, centsToBRL(p.PriceCents))
|
||||
rows = append(rows, []tgInlineButton{btn(label, "rnw:"+botItoa(p.ID)+":"+target)})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum plano de renovação disponível.", backKeyboard())
|
||||
return
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "renew")})
|
||||
b.sendOrEdit(chatID, msgID, "🔄 Escolha a duração da renovação:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startRenewPayment(chatID int64, from *tgUser, payload string) {
|
||||
parts := strings.SplitN(payload, ":", 3)
|
||||
if len(parts) < 3 {
|
||||
b.send(chatID, "Renovação inválida.", backKeyboard())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(parts[0])
|
||||
renewTarget := parts[1] + ":" + parts[2] // "s:username" or "x:uuid"
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "plan_renewal", "Renovação: "+p.Name, p.PriceCents, &pid, nil, 0, renewTarget)
|
||||
}
|
||||
|
||||
// ---------- Trial ----------
|
||||
|
||||
func (b *Bot) handleTrial(chatID int64, from *tgUser) {
|
||||
if !b.cfg.TrialEnabled {
|
||||
b.send(chatID, "Teste grátis indisponível.", backKeyboard())
|
||||
return
|
||||
}
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.TrialUsed {
|
||||
b.send(chatID, "⚠️ Você já utilizou seu teste grátis.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(b.cfg.TrialHours) * time.Hour)
|
||||
if b.cfg.TrialKind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, b.cfg.TrialInboundTag, "", exp, b.cfg.TrialMaxConnections, "", b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
log.Printf("[bot] trial xray: %v", err)
|
||||
b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard())
|
||||
return
|
||||
}
|
||||
_ = b.store.SetBotUserTrialUsed(b.ctx, from.ID)
|
||||
b.send(chatID, b.formatXrayDelivery("Teste Grátis", uuid, link, exp), backKeyboard())
|
||||
return
|
||||
}
|
||||
user := genUsername("test")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, b.cfg.TrialMaxConnections, 0, 0, ""); err != nil {
|
||||
log.Printf("[bot] trial ssh: %v", err)
|
||||
b.send(chatID, "❌ Falha ao criar teste. Tente mais tarde.", backKeyboard())
|
||||
return
|
||||
}
|
||||
_ = b.store.SetBotUserTrialUsed(b.ctx, from.ID)
|
||||
b.send(chatID, b.formatSSHDelivery("Teste Grátis", user, pass, exp), backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Purchases ----------
|
||||
|
||||
func (b *Bot) showPurchases(chatID int64, from *tgUser, msgID int64) {
|
||||
txns, _ := b.store.ListUserTransactions(b.ctx, from.ID, 20)
|
||||
var sb strings.Builder
|
||||
sb.WriteString("🛍️ <b>Suas Compras</b>\n\n")
|
||||
count := 0
|
||||
for _, t := range txns {
|
||||
if t.Status == "pending" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
sb.WriteString(fmt.Sprintf("• #%d %s — %s — %s\n", t.ID, txnTypeLabel(t.Type), centsToBRL(t.AmountCents), statusLabel(t.Status)))
|
||||
if t.TargetUsername != "" && t.Status == "approved" {
|
||||
sb.WriteString(" conta: <code>" + htmlEscape(t.TargetUsername) + "</code>\n")
|
||||
}
|
||||
}
|
||||
if count == 0 {
|
||||
sb.WriteString("Nenhuma compra ainda.")
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, sb.String(), backKeyboard())
|
||||
}
|
||||
|
||||
// ---------- Reseller ----------
|
||||
|
||||
func (b *Bot) showResellerMenu(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" {
|
||||
b.sendOrEdit(chatID, msgID, "Você não é um revendedor.", backKeyboard())
|
||||
return
|
||||
}
|
||||
text := fmt.Sprintf("👑 <b>Área do Revendedor</b>\n\n💳 Saldo: <b>%d créditos</b>", bu.CreditBalance)
|
||||
rows := [][]tgInlineButton{
|
||||
{btn("💳 Recarregar Créditos", "res:topup")},
|
||||
{btn("➕ Criar Conta", "res:create")},
|
||||
{btn("👥 Meus Clientes", "res:clients")},
|
||||
{btn("⬅️ Voltar", "menu:main")},
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, text, kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) showCreditPackages(chatID, msgID int64) {
|
||||
pkgs, _ := b.store.ListCreditPackages(b.ctx, true)
|
||||
if len(pkgs) == 0 {
|
||||
b.sendOrEdit(chatID, msgID, "Nenhum pacote de créditos disponível.", kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")}))
|
||||
return
|
||||
}
|
||||
var rows [][]tgInlineButton
|
||||
for _, p := range pkgs {
|
||||
rows = append(rows, []tgInlineButton{btn(fmt.Sprintf("%s — %d créd. — %s", p.Name, p.Credits, centsToBRL(p.PriceCents)), "res:topup:"+botItoa(p.ID))})
|
||||
}
|
||||
rows = append(rows, []tgInlineButton{btn("⬅️ Voltar", "res:menu")})
|
||||
b.sendOrEdit(chatID, msgID, "💳 Escolha um pacote de créditos:", kb(rows...))
|
||||
}
|
||||
|
||||
func (b *Bot) startTopup(chatID int64, from *tgUser, pkgIDStr string) {
|
||||
id, _ := strconv.Atoi(pkgIDStr)
|
||||
p, err := b.store.GetCreditPackage(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Pacote não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
pid := p.ID
|
||||
b.createAndSendPix(chatID, from, "credit_topup", "Recarga: "+p.Name, p.PriceCents, nil, &pid, p.Credits, "")
|
||||
}
|
||||
|
||||
func (b *Bot) resellerCreateAccount(chatID int64, from *tgUser, planIDStr string) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" || bu.LinkedAdminUsername == "" {
|
||||
b.send(chatID, "Conta de revendedor não configurada.", backKeyboard())
|
||||
return
|
||||
}
|
||||
id, _ := strconv.Atoi(planIDStr)
|
||||
p, err := b.store.GetPlan(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Plano não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if bu.CreditBalance < p.CreditCost {
|
||||
b.send(chatID, fmt.Sprintf("❌ Saldo insuficiente. Necessário %d créditos, você tem %d.", p.CreditCost, bu.CreditBalance),
|
||||
kb([]tgInlineButton{btn("💳 Recarregar", "res:topup"), btn("⬅️ Voltar", "res:menu")}))
|
||||
return
|
||||
}
|
||||
if owner, ok := adminUsers.get(bu.LinkedAdminUsername); ok && owner.MaxUsers > 0 &&
|
||||
countOwnedQuota(b.ctx, b.store, bu.LinkedAdminUsername) >= owner.MaxUsers {
|
||||
b.send(chatID, fmt.Sprintf("❌ Limite de contas atingido (%d).", owner.MaxUsers), backKeyboard())
|
||||
return
|
||||
}
|
||||
// Debit first; refund if provisioning fails.
|
||||
if _, err := b.store.AdjustCredits(b.ctx, from.ID, -p.CreditCost, "account_create", nil); err != nil {
|
||||
b.send(chatID, "❌ Não foi possível debitar créditos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour)
|
||||
var deliver string
|
||||
if p.Kind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, bu.LinkedAdminUsername, b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
_, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil)
|
||||
b.send(chatID, "❌ Falha ao criar conta Xray. Créditos devolvidos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
deliver = b.formatXrayDelivery(p.Name, uuid, link, exp)
|
||||
} else {
|
||||
user := genUsername("r")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, bu.LinkedAdminUsername); err != nil {
|
||||
_, _ = b.store.AdjustCredits(b.ctx, from.ID, p.CreditCost, "refund", nil)
|
||||
b.send(chatID, "❌ Falha ao criar conta SSH. Créditos devolvidos.", backKeyboard())
|
||||
return
|
||||
}
|
||||
deliver = b.formatSSHDelivery(p.Name, user, pass, exp)
|
||||
}
|
||||
b.send(chatID, deliver+fmt.Sprintf("\n\n💳 Saldo restante: %d créditos", bu.CreditBalance-p.CreditCost),
|
||||
kb([]tgInlineButton{btn("➕ Criar outra", "res:create"), btn("⬅️ Voltar", "res:menu")}))
|
||||
}
|
||||
|
||||
func (b *Bot) showResellerClients(chatID int64, from *tgUser, msgID int64) {
|
||||
bu := b.botUser(from.ID)
|
||||
if bu.Role != "reseller" || bu.LinkedAdminUsername == "" {
|
||||
b.sendOrEdit(chatID, msgID, "Conta de revendedor não configurada.", backKeyboard())
|
||||
return
|
||||
}
|
||||
var sb strings.Builder
|
||||
sb.WriteString("👥 <b>Seus Clientes</b>\n\n")
|
||||
n := 0
|
||||
for _, u := range userMgr.List() {
|
||||
if u.Cfg.OwnerUsername == bu.LinkedAdminUsername {
|
||||
n++
|
||||
exp := "sem validade"
|
||||
if u.ExpiresAt != nil {
|
||||
exp = u.ExpiresAt.Format("02/01/2006")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• SSH <code>%s</code> — %s\n", htmlEscape(u.Cfg.Username), exp))
|
||||
if n >= 40 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
xs, _ := b.store.ListXrayClientsByOwner(b.ctx, bu.LinkedAdminUsername)
|
||||
for _, x := range xs {
|
||||
n++
|
||||
exp := "sem validade"
|
||||
if x.ExpiresAt != nil {
|
||||
exp = x.ExpiresAt.Format("02/01/2006")
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("• Xray <code>%s</code> — %s\n", htmlEscape(x.UUID), exp))
|
||||
if n >= 80 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if n == 0 {
|
||||
sb.WriteString("Nenhum cliente ainda.")
|
||||
}
|
||||
b.sendOrEdit(chatID, msgID, sb.String(), kb([]tgInlineButton{btn("⬅️ Voltar", "res:menu")}))
|
||||
}
|
||||
|
||||
// ---------- PIX charge creation + delivery formatting ----------
|
||||
|
||||
func (b *Bot) createAndSendPix(chatID int64, from *tgUser, ttype, description string, amountCents int, planID, pkgID *int, credits int, renewTarget string) {
|
||||
if amountCents <= 0 {
|
||||
b.send(chatID, "❌ Este item não tem preço configurado. Fale com o suporte.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
b.send(chatID, "❌ Pagamento não configurado no momento. Fale com o suporte.", backKeyboard())
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(b.cfg.PixExpirationMinutes) * time.Minute)
|
||||
pix, err := b.mp.CreatePixPayment(b.ctx, amountCents, description, "", strconv.FormatInt(from.ID, 10), exp, uuidV4())
|
||||
if err != nil {
|
||||
log.Printf("[bot] create pix: %v", err)
|
||||
b.send(chatID, "❌ Falha ao gerar o pagamento PIX. Tente novamente em instantes.", backKeyboard())
|
||||
return
|
||||
}
|
||||
txn := &BotTransaction{
|
||||
TelegramID: from.ID,
|
||||
Type: ttype,
|
||||
PlanID: planID,
|
||||
PackageID: pkgID,
|
||||
Credits: credits,
|
||||
AmountCents: amountCents,
|
||||
MPPaymentID: pix.PaymentID,
|
||||
MPQRCode: pix.QRCode,
|
||||
MPQRBase64: pix.QRBase64,
|
||||
Status: "pending",
|
||||
RenewTarget: renewTarget,
|
||||
ExpiresAt: &exp,
|
||||
}
|
||||
if err := b.store.CreateTransaction(b.ctx, txn); err != nil {
|
||||
log.Printf("[bot] create txn: %v", err)
|
||||
b.send(chatID, "❌ Erro interno ao registrar o pagamento.", backKeyboard())
|
||||
return
|
||||
}
|
||||
b.sendPixMessage(chatID, txn, description)
|
||||
}
|
||||
|
||||
func (b *Bot) sendPixMessage(chatID int64, txn *BotTransaction, description string) {
|
||||
caption := fmt.Sprintf("💳 <b>Pagamento PIX</b>\n%s\nValor: <b>%s</b>\n⏱ Validade: %d min\n\nEscaneie o QR acima ou use o código copia-e-cola abaixo. A liberação é automática após o pagamento.",
|
||||
htmlEscape(description), centsToBRL(txn.AmountCents), b.cfg.PixExpirationMinutes)
|
||||
kbd := kb(
|
||||
[]tgInlineButton{btn("✅ Já paguei / Verificar", "pay:check:"+botItoa(txn.ID))},
|
||||
[]tgInlineButton{btn("⬅️ Voltar", "menu:main")},
|
||||
)
|
||||
sent := false
|
||||
if txn.MPQRBase64 != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(txn.MPQRBase64); err == nil {
|
||||
if _, err := b.tg.sendPhotoBytes(b.ctx, chatID, raw, "pix.png", caption, kbd); err == nil {
|
||||
sent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sent {
|
||||
b.send(chatID, caption, kbd)
|
||||
}
|
||||
if txn.MPQRCode != "" {
|
||||
b.send(chatID, "📋 <b>PIX Copia e Cola:</b>\n<code>"+htmlEscape(txn.MPQRCode)+"</code>", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) formatSSHDelivery(planName, user, pass string, exp time.Time) string {
|
||||
host := b.cfg.PublicHost
|
||||
if host == "" {
|
||||
host = "(configure o host no painel)"
|
||||
}
|
||||
return fmt.Sprintf("✅ <b>%s</b>\n\n🔒 <b>Conta SSH</b>\nHost: <code>%s</code>\nUsuário: <code>%s</code>\nSenha: <code>%s</code>\nValidade: %s",
|
||||
htmlEscape(planName), htmlEscape(host), htmlEscape(user), htmlEscape(pass), exp.Format("02/01/2006 15:04"))
|
||||
}
|
||||
|
||||
func (b *Bot) formatXrayDelivery(planName, uuid, link string, exp time.Time) string {
|
||||
return fmt.Sprintf("✅ <b>%s</b>\n\n⚡ <b>Conta Xray</b>\nUUID: <code>%s</code>\nValidade: %s\n\n🔗 Link de conexão:\n<code>%s</code>",
|
||||
htmlEscape(planName), htmlEscape(uuid), exp.Format("02/01/2006 15:04"), htmlEscape(link))
|
||||
}
|
||||
|
||||
// ---------- Admin commands (in-chat convenience) ----------
|
||||
|
||||
func (b *Bot) cmdAdminStats(chatID int64) {
|
||||
users, _ := b.store.ListBotUsers(b.ctx)
|
||||
pend, _ := b.store.ListPendingTransactions(b.ctx)
|
||||
b.send(chatID, fmt.Sprintf("📊 <b>Estatísticas</b>\nUsuários do bot: %d\nPagamentos pendentes: %d\nContas SSH ativas: %d",
|
||||
len(users), len(pend), len(userMgr.List())), nil)
|
||||
}
|
||||
|
||||
func (b *Bot) cmdAdminAddCredit(chatID int64, text string) {
|
||||
f := strings.Fields(text)
|
||||
if len(f) < 3 {
|
||||
b.send(chatID, "Uso: /addcredit <telegram_id> <quantidade>", nil)
|
||||
return
|
||||
}
|
||||
tid, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
amt, _ := strconv.Atoi(f[2])
|
||||
bal, err := b.store.AdjustCredits(b.ctx, tid, amt, "admin_adjust", nil)
|
||||
if err != nil {
|
||||
b.send(chatID, "Erro: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
b.send(chatID, fmt.Sprintf("✅ Ajuste aplicado. Novo saldo de %d: %d créditos", tid, bal), nil)
|
||||
b.notify(tid, fmt.Sprintf("💳 Seu saldo foi ajustado em %+d créditos. Saldo atual: %d", amt, bal))
|
||||
}
|
||||
|
||||
func (b *Bot) notify(telegramID int64, text string) {
|
||||
if _, err := b.tg.sendMessage(b.ctx, telegramID, text, nil); err != nil {
|
||||
log.Printf("[bot] notify %d: %v", telegramID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- labels ----------
|
||||
|
||||
func txnTypeLabel(t string) string {
|
||||
switch t {
|
||||
case "plan_purchase":
|
||||
return "Compra"
|
||||
case "plan_renewal":
|
||||
return "Renovação"
|
||||
case "credit_topup":
|
||||
return "Recarga"
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func statusLabel(s string) string {
|
||||
switch s {
|
||||
case "approved":
|
||||
return "✅ pago"
|
||||
case "pending":
|
||||
return "⏳ pendente"
|
||||
case "expired":
|
||||
return "⌛ expirado"
|
||||
case "refunded":
|
||||
return "↩️ estornado"
|
||||
case "error":
|
||||
return "❌ erro"
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
// bot_mercadopago.go — Mercado Pago PIX client + inbound webhook handler.
|
||||
// Built on net/http; no third-party dependency.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const mpAPIBase = "https://api.mercadopago.com"
|
||||
|
||||
type mpClient struct {
|
||||
accessToken string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newMPClient(accessToken string) *mpClient {
|
||||
return &mpClient{accessToken: accessToken, hc: &http.Client{Timeout: 25 * time.Second}}
|
||||
}
|
||||
|
||||
// mpPixResult holds what the bot needs to show the buyer.
|
||||
type mpPixResult struct {
|
||||
PaymentID string
|
||||
QRCode string // copy-and-paste PIX string
|
||||
QRBase64 string // PNG image, base64 (no data: prefix)
|
||||
Status string
|
||||
}
|
||||
|
||||
// CreatePixPayment creates a PIX charge and returns the QR data.
|
||||
// amountCents is BRL cents; expiresAt bounds the QR validity.
|
||||
func (c *mpClient) CreatePixPayment(ctx context.Context, amountCents int, description, payerEmail, externalRef string, expiresAt time.Time, idempotencyKey string) (*mpPixResult, error) {
|
||||
if payerEmail == "" {
|
||||
payerEmail = "comprador@example.com"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"transaction_amount": float64(amountCents) / 100.0,
|
||||
"description": description,
|
||||
"payment_method_id": "pix",
|
||||
"payer": map[string]interface{}{"email": payerEmail},
|
||||
"date_of_expiration": expiresAt.Format("2006-01-02T15:04:05.000-07:00"),
|
||||
"external_reference": externalRef,
|
||||
}
|
||||
raw, err := c.do(ctx, http.MethodPost, "/v1/payments", body, idempotencyKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp struct {
|
||||
ID json.Number `json:"id"`
|
||||
Status string `json:"status"`
|
||||
PointOfInteraction struct {
|
||||
TransactionData struct {
|
||||
QRCode string `json:"qr_code"`
|
||||
QRCodeBase64 string `json:"qr_code_base64"`
|
||||
} `json:"transaction_data"`
|
||||
} `json:"point_of_interaction"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return nil, fmt.Errorf("mp create payment: parse: %w", err)
|
||||
}
|
||||
if resp.ID.String() == "" {
|
||||
return nil, fmt.Errorf("mp create payment: no id in response: %s", string(raw))
|
||||
}
|
||||
return &mpPixResult{
|
||||
PaymentID: resp.ID.String(),
|
||||
QRCode: resp.PointOfInteraction.TransactionData.QRCode,
|
||||
QRBase64: resp.PointOfInteraction.TransactionData.QRCodeBase64,
|
||||
Status: resp.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetPaymentStatus returns the current status of a payment (e.g. "approved").
|
||||
func (c *mpClient) GetPaymentStatus(ctx context.Context, paymentID string) (string, error) {
|
||||
raw, err := c.do(ctx, http.MethodGet, "/v1/payments/"+paymentID, nil, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Status, nil
|
||||
}
|
||||
|
||||
func (c *mpClient) do(ctx context.Context, method, path string, body interface{}, idempotencyKey string) ([]byte, error) {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, mpAPIBase+path, rdr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.accessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if idempotencyKey != "" {
|
||||
req.Header.Set("X-Idempotency-Key", idempotencyKey)
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("mercado pago %s %s: http %d: %s", method, path, resp.StatusCode, string(data))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// verifyMPSignature validates the x-signature header per Mercado Pago's spec.
|
||||
// Manifest: "id:<dataID>;request-id:<x-request-id>;ts:<ts>;" HMAC-SHA256(secret).
|
||||
func verifyMPSignature(xSignature, xRequestID, dataID, secret string) bool {
|
||||
if secret == "" {
|
||||
return false
|
||||
}
|
||||
var ts, v1 string
|
||||
for _, part := range strings.Split(xSignature, ",") {
|
||||
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(kv[0]) {
|
||||
case "ts":
|
||||
ts = strings.TrimSpace(kv[1])
|
||||
case "v1":
|
||||
v1 = strings.TrimSpace(kv[1])
|
||||
}
|
||||
}
|
||||
if ts == "" || v1 == "" {
|
||||
return false
|
||||
}
|
||||
if timestamp, err := strconv.ParseInt(ts, 10, 64); err != nil || timestamp <= 0 {
|
||||
return false
|
||||
}
|
||||
parts := make([]string, 0, 3)
|
||||
if dataID != "" {
|
||||
parts = append(parts, "id:"+strings.ToLower(dataID))
|
||||
}
|
||||
if xRequestID != "" {
|
||||
parts = append(parts, "request-id:"+xRequestID)
|
||||
}
|
||||
parts = append(parts, "ts:"+ts)
|
||||
manifest := strings.Join(parts, ";") + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(manifest))
|
||||
expected := hex.EncodeToString(mac.Sum(nil))
|
||||
return hmac.Equal([]byte(expected), []byte(v1))
|
||||
}
|
||||
|
||||
// handleMPWebhook is the public endpoint Mercado Pago calls on payment events.
|
||||
// It never trusts the body: it re-fetches the payment and fulfills idempotently.
|
||||
func handleMPWebhook(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
b := currentBot()
|
||||
if b == nil {
|
||||
w.WriteHeader(http.StatusOK) // bot disabled; acknowledge to stop retries
|
||||
return
|
||||
}
|
||||
// Only honor webhooks when confirmation mode is "webhook". In polling mode
|
||||
// the poller drives fulfillment; ignore unsolicited posts to this endpoint.
|
||||
if b.cfg.MPConfirmMode != "webhook" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
// Extract the payment id from body or query.
|
||||
dataID := r.URL.Query().Get("data.id")
|
||||
if dataID == "" {
|
||||
dataID = r.URL.Query().Get("id")
|
||||
}
|
||||
signatureDataID := dataID
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Action string `json:"action"`
|
||||
Data struct {
|
||||
ID json.Number `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid webhook body", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
if len(body) > 0 {
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
if dataID == "" {
|
||||
dataID = payload.Data.ID.String()
|
||||
}
|
||||
}
|
||||
if dataID == "" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
if len(dataID) > 32 {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, char := range dataID {
|
||||
if char < '0' || char > '9' {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !verifyMPSignature(r.Header.Get("x-signature"), r.Header.Get("x-request-id"), signatureDataID, b.cfg.MPWebhookSecret) {
|
||||
log.Printf("[bot] MP webhook: invalid signature for payment %s", dataID)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Acknowledge immediately; process in the background so MP doesn't time out.
|
||||
go b.processPaymentByMPID(dataID)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// centsToBRL formats cents as "R$ 12,34".
|
||||
func centsToBRL(cents int) string {
|
||||
reais := cents / 100
|
||||
cent := cents % 100
|
||||
return "R$ " + strconv.Itoa(reais) + "," + fmt.Sprintf("%02d", cent)
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
// bot_payments.go — payment polling, webhook processing, and idempotent delivery.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Polling mode ----------
|
||||
|
||||
func (b *Bot) runPaymentPoller() {
|
||||
d, err := time.ParseDuration(b.cfg.MPPollInterval)
|
||||
if err != nil || d < 5*time.Second {
|
||||
d = 20 * time.Second
|
||||
}
|
||||
t := time.NewTicker(d)
|
||||
defer t.Stop()
|
||||
log.Printf("[bot] payment poller started (interval=%s)", d)
|
||||
for {
|
||||
select {
|
||||
case <-b.ctx.Done():
|
||||
log.Printf("[bot] payment poller stopped")
|
||||
return
|
||||
case <-t.C:
|
||||
b.pollPending()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) pollPending() {
|
||||
txns, err := b.store.ListPendingTransactions(b.ctx)
|
||||
if err != nil {
|
||||
log.Printf("[bot] poll list: %v", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, txn := range txns {
|
||||
if txn.ExpiresAt != nil && now.After(*txn.ExpiresAt) {
|
||||
_ = b.store.SetTransactionStatus(b.ctx, txn.ID, "expired")
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("⌛ O PIX do pedido #%d expirou. Gere um novo pagamento se ainda quiser.", txn.ID))
|
||||
continue
|
||||
}
|
||||
if b.mp == nil {
|
||||
continue
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Webhook mode ----------
|
||||
|
||||
// processPaymentByMPID is invoked from the Mercado Pago webhook handler.
|
||||
func (b *Bot) processPaymentByMPID(mpID string) {
|
||||
txn, err := b.store.GetTransactionByMPID(b.ctx, mpID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] webhook: no txn for mp payment %s: %v", mpID, err)
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
return
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, mpID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] webhook: get status %s: %v", mpID, err)
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Idempotent fulfillment ----------
|
||||
|
||||
// tryFulfill flips the txn to approved exactly once, then delivers.
|
||||
func (b *Bot) tryFulfill(txnID int) {
|
||||
ok, err := b.store.MarkTransactionApproved(b.ctx, txnID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] mark approved %d: %v", txnID, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
return // already fulfilled by another path
|
||||
}
|
||||
txn, err := b.store.GetTransaction(b.ctx, txnID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill get txn %d: %v", txnID, err)
|
||||
return
|
||||
}
|
||||
b.fulfillTransaction(txn)
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillTransaction(txn *BotTransaction) {
|
||||
switch txn.Type {
|
||||
case "credit_topup":
|
||||
bal, err := b.store.AdjustCredits(b.ctx, txn.TelegramID, txn.Credits, "topup", &txn.ID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] topup credit %d: %v", txn.ID, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao creditar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ Recarga aprovada! +%d créditos.\n💳 Saldo atual: %d créditos.", txn.Credits, bal))
|
||||
case "plan_renewal":
|
||||
b.fulfillRenewal(txn)
|
||||
default:
|
||||
b.fulfillPurchase(txn)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillPurchase(txn *BotTransaction) {
|
||||
if txn.PlanID == nil {
|
||||
return
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *txn.PlanID)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill purchase: plan %v: %v", txn.PlanID, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas o plano não foi encontrado. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
exp := time.Now().Add(time.Duration(p.Days) * 24 * time.Hour)
|
||||
if p.Kind == "xray" {
|
||||
uuid, link, err := createXrayClient(b.ctx, b.store, p.XrayInboundTag, p.XrayProtocol, exp, p.MaxConnections, "", b.cfg.XrayPublicHost)
|
||||
if err != nil {
|
||||
log.Printf("[bot] fulfill xray: %v", err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta Xray. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, uuid)
|
||||
b.notify(txn.TelegramID, b.formatXrayDelivery(p.Name, uuid, link, exp))
|
||||
return
|
||||
}
|
||||
user := genUsername("ssh")
|
||||
pass := genPassword()
|
||||
if err := createSSHUser(b.ctx, b.store, user, pass, exp, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown, ""); err != nil {
|
||||
log.Printf("[bot] fulfill ssh: %v", err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao criar a conta SSH. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, user)
|
||||
b.notify(txn.TelegramID, b.formatSSHDelivery(p.Name, user, pass, exp))
|
||||
}
|
||||
|
||||
func (b *Bot) fulfillRenewal(txn *BotTransaction) {
|
||||
if txn.PlanID == nil || txn.RenewTarget == "" {
|
||||
return
|
||||
}
|
||||
p, err := b.store.GetPlan(b.ctx, *txn.PlanID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
parts := strings.SplitN(txn.RenewTarget, ":", 2)
|
||||
if len(parts) < 2 {
|
||||
return
|
||||
}
|
||||
kind, id := parts[0], parts[1]
|
||||
base := time.Now()
|
||||
add := time.Duration(p.Days) * 24 * time.Hour
|
||||
|
||||
if kind == "x" {
|
||||
newExp := base.Add(add)
|
||||
if meta, err := b.store.GetXrayClientMeta(b.ctx, id); err == nil && meta.ExpiresAt != nil && meta.ExpiresAt.After(base) {
|
||||
newExp = meta.ExpiresAt.Add(add)
|
||||
}
|
||||
if err := renewXrayClient(b.ctx, b.store, id, newExp); err != nil {
|
||||
log.Printf("[bot] renew xray %s: %v", id, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, id)
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ <b>%s</b> renovado!\n⚡ Xray <code>%s</code>\nNova validade: %s",
|
||||
htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04")))
|
||||
return
|
||||
}
|
||||
|
||||
newExp := base.Add(add)
|
||||
if u, ok := userMgr.Get(id); ok && u.ExpiresAt != nil && u.ExpiresAt.After(base) {
|
||||
newExp = u.ExpiresAt.Add(add)
|
||||
}
|
||||
if err := renewSSHUser(b.ctx, b.store, id, newExp); err != nil {
|
||||
log.Printf("[bot] renew ssh %s: %v", id, err)
|
||||
b.notify(txn.TelegramID, "❌ Pagamento aprovado, mas houve um erro ao renovar. Contate o suporte.")
|
||||
return
|
||||
}
|
||||
_ = b.store.SetTransactionTarget(b.ctx, txn.ID, id)
|
||||
b.notify(txn.TelegramID, fmt.Sprintf("✅ <b>%s</b> renovado!\n🔒 SSH <code>%s</code>\nNova validade: %s",
|
||||
htmlEscape(p.Name), htmlEscape(id), newExp.Format("02/01/2006 15:04")))
|
||||
}
|
||||
|
||||
// ---------- "Verificar" button ----------
|
||||
|
||||
func (b *Bot) checkPaymentButton(chatID int64, from *tgUser, txnIDStr string) {
|
||||
id, _ := strconv.Atoi(txnIDStr)
|
||||
txn, err := b.store.GetTransaction(b.ctx, id)
|
||||
if err != nil {
|
||||
b.send(chatID, "Pagamento não encontrado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if txn.TelegramID != from.ID {
|
||||
b.send(chatID, "Pagamento inválido.", backKeyboard())
|
||||
return
|
||||
}
|
||||
switch txn.Status {
|
||||
case "approved":
|
||||
b.send(chatID, "✅ Pagamento já confirmado! Veja em 🛍️ Minhas Compras.", backKeyboard())
|
||||
return
|
||||
case "expired":
|
||||
b.send(chatID, "⌛ Este PIX expirou. Gere um novo pagamento.", backKeyboard())
|
||||
return
|
||||
case "refunded":
|
||||
b.send(chatID, "Este pagamento foi estornado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if b.mp == nil {
|
||||
b.send(chatID, "Pagamento não configurado.", backKeyboard())
|
||||
return
|
||||
}
|
||||
status, err := b.mp.GetPaymentStatus(b.ctx, txn.MPPaymentID)
|
||||
if err != nil {
|
||||
b.send(chatID, "Não foi possível verificar agora. Tente novamente em instantes.", backKeyboard())
|
||||
return
|
||||
}
|
||||
if status == "approved" {
|
||||
b.tryFulfill(txn.ID) // delivers via notify
|
||||
return
|
||||
}
|
||||
b.send(chatID, "⏱ Pagamento ainda não identificado. Assim que cair, a liberação é automática.",
|
||||
kb([]tgInlineButton{btn("🔄 Verificar novamente", "pay:check:"+botItoa(txn.ID)), btn("⬅️ Voltar", "menu:main")}))
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package main
|
||||
|
||||
// bot_provision.go — bridges the bot to the panel's account creation.
|
||||
// SSH: Store.UpsertUser. Xray: xrayMgr.AddXrayClient + UpsertXrayClientMeta.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- credential generation ----------
|
||||
|
||||
const credAlphabet = "abcdefghijkmnpqrstuvwxyz23456789"
|
||||
|
||||
func randString(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
// extremely unlikely; fall back to a fixed-length timestamp-free filler
|
||||
for i := range b {
|
||||
b[i] = credAlphabet[0]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for i := range b {
|
||||
b[i] = credAlphabet[int(b[i])%len(credAlphabet)]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func genUsername(prefix string) string {
|
||||
if prefix == "" {
|
||||
prefix = "ssh"
|
||||
}
|
||||
return prefix + randString(6)
|
||||
}
|
||||
|
||||
func genPassword() string { return randString(10) }
|
||||
|
||||
func uuidV4() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
|
||||
}
|
||||
|
||||
// ---------- SSH ----------
|
||||
|
||||
func createSSHUser(ctx context.Context, store *Store, username, password string, expiresAt time.Time, maxConns, upMbps, downMbps int, owner string) error {
|
||||
cfg := UserConfig{
|
||||
Username: username,
|
||||
Password: password,
|
||||
MaxConnections: maxConns,
|
||||
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
|
||||
LimitMbpsUp: upMbps,
|
||||
LimitMbpsDown: downMbps,
|
||||
OwnerUsername: owner,
|
||||
}
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
reloadUsersFromDB(ctx, store)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renewSSHUser extends an existing SSH account's expiry, preserving credentials.
|
||||
func renewSSHUser(ctx context.Context, store *Store, username string, newExpiry time.Time) error {
|
||||
u, ok := userMgr.Get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("conta SSH %q não encontrada", username)
|
||||
}
|
||||
cfg := u.Cfg
|
||||
cfg.ExpiresAt = newExpiry.UTC().Format(time.RFC3339)
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
reloadUsersFromDB(ctx, store)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Xray ----------
|
||||
|
||||
func createXrayClient(ctx context.Context, store *Store, inboundTag, protocol string, expiresAt time.Time, maxConns int, owner, publicHost string) (uuid, link string, err error) {
|
||||
if inboundTag == "" {
|
||||
return "", "", fmt.Errorf("plano Xray sem inbound configurado")
|
||||
}
|
||||
uuid = uuidV4()
|
||||
email := "bot-" + uuid[:8]
|
||||
if err = xrayMgr.AddXrayClient(inboundTag, uuid, email); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
exp := expiresAt
|
||||
meta := XrayClientMeta{
|
||||
UUID: uuid,
|
||||
Name: email,
|
||||
Email: email,
|
||||
InboundTag: inboundTag,
|
||||
OwnerUsername: owner,
|
||||
MaxConns: maxConns,
|
||||
ExpiresAt: &exp,
|
||||
}
|
||||
if e := store.UpsertXrayClientMeta(ctx, meta); e != nil {
|
||||
// The client is already live in Xray; a metadata failure must not
|
||||
// abort delivery. Log and continue.
|
||||
log.Printf("[bot] xray meta save for %s: %v", uuid, e)
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
link = buildXrayLink(inboundTag, protocol, uuid, publicHost, email)
|
||||
return uuid, link, nil
|
||||
}
|
||||
|
||||
func renewXrayClient(ctx context.Context, store *Store, uuid string, newExpiry time.Time) error {
|
||||
meta, err := store.GetXrayClientMeta(ctx, uuid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cliente Xray não encontrado: %w", err)
|
||||
}
|
||||
exp := newExpiry
|
||||
meta.ExpiresAt = &exp
|
||||
return store.UpsertXrayClientMeta(ctx, *meta)
|
||||
}
|
||||
|
||||
// ---------- Xray connection link ----------
|
||||
|
||||
type xrayInboundDetail struct {
|
||||
Protocol string
|
||||
Port int
|
||||
Network string
|
||||
Security string
|
||||
Path string
|
||||
Host string
|
||||
SNI string
|
||||
ServiceName string
|
||||
}
|
||||
|
||||
// inboundDetail reads streamSettings for an inbound from the raw Xray config.
|
||||
func (m *XrayManager) inboundDetail(tag string) (*xrayInboundDetail, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
data, err := m.readConfigLocked()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []json.RawMessage `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, raw := range cfg.Inbounds {
|
||||
var ib struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port json.RawMessage `json:"port"`
|
||||
StreamSettings map[string]interface{} `json:"streamSettings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ib); err != nil {
|
||||
continue
|
||||
}
|
||||
if ib.Tag != tag {
|
||||
continue
|
||||
}
|
||||
d := &xrayInboundDetail{Protocol: strings.ToLower(ib.Protocol)}
|
||||
var pnum int
|
||||
if json.Unmarshal(ib.Port, &pnum) == nil {
|
||||
d.Port = pnum
|
||||
} else {
|
||||
var pstr string
|
||||
if json.Unmarshal(ib.Port, &pstr) == nil {
|
||||
d.Port, _ = strconv.Atoi(pstr)
|
||||
}
|
||||
}
|
||||
if ss := ib.StreamSettings; ss != nil {
|
||||
d.Network, _ = ss["network"].(string)
|
||||
d.Security, _ = ss["security"].(string)
|
||||
d.Path, d.Host, d.ServiceName = extractStreamParams(ss, d.Network)
|
||||
if tls, ok := ss["tlsSettings"].(map[string]interface{}); ok {
|
||||
d.SNI, _ = tls["serverName"].(string)
|
||||
}
|
||||
if rl, ok := ss["realitySettings"].(map[string]interface{}); ok {
|
||||
if sn, _ := rl["serverNames"].([]interface{}); len(sn) > 0 {
|
||||
d.SNI, _ = sn[0].(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
return nil, fmt.Errorf("inbound %q não encontrado", tag)
|
||||
}
|
||||
|
||||
func extractStreamParams(ss map[string]interface{}, network string) (path, host, serviceName string) {
|
||||
get := func(key, field string) string {
|
||||
if sub, ok := ss[key].(map[string]interface{}); ok {
|
||||
v, _ := sub[field].(string)
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
switch network {
|
||||
case "ws":
|
||||
return get("wsSettings", "path"), get("wsSettings", "host"), ""
|
||||
case "xhttp":
|
||||
return get("xhttpSettings", "path"), get("xhttpSettings", "host"), ""
|
||||
case "httpupgrade":
|
||||
return get("httpupgradeSettings", "path"), get("httpupgradeSettings", "host"), ""
|
||||
case "grpc":
|
||||
return "", "", get("grpcSettings", "serviceName")
|
||||
case "http", "h2":
|
||||
return get("httpSettings", "path"), get("httpSettings", "host"), ""
|
||||
}
|
||||
return "", "", ""
|
||||
}
|
||||
|
||||
// buildXrayLink assembles a shareable connection URI. Best-effort: if the config
|
||||
// can't be read it still returns a minimal link with host/port/uuid.
|
||||
func buildXrayLink(inboundTag, protocol, uuid, publicHost, label string) string {
|
||||
d, err := xrayMgr.inboundDetail(inboundTag)
|
||||
if err != nil || d == nil {
|
||||
if protocol == "" {
|
||||
protocol = "vless"
|
||||
}
|
||||
return fmt.Sprintf("%s://%s@%s#%s", protocol, uuid, publicHost, url.QueryEscape(label))
|
||||
}
|
||||
if protocol == "" {
|
||||
protocol = d.Protocol
|
||||
}
|
||||
host := publicHost
|
||||
if host == "" {
|
||||
host = d.SNI
|
||||
}
|
||||
addr := fmt.Sprintf("%s:%d", host, d.Port)
|
||||
|
||||
q := url.Values{}
|
||||
if d.Network != "" {
|
||||
q.Set("type", d.Network)
|
||||
}
|
||||
if d.Security != "" {
|
||||
q.Set("security", d.Security)
|
||||
}
|
||||
if d.Path != "" {
|
||||
q.Set("path", d.Path)
|
||||
}
|
||||
if d.Host != "" {
|
||||
q.Set("host", d.Host)
|
||||
}
|
||||
if d.SNI != "" {
|
||||
q.Set("sni", d.SNI)
|
||||
}
|
||||
if d.ServiceName != "" {
|
||||
q.Set("serviceName", d.ServiceName)
|
||||
}
|
||||
|
||||
switch protocol {
|
||||
case "vmess":
|
||||
conf := map[string]interface{}{
|
||||
"v": "2", "ps": label, "add": host, "port": strconv.Itoa(d.Port),
|
||||
"id": uuid, "aid": "0", "scy": "auto", "net": d.Network,
|
||||
"type": "none", "host": d.Host, "path": d.Path, "tls": d.Security, "sni": d.SNI,
|
||||
}
|
||||
b, _ := json.Marshal(conf)
|
||||
return "vmess://" + base64.StdEncoding.EncodeToString(b)
|
||||
default: // vless, trojan
|
||||
return fmt.Sprintf("%s://%s@%s?%s#%s", protocol, uuid, addr, q.Encode(), url.QueryEscape(label))
|
||||
}
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
package main
|
||||
|
||||
// bot_store.go — PostgreSQL persistence for the Telegram sales bot.
|
||||
//
|
||||
// Tables (all created idempotently by EnsureBotSchema):
|
||||
// bot_users — Telegram customers/resellers
|
||||
// bot_plans — sellable SSH/Xray plans
|
||||
// bot_credit_packages — reseller credit top-up packages
|
||||
// bot_transactions — PIX payments (Mercado Pago)
|
||||
// bot_credits_ledger — auditable credit movements
|
||||
// bot_settings — editable bot texts (key/value)
|
||||
// bot_config — single-row bot config with encrypted secrets
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ---------- Models ----------
|
||||
|
||||
type BotUser struct {
|
||||
TelegramID int64
|
||||
Username string
|
||||
FirstName string
|
||||
Role string // customer | reseller | blocked
|
||||
LinkedAdminUsername string
|
||||
CreditBalance int
|
||||
TrialUsed bool
|
||||
CreatedAt time.Time
|
||||
LastSeenAt time.Time
|
||||
}
|
||||
|
||||
type BotPlan struct {
|
||||
ID int
|
||||
Name string
|
||||
Kind string // ssh | xray
|
||||
Days int
|
||||
MaxConnections int
|
||||
LimitMbpsUp int
|
||||
LimitMbpsDown int
|
||||
XrayInboundTag string
|
||||
XrayProtocol string
|
||||
PriceCents int
|
||||
CreditCost int
|
||||
ServerID string
|
||||
IsActive bool
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type BotCreditPackage struct {
|
||||
ID int
|
||||
Name string
|
||||
Credits int
|
||||
PriceCents int
|
||||
IsActive bool
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
type BotTransaction struct {
|
||||
ID int
|
||||
TelegramID int64
|
||||
Type string // plan_purchase | plan_renewal | credit_topup
|
||||
PlanID *int
|
||||
PackageID *int
|
||||
Credits int
|
||||
AmountCents int
|
||||
MPPaymentID string
|
||||
MPQRCode string
|
||||
MPQRBase64 string
|
||||
Status string // pending | approved | expired | refunded | error
|
||||
TargetUsername string // account/uuid created or renewed
|
||||
RenewTarget string // for renewals: existing account/uuid to extend
|
||||
CreatedAt time.Time
|
||||
PaidAt *time.Time
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type BotLedgerEntry struct {
|
||||
ID int
|
||||
TelegramID int64
|
||||
Delta int
|
||||
Reason string
|
||||
RefTxnID *int
|
||||
BalanceAfter int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Schema ----------
|
||||
|
||||
func (s *Store) EnsureBotSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS bot_users (
|
||||
telegram_id BIGINT PRIMARY KEY,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
linked_admin_username TEXT NOT NULL DEFAULT '',
|
||||
credit_balance INT NOT NULL DEFAULT 0,
|
||||
trial_used BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_plans (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
days INT NOT NULL DEFAULT 30,
|
||||
max_connections INT NOT NULL DEFAULT 1,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0,
|
||||
limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
xray_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
xray_protocol TEXT NOT NULL DEFAULT '',
|
||||
price_cents INT NOT NULL DEFAULT 0,
|
||||
credit_cost INT NOT NULL DEFAULT 1,
|
||||
server_id TEXT NOT NULL DEFAULT '',
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_credit_packages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
credits INT NOT NULL DEFAULT 0,
|
||||
price_cents INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||
sort_order INT NOT NULL DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_transactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
plan_id INT,
|
||||
package_id INT,
|
||||
credits INT NOT NULL DEFAULT 0,
|
||||
amount_cents INT NOT NULL DEFAULT 0,
|
||||
mp_payment_id TEXT NOT NULL DEFAULT '',
|
||||
mp_qr_code TEXT NOT NULL DEFAULT '',
|
||||
mp_qr_base64 TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
target_username TEXT NOT NULL DEFAULT '',
|
||||
renew_target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
paid_at TIMESTAMPTZ,
|
||||
expires_at TIMESTAMPTZ
|
||||
)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx
|
||||
ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> ''`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_credits_ledger (
|
||||
id SERIAL PRIMARY KEY,
|
||||
telegram_id BIGINT NOT NULL,
|
||||
delta INT NOT NULL,
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
ref_transaction_id INT,
|
||||
balance_after INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS bot_config (
|
||||
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
telegram_mode TEXT NOT NULL DEFAULT 'polling',
|
||||
telegram_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
mp_confirm_mode TEXT NOT NULL DEFAULT 'polling',
|
||||
mp_poll_interval TEXT NOT NULL DEFAULT '20s',
|
||||
pix_expiration_minutes INT NOT NULL DEFAULT 30,
|
||||
trial_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
trial_hours INT NOT NULL DEFAULT 1,
|
||||
trial_max_connections INT NOT NULL DEFAULT 1,
|
||||
trial_kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
trial_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}',
|
||||
currency TEXT NOT NULL DEFAULT 'BRL',
|
||||
public_host TEXT NOT NULL DEFAULT '',
|
||||
xray_public_host TEXT NOT NULL DEFAULT '',
|
||||
telegram_token_enc BYTEA,
|
||||
telegram_webhook_secret_enc BYTEA,
|
||||
mp_access_token_enc BYTEA,
|
||||
mp_webhook_secret_enc BYTEA,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- bot_users ----------
|
||||
|
||||
// UpsertBotUser inserts a user or refreshes username/first_name/last_seen.
|
||||
// Role, credits, linkage and trial_used are preserved on update.
|
||||
func (s *Store) UpsertBotUser(ctx context.Context, u *BotUser) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO bot_users (telegram_id, username, first_name, last_seen_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
ON CONFLICT (telegram_id) DO UPDATE
|
||||
SET username = EXCLUDED.username,
|
||||
first_name = EXCLUDED.first_name,
|
||||
last_seen_at = NOW()`,
|
||||
u.TelegramID, u.Username, u.FirstName)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanBotUser(row interface{ Scan(...interface{}) error }) (*BotUser, error) {
|
||||
var u BotUser
|
||||
err := row.Scan(&u.TelegramID, &u.Username, &u.FirstName, &u.Role,
|
||||
&u.LinkedAdminUsername, &u.CreditBalance, &u.TrialUsed, &u.CreatedAt, &u.LastSeenAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
const botUserCols = `telegram_id, username, first_name, role, linked_admin_username, credit_balance, trial_used, created_at, last_seen_at`
|
||||
|
||||
func (s *Store) GetBotUser(ctx context.Context, telegramID int64) (*BotUser, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botUserCols+` FROM bot_users WHERE telegram_id=$1`, telegramID)
|
||||
return scanBotUser(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListBotUsers(ctx context.Context) ([]*BotUser, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+botUserCols+` FROM bot_users ORDER BY last_seen_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotUser
|
||||
for rows.Next() {
|
||||
u, err := scanBotUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetBotUserRole updates role and reseller linkage.
|
||||
func (s *Store) SetBotUserRole(ctx context.Context, telegramID int64, role, linkedAdmin string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_users SET role=$2, linked_admin_username=$3 WHERE telegram_id=$1`,
|
||||
telegramID, role, linkedAdmin)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetBotUserTrialUsed(ctx context.Context, telegramID int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_users SET trial_used=true WHERE telegram_id=$1`, telegramID)
|
||||
return err
|
||||
}
|
||||
|
||||
// AdjustCredits changes a reseller's balance atomically and writes a ledger row.
|
||||
// Returns the resulting balance. Fails (rolls back) if the balance would go negative.
|
||||
func (s *Store) AdjustCredits(ctx context.Context, telegramID int64, delta int, reason string, refTxn *int) (int, error) {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
var balance int
|
||||
if err := tx.QueryRowContext(ctx,
|
||||
`UPDATE bot_users SET credit_balance = credit_balance + $2
|
||||
WHERE telegram_id=$1 RETURNING credit_balance`,
|
||||
telegramID, delta).Scan(&balance); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if balance < 0 {
|
||||
return 0, errInsufficientCredits
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO bot_credits_ledger (telegram_id, delta, reason, ref_transaction_id, balance_after)
|
||||
VALUES ($1,$2,$3,$4,$5)`,
|
||||
telegramID, delta, reason, refTxn, balance); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListLedger(ctx context.Context, telegramID int64, limit int) ([]*BotLedgerEntry, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, telegram_id, delta, reason, ref_transaction_id, balance_after, created_at
|
||||
FROM bot_credits_ledger WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotLedgerEntry
|
||||
for rows.Next() {
|
||||
var e BotLedgerEntry
|
||||
if err := rows.Scan(&e.ID, &e.TelegramID, &e.Delta, &e.Reason, &e.RefTxnID, &e.BalanceAfter, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bot_plans ----------
|
||||
|
||||
const botPlanCols = `id, name, kind, days, max_connections, limit_mbps_up, limit_mbps_down, xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order`
|
||||
|
||||
func scanBotPlan(row interface{ Scan(...interface{}) error }) (*BotPlan, error) {
|
||||
var p BotPlan
|
||||
err := row.Scan(&p.ID, &p.Name, &p.Kind, &p.Days, &p.MaxConnections, &p.LimitMbpsUp, &p.LimitMbpsDown,
|
||||
&p.XrayInboundTag, &p.XrayProtocol, &p.PriceCents, &p.CreditCost, &p.ServerID, &p.IsActive, &p.SortOrder)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListPlans(ctx context.Context, onlyActive bool) ([]*BotPlan, error) {
|
||||
q := `SELECT ` + botPlanCols + ` FROM bot_plans`
|
||||
if onlyActive {
|
||||
q += ` WHERE is_active=true`
|
||||
}
|
||||
q += ` ORDER BY sort_order, id`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotPlan
|
||||
for rows.Next() {
|
||||
p, err := scanBotPlan(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetPlan(ctx context.Context, id int) (*BotPlan, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botPlanCols+` FROM bot_plans WHERE id=$1`, id)
|
||||
return scanBotPlan(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertPlan(ctx context.Context, p *BotPlan) error {
|
||||
if p.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO bot_plans (name, kind, days, max_connections, limit_mbps_up, limit_mbps_down,
|
||||
xray_inbound_tag, xray_protocol, price_cents, credit_cost, server_id, is_active, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id`,
|
||||
p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown,
|
||||
p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder,
|
||||
).Scan(&p.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
UPDATE bot_plans SET name=$2, kind=$3, days=$4, max_connections=$5, limit_mbps_up=$6, limit_mbps_down=$7,
|
||||
xray_inbound_tag=$8, xray_protocol=$9, price_cents=$10, credit_cost=$11, server_id=$12, is_active=$13, sort_order=$14
|
||||
WHERE id=$1`,
|
||||
p.ID, p.Name, p.Kind, p.Days, p.MaxConnections, p.LimitMbpsUp, p.LimitMbpsDown,
|
||||
p.XrayInboundTag, p.XrayProtocol, p.PriceCents, p.CreditCost, p.ServerID, p.IsActive, p.SortOrder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeletePlan(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bot_plans WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_credit_packages ----------
|
||||
|
||||
const botPkgCols = `id, name, credits, price_cents, is_active, sort_order`
|
||||
|
||||
func scanBotPkg(row interface{ Scan(...interface{}) error }) (*BotCreditPackage, error) {
|
||||
var p BotCreditPackage
|
||||
if err := row.Scan(&p.ID, &p.Name, &p.Credits, &p.PriceCents, &p.IsActive, &p.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (s *Store) ListCreditPackages(ctx context.Context, onlyActive bool) ([]*BotCreditPackage, error) {
|
||||
q := `SELECT ` + botPkgCols + ` FROM bot_credit_packages`
|
||||
if onlyActive {
|
||||
q += ` WHERE is_active=true`
|
||||
}
|
||||
q += ` ORDER BY sort_order, id`
|
||||
rows, err := s.db.QueryContext(ctx, q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotCreditPackage
|
||||
for rows.Next() {
|
||||
p, err := scanBotPkg(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) GetCreditPackage(ctx context.Context, id int) (*BotCreditPackage, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botPkgCols+` FROM bot_credit_packages WHERE id=$1`, id)
|
||||
return scanBotPkg(row)
|
||||
}
|
||||
|
||||
func (s *Store) UpsertCreditPackage(ctx context.Context, p *BotCreditPackage) error {
|
||||
if p.ID == 0 {
|
||||
return s.db.QueryRowContext(ctx,
|
||||
`INSERT INTO bot_credit_packages (name, credits, price_cents, is_active, sort_order)
|
||||
VALUES ($1,$2,$3,$4,$5) RETURNING id`,
|
||||
p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder).Scan(&p.ID)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_credit_packages SET name=$2, credits=$3, price_cents=$4, is_active=$5, sort_order=$6 WHERE id=$1`,
|
||||
p.ID, p.Name, p.Credits, p.PriceCents, p.IsActive, p.SortOrder)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteCreditPackage(ctx context.Context, id int) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM bot_credit_packages WHERE id=$1`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_transactions ----------
|
||||
|
||||
const botTxnCols = `id, telegram_id, type, plan_id, package_id, credits, amount_cents, mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, created_at, paid_at, expires_at`
|
||||
|
||||
func scanBotTxn(row interface{ Scan(...interface{}) error }) (*BotTransaction, error) {
|
||||
var t BotTransaction
|
||||
err := row.Scan(&t.ID, &t.TelegramID, &t.Type, &t.PlanID, &t.PackageID, &t.Credits, &t.AmountCents,
|
||||
&t.MPPaymentID, &t.MPQRCode, &t.MPQRBase64, &t.Status, &t.TargetUsername, &t.RenewTarget,
|
||||
&t.CreatedAt, &t.PaidAt, &t.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateTransaction(ctx context.Context, t *BotTransaction) error {
|
||||
return s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO bot_transactions (telegram_id, type, plan_id, package_id, credits, amount_cents,
|
||||
mp_payment_id, mp_qr_code, mp_qr_base64, status, target_username, renew_target, expires_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) RETURNING id, created_at`,
|
||||
t.TelegramID, t.Type, t.PlanID, t.PackageID, t.Credits, t.AmountCents,
|
||||
t.MPPaymentID, t.MPQRCode, t.MPQRBase64, t.Status, t.TargetUsername, t.RenewTarget, t.ExpiresAt,
|
||||
).Scan(&t.ID, &t.CreatedAt)
|
||||
}
|
||||
|
||||
func (s *Store) GetTransaction(ctx context.Context, id int) (*BotTransaction, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE id=$1`, id)
|
||||
return scanBotTxn(row)
|
||||
}
|
||||
|
||||
func (s *Store) GetTransactionByMPID(ctx context.Context, mpID string) (*BotTransaction, error) {
|
||||
row := s.db.QueryRowContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE mp_payment_id=$1`, mpID)
|
||||
return scanBotTxn(row)
|
||||
}
|
||||
|
||||
func (s *Store) ListPendingTransactions(ctx context.Context) ([]*BotTransaction, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+botTxnCols+` FROM bot_transactions WHERE status='pending' AND mp_payment_id <> '' ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListUserTransactions(ctx context.Context, telegramID int64, limit int) ([]*BotTransaction, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT `+botTxnCols+` FROM bot_transactions WHERE telegram_id=$1 ORDER BY id DESC LIMIT $2`, telegramID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListTransactions(ctx context.Context, status string, limit int) ([]*BotTransaction, error) {
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if status != "" {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions WHERE status=$1 ORDER BY id DESC LIMIT $2`, status, limit)
|
||||
} else {
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT `+botTxnCols+` FROM bot_transactions ORDER BY id DESC LIMIT $1`, limit)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*BotTransaction
|
||||
for rows.Next() {
|
||||
t, err := scanBotTxn(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// MarkTransactionApproved atomically flips a pending txn to approved. It returns
|
||||
// true only for the caller that actually performed the transition, giving
|
||||
// idempotent delivery even if webhook and poller race.
|
||||
func (s *Store) MarkTransactionApproved(ctx context.Context, id int) (bool, error) {
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE bot_transactions SET status='approved', paid_at=NOW() WHERE id=$1 AND status='pending'`, id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n == 1, nil
|
||||
}
|
||||
|
||||
func (s *Store) SetTransactionStatus(ctx context.Context, id int, status string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET status=$2 WHERE id=$1`, id, status)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetTransactionTarget(ctx context.Context, id int, target string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_transactions SET target_username=$2 WHERE id=$1`, id, target)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---------- bot_settings ----------
|
||||
|
||||
func (s *Store) GetSetting(ctx context.Context, key, def string) string {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM bot_settings WHERE key=$1`, key).Scan(&v)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func (s *Store) SetSetting(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO bot_settings (key, value) VALUES ($1,$2)
|
||||
ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) AllSettings(ctx context.Context) (map[string]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT key, value FROM bot_settings`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]string{}
|
||||
for rows.Next() {
|
||||
var k, v string
|
||||
if err := rows.Scan(&k, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ---------- bot_config (row) ----------
|
||||
|
||||
// botConfigRow mirrors the DB row; secrets stay encrypted here.
|
||||
type botConfigRow struct {
|
||||
Enabled bool
|
||||
TelegramMode string
|
||||
TelegramWebhookURL string
|
||||
MPConfirmMode string
|
||||
MPPollInterval string
|
||||
PixExpirationMinutes int
|
||||
TrialEnabled bool
|
||||
TrialHours int
|
||||
TrialMaxConnections int
|
||||
TrialKind string
|
||||
TrialInboundTag string
|
||||
AdminTelegramIDs []int64
|
||||
Currency string
|
||||
PublicHost string
|
||||
XrayPublicHost string
|
||||
TelegramTokenEnc []byte
|
||||
TelegramWebhookSecretEnc []byte
|
||||
MPAccessTokenEnc []byte
|
||||
MPWebhookSecretEnc []byte
|
||||
}
|
||||
|
||||
func (s *Store) getBotConfigRow(ctx context.Context) (*botConfigRow, error) {
|
||||
var r botConfigRow
|
||||
var ids pq.Int64Array
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT enabled, telegram_mode, telegram_webhook_url, mp_confirm_mode, mp_poll_interval,
|
||||
pix_expiration_minutes, trial_enabled, trial_hours, trial_max_connections, trial_kind,
|
||||
trial_inbound_tag, admin_telegram_ids, currency, public_host, xray_public_host,
|
||||
telegram_token_enc, telegram_webhook_secret_enc, mp_access_token_enc, mp_webhook_secret_enc
|
||||
FROM bot_config WHERE id=1`).Scan(
|
||||
&r.Enabled, &r.TelegramMode, &r.TelegramWebhookURL, &r.MPConfirmMode, &r.MPPollInterval,
|
||||
&r.PixExpirationMinutes, &r.TrialEnabled, &r.TrialHours, &r.TrialMaxConnections, &r.TrialKind,
|
||||
&r.TrialInboundTag, &ids, &r.Currency, &r.PublicHost, &r.XrayPublicHost,
|
||||
&r.TelegramTokenEnc, &r.TelegramWebhookSecretEnc, &r.MPAccessTokenEnc, &r.MPWebhookSecretEnc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.AdminTelegramIDs = []int64(ids)
|
||||
return &r, nil
|
||||
}
|
||||
|
||||
// saveBotConfigRow writes the non-secret fields plus any encrypted blobs that
|
||||
// are non-nil (nil blob = keep existing secret).
|
||||
func (s *Store) saveBotConfigRow(ctx context.Context, r *botConfigRow, tokEnc, tgSecEnc, mpEnc, mpSecEnc []byte) error {
|
||||
set := `enabled=$1, telegram_mode=$2, telegram_webhook_url=$3, mp_confirm_mode=$4, mp_poll_interval=$5,
|
||||
pix_expiration_minutes=$6, trial_enabled=$7, trial_hours=$8, trial_max_connections=$9, trial_kind=$10,
|
||||
trial_inbound_tag=$11, admin_telegram_ids=$12, currency=$13, public_host=$14, xray_public_host=$15,
|
||||
updated_at=NOW()`
|
||||
args := []interface{}{
|
||||
r.Enabled, r.TelegramMode, r.TelegramWebhookURL, r.MPConfirmMode, r.MPPollInterval,
|
||||
r.PixExpirationMinutes, r.TrialEnabled, r.TrialHours, r.TrialMaxConnections, r.TrialKind,
|
||||
r.TrialInboundTag, pq.Array(r.AdminTelegramIDs), r.Currency, r.PublicHost, r.XrayPublicHost,
|
||||
}
|
||||
n := len(args)
|
||||
if tokEnc != nil {
|
||||
n++
|
||||
set += `, telegram_token_enc=$` + botItoa(n)
|
||||
args = append(args, tokEnc)
|
||||
}
|
||||
if tgSecEnc != nil {
|
||||
n++
|
||||
set += `, telegram_webhook_secret_enc=$` + botItoa(n)
|
||||
args = append(args, tgSecEnc)
|
||||
}
|
||||
if mpEnc != nil {
|
||||
n++
|
||||
set += `, mp_access_token_enc=$` + botItoa(n)
|
||||
args = append(args, mpEnc)
|
||||
}
|
||||
if mpSecEnc != nil {
|
||||
n++
|
||||
set += `, mp_webhook_secret_enc=$` + botItoa(n)
|
||||
args = append(args, mpSecEnc)
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE bot_config SET `+set+` WHERE id=1`, args...)
|
||||
return err
|
||||
}
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
package main
|
||||
|
||||
// bot_telegram.go — minimal Telegram Bot API client built on net/http.
|
||||
// No third-party dependency.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tgAPIBase = "https://api.telegram.org/bot"
|
||||
|
||||
// ---------- Wire types (subset) ----------
|
||||
|
||||
type tgUpdate struct {
|
||||
UpdateID int64 `json:"update_id"`
|
||||
Message *tgMessage `json:"message"`
|
||||
CallbackQuery *tgCallbackQuery `json:"callback_query"`
|
||||
}
|
||||
|
||||
type tgMessage struct {
|
||||
MessageID int64 `json:"message_id"`
|
||||
From *tgUser `json:"from"`
|
||||
Chat tgChat `json:"chat"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type tgCallbackQuery struct {
|
||||
ID string `json:"id"`
|
||||
From tgUser `json:"from"`
|
||||
Message *tgMessage `json:"message"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
type tgUser struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName string `json:"first_name"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
type tgChat struct {
|
||||
ID int64 `json:"id"`
|
||||
}
|
||||
|
||||
type tgInlineKeyboard struct {
|
||||
InlineKeyboard [][]tgInlineButton `json:"inline_keyboard"`
|
||||
}
|
||||
|
||||
type tgInlineButton struct {
|
||||
Text string `json:"text"`
|
||||
CallbackData string `json:"callback_data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- Client ----------
|
||||
|
||||
type tgClient struct {
|
||||
token string
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
func newTGClient(token string) *tgClient {
|
||||
return &tgClient{token: token, hc: &http.Client{Timeout: 65 * time.Second}}
|
||||
}
|
||||
|
||||
type tgResponse struct {
|
||||
OK bool `json:"ok"`
|
||||
Description string `json:"description"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
func (c *tgClient) call(ctx context.Context, method string, payload interface{}) (json.RawMessage, error) {
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/"+method, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var tr tgResponse
|
||||
if err := json.Unmarshal(data, &tr); err != nil {
|
||||
return nil, fmt.Errorf("telegram %s: bad response: %s", method, string(data))
|
||||
}
|
||||
if !tr.OK {
|
||||
return nil, fmt.Errorf("telegram %s: %s", method, tr.Description)
|
||||
}
|
||||
return tr.Result, nil
|
||||
}
|
||||
|
||||
// getUpdates long-polls. offset is the next update_id to fetch.
|
||||
func (c *tgClient) getUpdates(ctx context.Context, offset int64, timeoutSec int) ([]tgUpdate, error) {
|
||||
payload := map[string]interface{}{
|
||||
"offset": offset,
|
||||
"timeout": timeoutSec,
|
||||
"allowed_updates": []string{"message", "callback_query"},
|
||||
}
|
||||
raw, err := c.call(ctx, "getUpdates", payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ups []tgUpdate
|
||||
if err := json.Unmarshal(raw, &ups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ups, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) sendMessage(ctx context.Context, chatID int64, text string, kb *tgInlineKeyboard) (int64, error) {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if kb != nil {
|
||||
payload["reply_markup"] = kb
|
||||
}
|
||||
raw, err := c.call(ctx, "sendMessage", payload)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var m tgMessage
|
||||
_ = json.Unmarshal(raw, &m)
|
||||
return m.MessageID, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) editMessageText(ctx context.Context, chatID, messageID int64, text string, kb *tgInlineKeyboard) error {
|
||||
payload := map[string]interface{}{
|
||||
"chat_id": chatID,
|
||||
"message_id": messageID,
|
||||
"text": text,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": true,
|
||||
}
|
||||
if kb != nil {
|
||||
payload["reply_markup"] = kb
|
||||
}
|
||||
_, err := c.call(ctx, "editMessageText", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *tgClient) answerCallback(ctx context.Context, callbackID, text string) error {
|
||||
payload := map[string]interface{}{"callback_query_id": callbackID}
|
||||
if text != "" {
|
||||
payload["text"] = text
|
||||
}
|
||||
_, err := c.call(ctx, "answerCallbackQuery", payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// sendPhotoBytes uploads a photo (e.g. a PIX QR PNG) via multipart.
|
||||
func (c *tgClient) sendPhotoBytes(ctx context.Context, chatID int64, photo []byte, filename, caption string, kb *tgInlineKeyboard) (int64, error) {
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("chat_id", strconv.FormatInt(chatID, 10))
|
||||
if caption != "" {
|
||||
_ = w.WriteField("caption", caption)
|
||||
_ = w.WriteField("parse_mode", "HTML")
|
||||
}
|
||||
if kb != nil {
|
||||
kbJSON, _ := json.Marshal(kb)
|
||||
_ = w.WriteField("reply_markup", string(kbJSON))
|
||||
}
|
||||
fw, err := w.CreateFormFile("photo", filename)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if _, err := fw.Write(photo); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
_ = w.Close()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tgAPIBase+c.token+"/sendPhoto", &buf)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
var tr tgResponse
|
||||
if err := json.Unmarshal(data, &tr); err != nil || !tr.OK {
|
||||
return 0, fmt.Errorf("telegram sendPhoto: %s", string(data))
|
||||
}
|
||||
var m tgMessage
|
||||
_ = json.Unmarshal(tr.Result, &m)
|
||||
return m.MessageID, nil
|
||||
}
|
||||
|
||||
func (c *tgClient) deleteWebhook(ctx context.Context) error {
|
||||
_, err := c.call(ctx, "deleteWebhook", map[string]interface{}{"drop_pending_updates": false})
|
||||
return err
|
||||
}
|
||||
|
||||
// getMe validates the token and returns the bot username.
|
||||
func (c *tgClient) getMe(ctx context.Context) (string, error) {
|
||||
raw, err := c.call(ctx, "getMe", map[string]interface{}{})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var me tgUser
|
||||
_ = json.Unmarshal(raw, &me)
|
||||
return me.Username, nil
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
return s
|
||||
}
|
||||
+12
-24
@@ -104,18 +104,15 @@ get_env_value() {
|
||||
' "$ENV_FILE"
|
||||
}
|
||||
|
||||
update_env_password() {
|
||||
local new_password="$1"
|
||||
remove_legacy_env_password() {
|
||||
local tmp
|
||||
tmp="$(mktemp)"
|
||||
awk -v line="ADMIN_PASSWORD=${new_password}" '
|
||||
BEGIN { done = 0 }
|
||||
/^ADMIN_PASSWORD=/ { print line; done = 1; next }
|
||||
awk '
|
||||
/^ADMIN_PASSWORD=/ { next }
|
||||
{ print }
|
||||
END { if (!done) print line }
|
||||
' "$ENV_FILE" > "$tmp"
|
||||
cat "$tmp" > "$ENV_FILE"
|
||||
rm -f "$tmp"
|
||||
install -m 600 "$tmp" "$ENV_FILE"
|
||||
rm -f -- "$tmp"
|
||||
chmod 600 "$ENV_FILE" 2>/dev/null || true
|
||||
}
|
||||
|
||||
@@ -135,15 +132,8 @@ generate_password() {
|
||||
|
||||
hash_password() {
|
||||
local pw="$1"
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | sha256sum | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | shasum -a 256 | awk '{print $1}'
|
||||
elif command -v openssl >/dev/null 2>&1; then
|
||||
printf '%s' "$pw" | openssl dgst -sha256 -r | awk '{print $1}'
|
||||
else
|
||||
error "No SHA-256 tool found. Install coreutils or openssl."
|
||||
fi
|
||||
[[ -x "$INSTALL_DIR/sshpanel" ]] || error "Panel binary not found: $INSTALL_DIR/sshpanel"
|
||||
printf '%s' "$pw" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null
|
||||
}
|
||||
|
||||
PG_DSN="$(get_env_value PG_DSN)"
|
||||
@@ -168,12 +158,12 @@ elif [[ -z "$NEW_PASSWORD" ]]; then
|
||||
fi
|
||||
|
||||
[[ -n "$NEW_PASSWORD" ]] || error "Password cannot be empty."
|
||||
if [[ ${#NEW_PASSWORD} -lt 8 ]]; then
|
||||
error "Password must have at least 8 characters."
|
||||
if [[ ${#NEW_PASSWORD} -lt 10 ]]; then
|
||||
error "Password must have at least 10 characters."
|
||||
fi
|
||||
|
||||
PASSWORD_HASH="$(hash_password "$NEW_PASSWORD")"
|
||||
[[ ${#PASSWORD_HASH} -eq 64 ]] || error "Failed to generate valid SHA-256 password hash."
|
||||
[[ "$PASSWORD_HASH" == \$2* ]] || error "Failed to generate a valid bcrypt password hash."
|
||||
|
||||
info "Updating admin user '${ADMIN_USER}' in PostgreSQL..."
|
||||
psql "$PG_DSN" -v ON_ERROR_STOP=1 \
|
||||
@@ -201,10 +191,8 @@ ON CONFLICT (username) DO UPDATE SET
|
||||
SQL
|
||||
|
||||
if [[ "$ADMIN_USER" == "admin" ]]; then
|
||||
update_env_password "$NEW_PASSWORD"
|
||||
info "Updated ADMIN_PASSWORD inside $ENV_FILE"
|
||||
else
|
||||
warn "ADMIN_PASSWORD in $ENV_FILE was not changed because username is not 'admin'."
|
||||
remove_legacy_env_password
|
||||
info "Removed any legacy plaintext ADMIN_PASSWORD entry from $ENV_FILE"
|
||||
fi
|
||||
|
||||
if ! $NO_RESTART; then
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSSHIdleTimeoutDisabledByDefault(t *testing.T) {
|
||||
for _, raw := range []string{"", "0", "0s", "off", "disabled"} {
|
||||
if got := parseSSHIdleTimeout(raw); got != 0 {
|
||||
t.Fatalf("parseSSHIdleTimeout(%q) = %s, want disabled", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHIdleTimeoutExplicitValue(t *testing.T) {
|
||||
if got := parseSSHIdleTimeout("30m"); got != 30*time.Minute {
|
||||
t.Fatalf("got %s, want 30m", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeXHTTPConnectedIdleSweepDisabled(t *testing.T) {
|
||||
if got := nativeXHTTPIdleTimeout(); got != 0 {
|
||||
t.Fatalf("native XHTTP idle timeout = %s, want disabled", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func sealCredential(prefix, plain string) (string, error) {
|
||||
if plain == "" || strings.HasPrefix(plain, prefix) {
|
||||
return plain, nil
|
||||
}
|
||||
enc, err := encryptSecret(plain)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return prefix + base64.RawStdEncoding.EncodeToString(enc), nil
|
||||
}
|
||||
|
||||
func openCredential(prefix, stored string) (string, error) {
|
||||
if !strings.HasPrefix(stored, prefix) {
|
||||
return stored, nil
|
||||
}
|
||||
raw, err := base64.RawStdEncoding.DecodeString(strings.TrimPrefix(stored, prefix))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("decode encrypted credential: %w", err)
|
||||
}
|
||||
return decryptSecret(raw)
|
||||
}
|
||||
+37
-20
@@ -34,7 +34,12 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
"www.bamsoftware.com/git/dnstt.git/dns"
|
||||
"www.bamsoftware.com/git/dnstt.git/noise"
|
||||
"www.bamsoftware.com/git/dnstt.git/turbotunnel"
|
||||
|
||||
// Local fork of the upstream turbotunnel package. Identical except that
|
||||
// RemoteMap's background expiry goroutine is stoppable via Close(); upstream
|
||||
// leaks it for the process lifetime, which becomes a per-restart goroutine
|
||||
// leak under this integration's DNSTT hot-reload / auto-restart.
|
||||
"shell2/internal/turbotunnel"
|
||||
)
|
||||
|
||||
// ---------- Hot-reload stop mechanism ----------
|
||||
@@ -828,18 +833,19 @@ func handleDNSTTStream(stream *smux.Stream, conv uint32) error {
|
||||
}
|
||||
|
||||
// streamConn adapts a smux.Stream to the net.Conn interface expected by
|
||||
// handleConn. smux.Stream already implements Read and Write, but does not
|
||||
// satisfy net.Conn because it lacks methods for deadlines and addresses. We
|
||||
// implement those methods with no‑ops and placeholder addresses.
|
||||
// handleConn. smux.Stream implements Read, Write, and (via its embedded
|
||||
// *stream) real deadline methods, but its addresses reflect the underlying
|
||||
// KCP/Noise DummyAddr, so we only override LocalAddr/RemoteAddr with a stable
|
||||
// "dnstt" placeholder for logging. The deadline methods are intentionally NOT
|
||||
// overridden: handleConn's sshHandshakeTimeout guard relies on SetReadDeadline
|
||||
// actually working, otherwise a stream that opens but never completes the SSH
|
||||
// handshake wedges a goroutine until MaxStreams is hit.
|
||||
type streamConn struct {
|
||||
*smux.Stream
|
||||
}
|
||||
|
||||
func (s *streamConn) LocalAddr() net.Addr { return dummyAddr{} }
|
||||
func (s *streamConn) RemoteAddr() net.Addr { return dummyAddr{} }
|
||||
func (s *streamConn) SetDeadline(t time.Time) error { return nil }
|
||||
func (s *streamConn) SetReadDeadline(t time.Time) error { return nil }
|
||||
func (s *streamConn) SetWriteDeadline(t time.Time) error { return nil }
|
||||
func (s *streamConn) LocalAddr() net.Addr { return dummyAddr{} }
|
||||
func (s *streamConn) RemoteAddr() net.Addr { return dummyAddr{} }
|
||||
|
||||
// dummyAddr is a stand‑in net.Addr implementation for dnstt streams. It
|
||||
// reports a generic network and address; this satisfies the net.Conn
|
||||
@@ -854,11 +860,21 @@ func (d dummyAddr) String() string { return "dnstt" }
|
||||
// Session, then waits for smux streams. Each stream is passed to
|
||||
// handleDNSTTStream. Any errors from the Noise or smux layers are returned.
|
||||
func acceptDNSTTStreams(conn *kcp.UDPSession, privkey []byte, limits dnsttRuntimeLimits) error {
|
||||
// Bound the Noise handshake read. A client can complete the KCP accept and
|
||||
// then go silent (UDP: no FIN) before sending the Noise handshake; with no
|
||||
// deadline, noise.NewServer's blocking ReadFull would park this goroutine
|
||||
// and its KCP session forever (nothing frees them — smux keepalive cannot
|
||||
// help because smux is not constructed until after the handshake). idleTimeout
|
||||
// is generous: a real client sends the handshake immediately after connecting.
|
||||
_ = conn.SetReadDeadline(time.Now().Add(idleTimeout))
|
||||
// Put a Noise channel on top of the KCP conn.
|
||||
rw, err := noise.NewServer(conn, privkey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear the handshake deadline; the live session is governed by smux
|
||||
// KeepAliveTimeout and KCP's own dead-link detection from here on.
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
|
||||
// Put an smux session on top of the encrypted Noise channel.
|
||||
smuxConfig := smux.DefaultConfig()
|
||||
@@ -1516,7 +1532,6 @@ func sendLoop(ttConn *turbotunnel.QueuePacketConn, ch <-chan *record, maxEncoded
|
||||
atomic.AddUint64(&dnsttStats.RespSent, 1)
|
||||
atomic.AddUint64(&dnsttStats.RespSentBytes, uint64(len(buf)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeMaxEncodedPayload computes the maximum amount of downstream TXT RR
|
||||
@@ -1664,6 +1679,10 @@ func runDNSTTOnListeners(privkey []byte, listeners []dnsttListenerSpec, limits d
|
||||
}
|
||||
// set up turbotunnel and KCP listener
|
||||
ttConn := turbotunnel.NewQueuePacketConn(turbotunnel.DummyAddr{}, idleTimeout*2)
|
||||
// Close ttConn on exit so its RemoteMap expiry goroutine is stopped. Without
|
||||
// this, every DNSTT restart (hot-reload / auto-restart) leaks that goroutine
|
||||
// plus the QueuePacketConn's buffers for the process lifetime.
|
||||
defer ttConn.Close()
|
||||
ln, err := kcp.ServeConn(nil, 0, 0, ttConn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dnstt: opening KCP listener: %v", err)
|
||||
@@ -1824,20 +1843,18 @@ func handleDnsttGenKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
privkey, err := noise.GeneratePrivkey()
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(dnsttKeyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
var encoded bytes.Buffer
|
||||
if err := noise.WriteKey(&encoded, privkey); err != nil {
|
||||
writeInternalError(w, "encode DNSTT key", err)
|
||||
return
|
||||
}
|
||||
if err := noise.WriteKey(f, privkey); err != nil {
|
||||
f.Close()
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(dnsttKeyFile, encoded.Bytes(), 0o600); err != nil {
|
||||
writeInternalError(w, "write DNSTT key", err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -1860,13 +1877,13 @@ func handleDnsttGetPubKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
f, err := os.Open(keyPath)
|
||||
if err != nil {
|
||||
http.Error(w, "open key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "open DNSTT key", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
privkey, err := noise.ReadKey(f)
|
||||
if err != nil {
|
||||
http.Error(w, "read key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read DNSTT key", err)
|
||||
return
|
||||
}
|
||||
pubkey := noise.PubkeyFromPrivkey(privkey)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Trusted Go archives used by install.sh and update.sh.
|
||||
# Format: version architecture sha256
|
||||
1.25.12 amd64 234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1
|
||||
1.25.12 arm64 8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2
|
||||
1.25.12 armv6l 6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1
|
||||
@@ -1,21 +1,23 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.4
|
||||
go 1.25.12
|
||||
|
||||
require golang.org/x/crypto v0.45.0
|
||||
require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61
|
||||
github.com/xtaci/smux v1.5.50
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/net v0.57.0
|
||||
golang.org/x/time v0.15.0
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/flynn/noise v1.0.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 // indirect
|
||||
github.com/klauspost/reedsolomon v1.12.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/tjfoc/gmsm v1.4.1 // indirect
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61 // indirect
|
||||
github.com/xtaci/smux v1.5.50 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
)
|
||||
|
||||
@@ -3,6 +3,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
@@ -27,26 +29,34 @@ github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/4
|
||||
github.com/klauspost/cpuid/v2 v2.2.6/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/reedsolomon v1.12.0 h1:I5FEp3xSwVCcEh3F5A7dofEfhXdF/bWhQWPH+XwBFno=
|
||||
github.com/klauspost/reedsolomon v1.12.0/go.mod h1:EPLZJeh4l27pUGC3aXOjheaoh1I9yut7xTURiW3LQ9Y=
|
||||
github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
|
||||
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61 h1:ajm12pGuWO+GWQNusPyPESC7Rq0yTC2rEXVYkM8ExOg=
|
||||
github.com/xtaci/kcp-go/v5 v5.6.61/go.mod h1:9O3D8WR+cyyUjGiTILYfg17vn72otWuXK2AFfqIe6CM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae h1:J0GxkO96kL4WF+AIT3M4mfUVinOCPgf2uUWYFUzN0sM=
|
||||
github.com/xtaci/lossyconn v0.0.0-20190602105132-8df528c0c9ae/go.mod h1:gXtu8J62kEgmN++bm9BVICuT/e8yiLI2KFobd/TRFsE=
|
||||
github.com/xtaci/smux v1.5.50 h1:y/1DlWQC9bnMeZzsyk4oL2hbLK6uVk4BKTz5BeQqUEA=
|
||||
github.com/xtaci/smux v1.5.50/go.mod h1:IGQ9QYrBphmb/4aTnLEcJby0TNr3NV+OslIOMrX825Q=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -58,8 +68,8 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -70,15 +80,17 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
@@ -99,7 +111,10 @@ google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQ
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0 h1:Xi0lmT+5kcgzY7P+r726eBXKMZKgGoD8GTNKrlh8TuE=
|
||||
|
||||
@@ -334,6 +334,7 @@ func applyFullConfigReload(newCfg *Config) ConfigReloadReport {
|
||||
// (picked up by new connections).
|
||||
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
|
||||
setSSHIdleTimeoutFromConfig(newCfg.SSHIdleTimeout)
|
||||
setMaxTotalConnsFromConfig(newCfg.MaxTotalConnections)
|
||||
|
||||
// Quiet logging / user count display
|
||||
if newCfg.Quiet {
|
||||
|
||||
+154
-10
@@ -15,6 +15,8 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
|
||||
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
|
||||
GO_SHA256="${GO_SHA256:-}"
|
||||
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
|
||||
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -32,6 +34,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "$GO_SHA256" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -234,16 +268,21 @@ if command -v go &>/dev/null; then
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
GO_EXPECTED_SHA256=""
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
GO_EXPECTED_SHA256="$(trusted_go_sha256 "$GO_VERSION" "$GOARCH" "$SCRIPT_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$GO_EXPECTED_SHA256" ]] || error "No trusted Go checksum for ${GO_VERSION}/${GOARCH}; set GO_SHA256 explicitly"
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading $GO_URL"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
verify_sha256_file "$GO_EXPECTED_SHA256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -264,10 +303,25 @@ info "[5/10] Building SSH Panel binary…"
|
||||
cd "$SCRIPT_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
BUILD_COMMIT="$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true)"
|
||||
BUILD_BRANCH="$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
BUILD_REPO_URL="$(git -C "$SCRIPT_DIR" config --get remote.origin.url 2>/dev/null || true)"
|
||||
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
|
||||
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
|
||||
[[ -n "$BUILD_REPO_URL" ]] || BUILD_REPO_URL="$REPO_URL"
|
||||
|
||||
go mod download
|
||||
go mod tidy
|
||||
go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
|
||||
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o "$INSTALL_DIR/sshpanel" .
|
||||
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
|
||||
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
|
||||
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
|
||||
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
|
||||
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
|
||||
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
|
||||
info " Binary: $INSTALL_DIR/sshpanel"
|
||||
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
|
||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
||||
info " Admin panel copied"
|
||||
if [[ -f "$SCRIPT_DIR/update.sh" ]]; then
|
||||
@@ -283,25 +337,51 @@ fi
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/10] Downloading Xray-core…"
|
||||
XRAY_VER=$(curl -sf "https://api.github.com/repos/XTLS/Xray-core/releases/latest" \
|
||||
| grep '"tag_name"' | head -1 | cut -d'"' -f4 || echo "v24.11.30")
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) XRAY_ARCH="64" ;;
|
||||
aarch64) XRAY_ARCH="arm64-v8a" ;;
|
||||
armv7l) XRAY_ARCH="arm32-v7a" ;;
|
||||
*) XRAY_ARCH="64" ;;
|
||||
*) error "Unsupported CPU architecture: $MACHINE" ;;
|
||||
esac
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
PYTHON_BIN="$(command -v python3 2>/dev/null || command -v python 2>/dev/null || true)"
|
||||
[[ -n "$PYTHON_BIN" ]] || error "Python is required to validate Xray release metadata"
|
||||
XRAY_RELEASE_JSON=/tmp/xray-release.json
|
||||
curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 \
|
||||
-o "$XRAY_RELEASE_JSON" https://api.github.com/repos/XTLS/Xray-core/releases/latest
|
||||
readarray -t XRAY_META < <("$PYTHON_BIN" -c '
|
||||
import json, re, sys
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as handle:
|
||||
release = json.load(handle)
|
||||
tag = release.get("tag_name", "")
|
||||
name = sys.argv[2]
|
||||
asset = next((item for item in release.get("assets", []) if item.get("name") == name), None)
|
||||
if not tag or not asset:
|
||||
raise SystemExit(2)
|
||||
url = asset.get("browser_download_url", "")
|
||||
digest = asset.get("digest", "")
|
||||
prefix = "https://github.com/XTLS/Xray-core/releases/download/" + tag + "/"
|
||||
if not url.startswith(prefix) or not re.fullmatch(r"sha256:[0-9a-fA-F]{64}", digest):
|
||||
raise SystemExit(3)
|
||||
print(tag)
|
||||
print(url)
|
||||
print(digest.split(":", 1)[1])
|
||||
' "$XRAY_RELEASE_JSON" "Xray-linux-${XRAY_ARCH}.zip")
|
||||
[[ ${#XRAY_META[@]} -eq 3 ]] || error "Xray release metadata is missing a trusted asset digest"
|
||||
XRAY_VER="${XRAY_META[0]}"
|
||||
XRAY_URL="${XRAY_META[1]}"
|
||||
XRAY_SHA256="${XRAY_META[2]}"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
verify_sha256_file "$XRAY_SHA256" /tmp/xray.zip
|
||||
info " Xray archive checksum verified"
|
||||
unzip -o /tmp/xray.zip xray -d "$INSTALL_DIR" > /dev/null 2>&1 || {
|
||||
mkdir -p /tmp/xray_extract
|
||||
unzip -o /tmp/xray.zip -d /tmp/xray_extract > /dev/null 2>&1
|
||||
mv /tmp/xray_extract/xray "$INSTALL_DIR/xray"
|
||||
}
|
||||
chmod +x "$INSTALL_DIR/xray"
|
||||
rm -f /tmp/xray.zip
|
||||
rm -f /tmp/xray.zip "$XRAY_RELEASE_JSON"
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
@@ -384,11 +464,75 @@ CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Telegram sales bot (created idempotently by the app too; mirrored here for a clean install)
|
||||
CREATE TABLE IF NOT EXISTS bot_users (
|
||||
telegram_id BIGINT PRIMARY KEY,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
first_name TEXT NOT NULL DEFAULT '',
|
||||
role TEXT NOT NULL DEFAULT 'customer',
|
||||
linked_admin_username TEXT NOT NULL DEFAULT '',
|
||||
credit_balance INT NOT NULL DEFAULT 0,
|
||||
trial_used BOOLEAN NOT NULL DEFAULT false,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_plans (
|
||||
id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', kind TEXT NOT NULL DEFAULT 'ssh',
|
||||
days INT NOT NULL DEFAULT 30, max_connections INT NOT NULL DEFAULT 1,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0, limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
xray_inbound_tag TEXT NOT NULL DEFAULT '', xray_protocol TEXT NOT NULL DEFAULT '',
|
||||
price_cents INT NOT NULL DEFAULT 0, credit_cost INT NOT NULL DEFAULT 1,
|
||||
server_id TEXT NOT NULL DEFAULT '', is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_credit_packages (
|
||||
id SERIAL PRIMARY KEY, name TEXT NOT NULL DEFAULT '', credits INT NOT NULL DEFAULT 0,
|
||||
price_cents INT NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT true, sort_order INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_transactions (
|
||||
id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, type TEXT NOT NULL,
|
||||
plan_id INT, package_id INT, credits INT NOT NULL DEFAULT 0, amount_cents INT NOT NULL DEFAULT 0,
|
||||
mp_payment_id TEXT NOT NULL DEFAULT '', mp_qr_code TEXT NOT NULL DEFAULT '', mp_qr_base64 TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending', target_username TEXT NOT NULL DEFAULT '', renew_target TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), paid_at TIMESTAMPTZ, expires_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS bot_transactions_mp_payment_id_uidx ON bot_transactions (mp_payment_id) WHERE mp_payment_id <> '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_credits_ledger (
|
||||
id SERIAL PRIMARY KEY, telegram_id BIGINT NOT NULL, delta INT NOT NULL, reason TEXT NOT NULL DEFAULT '',
|
||||
ref_transaction_id INT, balance_after INT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (key TEXT PRIMARY KEY, value TEXT NOT NULL DEFAULT '');
|
||||
|
||||
CREATE TABLE IF NOT EXISTS bot_config (
|
||||
id INT PRIMARY KEY DEFAULT 1 CHECK (id = 1), enabled BOOLEAN NOT NULL DEFAULT false,
|
||||
telegram_mode TEXT NOT NULL DEFAULT 'polling', telegram_webhook_url TEXT NOT NULL DEFAULT '',
|
||||
mp_confirm_mode TEXT NOT NULL DEFAULT 'polling', mp_poll_interval TEXT NOT NULL DEFAULT '20s',
|
||||
pix_expiration_minutes INT NOT NULL DEFAULT 30, trial_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
trial_hours INT NOT NULL DEFAULT 1, trial_max_connections INT NOT NULL DEFAULT 1,
|
||||
trial_kind TEXT NOT NULL DEFAULT 'ssh', trial_inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
admin_telegram_ids BIGINT[] NOT NULL DEFAULT '{}', currency TEXT NOT NULL DEFAULT 'BRL',
|
||||
public_host TEXT NOT NULL DEFAULT '', xray_public_host TEXT NOT NULL DEFAULT '',
|
||||
telegram_token_enc BYTEA, telegram_webhook_secret_enc BYTEA, mp_access_token_enc BYTEA, mp_webhook_secret_enc BYTEA,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
INSERT INTO bot_config (id) VALUES (1) ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
ALTER SCHEMA public OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS ssh_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS ssh_iface_totals OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS admin_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS xray_clients OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_users OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_plans OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_credit_packages OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_transactions OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_credits_ledger OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_settings OWNER TO ${DB_USER};
|
||||
ALTER TABLE IF EXISTS bot_config OWNER TO ${DB_USER};
|
||||
ALTER SEQUENCE IF EXISTS admin_users_id_seq OWNER TO ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON DATABASE ${DB_NAME} TO ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON SCHEMA public TO ${DB_USER};
|
||||
@@ -413,7 +557,8 @@ ADMIN_PASSWORD=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 20 || true)
|
||||
if [[ ${#ADMIN_PASSWORD} -lt 20 ]]; then
|
||||
ADMIN_PASSWORD=$(openssl rand -hex 10 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | sha256sum | awk '{print $1}')
|
||||
ADMIN_PASSWORD_HASH=$(printf '%s' "${ADMIN_PASSWORD}" | "$INSTALL_DIR/sshpanel" -hash-admin-password-stdin 2>/dev/null)
|
||||
[[ "$ADMIN_PASSWORD_HASH" == \$2* ]] || error "Failed to generate admin bcrypt password hash"
|
||||
su -c "psql -d ${DB_NAME}" postgres <<SQL
|
||||
INSERT INTO admin_users (username, password_hash, role, max_users, expires_at, is_active)
|
||||
VALUES ('admin', '${ADMIN_PASSWORD_HASH}', 'superadmin', 0, NULL, TRUE)
|
||||
@@ -429,7 +574,6 @@ SQL
|
||||
cat > "$INSTALL_DIR/.env" <<EOF
|
||||
PG_DSN=postgres://${DB_USER}:${DB_PASS}@127.0.0.1:5432/${DB_NAME}?sslmode=disable
|
||||
ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
ADMIN_HTTP_ADDR=0.0.0.0:9090
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/.env"
|
||||
@@ -623,7 +767,7 @@ echo -e " Admin login : ${YELLOW}admin${NC}"
|
||||
echo -e " Admin password: ${YELLOW}${ADMIN_PASSWORD}${NC}"
|
||||
echo -e " Admin token : ${YELLOW}${ADMIN_TOKEN}${NC}"
|
||||
echo ""
|
||||
echo -e " Token + DB creds stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " API token + DB credentials stored in: ${INSTALL_DIR}/.env"
|
||||
echo -e " Logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
|
||||
echo ""
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// This package is a local fork of www.bamsoftware.com/git/dnstt.git/turbotunnel
|
||||
// (upstream v1.20241021.0). The only behavioural change from upstream is that
|
||||
// RemoteMap's background expiry goroutine is now stoppable via Close(), wired
|
||||
// through QueuePacketConn.Close(); see remotemap.go and queuepacketconn.go.
|
||||
// Upstream leaks that goroutine for the process lifetime, which is harmless for
|
||||
// the upstream one-shot server but leaks one goroutine per DNSTT restart in this
|
||||
// integration (hot-reload / auto-restart). clientid.go and consts.go are copied
|
||||
// verbatim.
|
||||
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
// ClientID is an abstract identifier that binds together all the communications
|
||||
// belonging to a single client session, even though those communications may
|
||||
// arrive from multiple IP addresses or over multiple lower-level connections.
|
||||
// It plays the same role that an (IP address, port number) tuple plays in a
|
||||
// net.UDPConn: it's the return address pertaining to a long-lived abstract
|
||||
// client session. The client attaches its ClientID to each of its
|
||||
// communications, enabling the server to disambiguate requests among its many
|
||||
// clients. ClientID implements the net.Addr interface.
|
||||
type ClientID [8]byte
|
||||
|
||||
func NewClientID() ClientID {
|
||||
var id ClientID
|
||||
_, err := rand.Read(id[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (id ClientID) Network() string { return "clientid" }
|
||||
func (id ClientID) String() string { return hex.EncodeToString(id[:]) }
|
||||
@@ -0,0 +1,22 @@
|
||||
// Package turbotunnel is facilities for embedding packet-based reliability
|
||||
// protocols inside other protocols.
|
||||
//
|
||||
// https://github.com/net4people/bbs/issues/9
|
||||
package turbotunnel
|
||||
|
||||
import "errors"
|
||||
|
||||
// QueueSize is the size of send and receive queues in QueuePacketConn and
|
||||
// RemoteMap.
|
||||
const QueueSize = 128
|
||||
|
||||
var errClosedPacketConn = errors.New("operation on closed connection")
|
||||
var errNotImplemented = errors.New("not implemented")
|
||||
|
||||
// DummyAddr is a placeholder net.Addr, for when a programming interface
|
||||
// requires a net.Addr but there is none relevant. All DummyAddrs compare equal
|
||||
// to each other.
|
||||
type DummyAddr struct{}
|
||||
|
||||
func (addr DummyAddr) Network() string { return "dummy" }
|
||||
func (addr DummyAddr) String() string { return "dummy" }
|
||||
@@ -0,0 +1,165 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// taggedPacket is a combination of a []byte and a net.Addr, encapsulating the
|
||||
// return type of PacketConn.ReadFrom.
|
||||
type taggedPacket struct {
|
||||
P []byte
|
||||
Addr net.Addr
|
||||
}
|
||||
|
||||
// QueuePacketConn implements net.PacketConn by storing queues of packets. There
|
||||
// is one incoming queue (where packets are additionally tagged by the source
|
||||
// address of the peer that sent them). There are many outgoing queues, one for
|
||||
// each remote peer address that has been recently seen. The QueueIncoming
|
||||
// method inserts a packet into the incoming queue, to eventually be returned by
|
||||
// ReadFrom. WriteTo inserts a packet into an address-specific outgoing queue,
|
||||
// which can later by accessed through the OutgoingQueue method.
|
||||
//
|
||||
// Besides the outgoing queues, there is also a one-element "stash" for each
|
||||
// remote peer address. You can stash a packet using the Stash method, and get
|
||||
// it back later by receiving from the channel returned by Unstash. The stash is
|
||||
// meant as a convenient place to temporarily store a single packet, such as
|
||||
// when you've read one too many packets from the send queue and need to store
|
||||
// the extra packet to be processed first in the next pass. It's the caller's
|
||||
// responsibility to Unstash what they have Stashed. Calling Stash does not put
|
||||
// the packet at the head of the send queue; if there is the possibility that a
|
||||
// packet has been stashed, it must be checked for by calling Unstash in
|
||||
// addition to OutgoingQueue.
|
||||
type QueuePacketConn struct {
|
||||
remotes *RemoteMap
|
||||
localAddr net.Addr
|
||||
recvQueue chan taggedPacket
|
||||
closeOnce sync.Once
|
||||
closed chan struct{}
|
||||
// What error to return when the QueuePacketConn is closed.
|
||||
err atomic.Value
|
||||
}
|
||||
|
||||
// NewQueuePacketConn makes a new QueuePacketConn, set to track recent peers
|
||||
// for at least a duration of timeout.
|
||||
func NewQueuePacketConn(localAddr net.Addr, timeout time.Duration) *QueuePacketConn {
|
||||
return &QueuePacketConn{
|
||||
remotes: NewRemoteMap(timeout),
|
||||
localAddr: localAddr,
|
||||
recvQueue: make(chan taggedPacket, QueueSize),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// QueueIncoming queues and incoming packet and its source address, to be
|
||||
// returned in a future call to ReadFrom.
|
||||
func (c *QueuePacketConn) QueueIncoming(p []byte, addr net.Addr) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
// If we're closed, silently drop it.
|
||||
return
|
||||
default:
|
||||
}
|
||||
// Copy the slice so that the caller may reuse it.
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
select {
|
||||
case c.recvQueue <- taggedPacket{buf, addr}:
|
||||
default:
|
||||
// Drop the incoming packet if the receive queue is full.
|
||||
}
|
||||
}
|
||||
|
||||
// OutgoingQueue returns the queue of outgoing packets corresponding to addr,
|
||||
// creating it if necessary. The contents of the queue will be packets that are
|
||||
// written to the address in question using WriteTo.
|
||||
func (c *QueuePacketConn) OutgoingQueue(addr net.Addr) <-chan []byte {
|
||||
return c.remotes.SendQueue(addr)
|
||||
}
|
||||
|
||||
// Stash places p in the stash for addr, if the stash is not already occupied.
|
||||
// Returns true if the packet was placed in the stash, or false if the stash was
|
||||
// already occupied. This method is similar to WriteTo, except that it puts the
|
||||
// packet in the stash queue (accessible via Unstash), rather than the outgoing
|
||||
// queue (accessible via OutgoingQueue).
|
||||
func (c *QueuePacketConn) Stash(p []byte, addr net.Addr) bool {
|
||||
return c.remotes.Stash(addr, p)
|
||||
}
|
||||
|
||||
// Unstash returns the channel that represents the stash for addr.
|
||||
func (c *QueuePacketConn) Unstash(addr net.Addr) <-chan []byte {
|
||||
return c.remotes.Unstash(addr)
|
||||
}
|
||||
|
||||
// ReadFrom returns a packet and address previously stored by QueueIncoming.
|
||||
func (c *QueuePacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, nil, &net.OpError{Op: "read", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
case packet := <-c.recvQueue:
|
||||
return copy(p, packet.P), packet.Addr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTo queues an outgoing packet for the given address. The queue can later
|
||||
// be retrieved using the OutgoingQueue method.
|
||||
func (c *QueuePacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
|
||||
select {
|
||||
case <-c.closed:
|
||||
return 0, &net.OpError{Op: "write", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
default:
|
||||
}
|
||||
// Copy the slice so that the caller may reuse it.
|
||||
buf := make([]byte, len(p))
|
||||
copy(buf, p)
|
||||
select {
|
||||
case c.remotes.SendQueue(addr) <- buf:
|
||||
return len(buf), nil
|
||||
default:
|
||||
// Drop the outgoing packet if the send queue is full.
|
||||
return len(buf), nil
|
||||
}
|
||||
}
|
||||
|
||||
// closeWithError unblocks pending operations and makes future operations fail
|
||||
// with the given error. If err is nil, it becomes errClosedPacketConn.
|
||||
func (c *QueuePacketConn) closeWithError(err error) error {
|
||||
var newlyClosed bool
|
||||
c.closeOnce.Do(func() {
|
||||
newlyClosed = true
|
||||
// Store the error to be returned by future PacketConn
|
||||
// operations.
|
||||
if err == nil {
|
||||
err = errClosedPacketConn
|
||||
}
|
||||
c.err.Store(err)
|
||||
close(c.closed)
|
||||
// LOCAL FORK ADDITION: stop the RemoteMap expiry goroutine so it is not
|
||||
// leaked for the process lifetime. Upstream never does this.
|
||||
c.remotes.Close()
|
||||
})
|
||||
if !newlyClosed {
|
||||
return &net.OpError{Op: "close", Net: c.LocalAddr().Network(), Addr: c.LocalAddr(), Err: c.err.Load().(error)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close unblocks pending operations and makes future operations fail with a
|
||||
// "closed connection" error.
|
||||
func (c *QueuePacketConn) Close() error {
|
||||
return c.closeWithError(nil)
|
||||
}
|
||||
|
||||
// LocalAddr returns the localAddr value that was passed to NewQueuePacketConn.
|
||||
func (c *QueuePacketConn) LocalAddr() net.Addr { return c.localAddr }
|
||||
|
||||
func (c *QueuePacketConn) SetDeadline(t time.Time) error { return errNotImplemented }
|
||||
func (c *QueuePacketConn) SetReadDeadline(t time.Time) error { return errNotImplemented }
|
||||
func (c *QueuePacketConn) SetWriteDeadline(t time.Time) error { return errNotImplemented }
|
||||
@@ -0,0 +1,198 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// remoteRecord is a record of a recently seen remote peer, with the time it was
|
||||
// last seen and queues of outgoing packets.
|
||||
type remoteRecord struct {
|
||||
Addr net.Addr
|
||||
LastSeen time.Time
|
||||
SendQueue chan []byte
|
||||
Stash chan []byte
|
||||
}
|
||||
|
||||
// RemoteMap manages a mapping of live remote peers, keyed by address, to their
|
||||
// respective send queues. Each peer has two queues: a primary send queue, and a
|
||||
// "stash". The primary send queue is returned by the SendQueue method. The
|
||||
// stash is an auxiliary one-element queue accessed using the Stash and Unstash
|
||||
// methods. The stash is meant for use by callers that need to "unread" a packet
|
||||
// that's already been removed from the primary send queue.
|
||||
//
|
||||
// RemoteMap's functions are safe to call from multiple goroutines.
|
||||
type RemoteMap struct {
|
||||
// We use an inner structure to avoid exposing public heap.Interface
|
||||
// functions to users of remoteMap.
|
||||
inner remoteMapInner
|
||||
// Synchronizes access to inner.
|
||||
lock sync.Mutex
|
||||
// closed stops the background expiry goroutine. LOCAL FORK ADDITION:
|
||||
// upstream has no way to stop that goroutine, which leaks it for the
|
||||
// process lifetime on every RemoteMap created.
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewRemoteMap creates a RemoteMap that expires peers after a timeout.
|
||||
//
|
||||
// If the timeout is 0, peers never expire.
|
||||
//
|
||||
// The timeout does not have to be kept in sync with smux's idle timeout. If a
|
||||
// peer is removed from the map while the smux session is still live, the worst
|
||||
// that can happen is a loss of whatever packets were in the send queue at the
|
||||
// time. If smux later decides to send more packets to the same peer, we'll
|
||||
// instantiate a new send queue, and if the peer is ever seen again with a
|
||||
// matching address, we'll deliver them.
|
||||
func NewRemoteMap(timeout time.Duration) *RemoteMap {
|
||||
m := &RemoteMap{
|
||||
inner: remoteMapInner{
|
||||
byAge: make([]*remoteRecord, 0),
|
||||
byAddr: make(map[net.Addr]int),
|
||||
},
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
if timeout > 0 {
|
||||
// LOCAL FORK CHANGE: upstream is `for { time.Sleep(timeout/2); ... }`
|
||||
// with no exit. Use a ticker and select on m.closed so Close() can stop
|
||||
// this goroutine.
|
||||
go func() {
|
||||
ticker := time.NewTicker(timeout / 2)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.closed:
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
m.lock.Lock()
|
||||
m.inner.removeExpired(now, timeout)
|
||||
m.lock.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Close stops the background expiry goroutine started by NewRemoteMap. It is
|
||||
// safe to call more than once. LOCAL FORK ADDITION.
|
||||
func (m *RemoteMap) Close() error {
|
||||
m.closeOnce.Do(func() { close(m.closed) })
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendQueue returns the send queue corresponding to addr, creating it if
|
||||
// necessary.
|
||||
func (m *RemoteMap) SendQueue(addr net.Addr) chan []byte {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.inner.Lookup(addr, time.Now()).SendQueue
|
||||
}
|
||||
|
||||
// Stash places p in the stash corresponding to addr, if the stash is not
|
||||
// already occupied. Returns true if the p was placed in the stash, false
|
||||
// otherwise.
|
||||
func (m *RemoteMap) Stash(addr net.Addr, p []byte) bool {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
select {
|
||||
case m.inner.Lookup(addr, time.Now()).Stash <- p:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Unstash returns the channel that reads from the stash for addr.
|
||||
func (m *RemoteMap) Unstash(addr net.Addr) <-chan []byte {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
return m.inner.Lookup(addr, time.Now()).Stash
|
||||
}
|
||||
|
||||
// remoteMapInner is the inner type of RemoteMap, implementing heap.Interface.
|
||||
// byAge is the backing store, a heap ordered by LastSeen time, to facilitate
|
||||
// expiring old records. byAddr is a map from addresses to heap indices, to
|
||||
// allow looking up by address. Unlike RemoteMap, remoteMapInner requires
|
||||
// external synchonization.
|
||||
type remoteMapInner struct {
|
||||
byAge []*remoteRecord
|
||||
byAddr map[net.Addr]int
|
||||
}
|
||||
|
||||
// removeExpired removes all records whose LastSeen timestamp is more than
|
||||
// timeout in the past.
|
||||
func (inner *remoteMapInner) removeExpired(now time.Time, timeout time.Duration) {
|
||||
for len(inner.byAge) > 0 && now.Sub(inner.byAge[0].LastSeen) >= timeout {
|
||||
record := heap.Pop(inner).(*remoteRecord)
|
||||
close(record.SendQueue)
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup finds the existing record corresponding to addr, or creates a new
|
||||
// one if none exists yet. It updates the record's LastSeen time and returns the
|
||||
// record.
|
||||
func (inner *remoteMapInner) Lookup(addr net.Addr, now time.Time) *remoteRecord {
|
||||
var record *remoteRecord
|
||||
i, ok := inner.byAddr[addr]
|
||||
if ok {
|
||||
// Found one, update its LastSeen.
|
||||
record = inner.byAge[i]
|
||||
record.LastSeen = now
|
||||
heap.Fix(inner, i)
|
||||
} else {
|
||||
// Not found, create a new one.
|
||||
record = &remoteRecord{
|
||||
Addr: addr,
|
||||
LastSeen: now,
|
||||
SendQueue: make(chan []byte, QueueSize),
|
||||
Stash: make(chan []byte, 1),
|
||||
}
|
||||
heap.Push(inner, record)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
// heap.Interface for remoteMapInner.
|
||||
|
||||
func (inner *remoteMapInner) Len() int {
|
||||
if len(inner.byAge) != len(inner.byAddr) {
|
||||
panic("inconsistent remoteMap")
|
||||
}
|
||||
return len(inner.byAge)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Less(i, j int) bool {
|
||||
return inner.byAge[i].LastSeen.Before(inner.byAge[j].LastSeen)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Swap(i, j int) {
|
||||
inner.byAge[i], inner.byAge[j] = inner.byAge[j], inner.byAge[i]
|
||||
inner.byAddr[inner.byAge[i].Addr] = i
|
||||
inner.byAddr[inner.byAge[j].Addr] = j
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Push(x interface{}) {
|
||||
record := x.(*remoteRecord)
|
||||
if _, ok := inner.byAddr[record.Addr]; ok {
|
||||
panic("duplicate address in remoteMap")
|
||||
}
|
||||
// Insert into byAddr map.
|
||||
inner.byAddr[record.Addr] = len(inner.byAge)
|
||||
// Insert into byAge slice.
|
||||
inner.byAge = append(inner.byAge, record)
|
||||
}
|
||||
|
||||
func (inner *remoteMapInner) Pop() interface{} {
|
||||
n := len(inner.byAddr)
|
||||
// Remove from byAge slice.
|
||||
record := inner.byAge[n-1]
|
||||
inner.byAge[n-1] = nil
|
||||
inner.byAge = inner.byAge[:n-1]
|
||||
// Remove from byAddr map.
|
||||
delete(inner.byAddr, record.Addr)
|
||||
return record
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package turbotunnel
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestQueuePacketConnCloseStopsGoroutine is the regression test for the local
|
||||
// fork's reason to exist: upstream's RemoteMap expiry goroutine runs forever,
|
||||
// so creating and discarding many QueuePacketConns (as DNSTT restart does)
|
||||
// leaks one goroutine each. After Close(), the count must return to baseline.
|
||||
func TestQueuePacketConnCloseStopsGoroutine(t *testing.T) {
|
||||
// Let any goroutines from earlier settle.
|
||||
settle := func() {
|
||||
for i := 0; i < 50; i++ {
|
||||
runtime.GC()
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
settle()
|
||||
base := runtime.NumGoroutine()
|
||||
|
||||
const n = 200
|
||||
for i := 0; i < n; i++ {
|
||||
// Short timeout so the goroutine is definitely started (timeout > 0).
|
||||
c := NewQueuePacketConn(DummyAddr{}, 50*time.Millisecond)
|
||||
if err := c.Close(); err != nil {
|
||||
t.Fatalf("Close returned error: %v", err)
|
||||
}
|
||||
}
|
||||
settle()
|
||||
|
||||
got := runtime.NumGoroutine()
|
||||
// Allow a small slack for scheduler/runtime goroutines; the key point is we
|
||||
// are nowhere near base+n (which is what the upstream leak would produce).
|
||||
if got > base+20 {
|
||||
t.Fatalf("goroutine leak: baseline=%d after %d create/close cycles=%d (want <= baseline+20)", base, n, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRemoteMapCloseIdempotent verifies Close can be called repeatedly.
|
||||
func TestRemoteMapCloseIdempotent(t *testing.T) {
|
||||
m := NewRemoteMap(10 * time.Millisecond)
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("first Close: %v", err)
|
||||
}
|
||||
if err := m.Close(); err != nil {
|
||||
t.Fatalf("second Close: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
_ "github.com/lib/pq"
|
||||
@@ -33,13 +34,18 @@ import (
|
||||
|
||||
const (
|
||||
// Hard timeouts to ensure half-open connections don't leak goroutines.
|
||||
sshHandshakeTimeout = 15 * time.Second
|
||||
// This also bounds DNSTT SSH handshakes (streamConn now honors deadlines),
|
||||
// which travel over DNS and need more headroom than a direct TCP handshake;
|
||||
// 45s is generous for slow/lossy DNS paths while still reaping wedged streams.
|
||||
sshHandshakeTimeout = 45 * time.Second
|
||||
tlsHandshakeTimeout = 15 * time.Second
|
||||
// Dial timeout for direct-tcpip backend connections.
|
||||
directTCPIPDialTimeout = 10 * time.Second
|
||||
// Default post-auth SSH inactivity timeout. This is based on real bytes
|
||||
// moving in either direction, so live upload/download tunnels are not closed.
|
||||
defaultSSHIdleTimeout = 5 * time.Minute
|
||||
// VPN transports must remain connected even when the user is temporarily
|
||||
// idle. Dead peers are released by transport errors/request cancellation,
|
||||
// not by a short application-level inactivity timer. Operators may still
|
||||
// set ssh_idle_timeout explicitly when they intentionally want one.
|
||||
defaultSSHIdleTimeout time.Duration = 0
|
||||
)
|
||||
|
||||
// ---------- Config types ----------
|
||||
@@ -93,9 +99,17 @@ type Config struct {
|
||||
|
||||
// SSHIdleTimeout controls how long an authenticated SSH connection may
|
||||
// remain with no bytes moving in either direction before it is closed and
|
||||
// released from the active user count. Empty = default 5m. Use "0s" to disable.
|
||||
// released from the active user count. Empty, "0", or "0s" disables it.
|
||||
// VPN/XHTTP connections should normally leave this disabled.
|
||||
SSHIdleTimeout string `json:"ssh_idle_timeout,omitempty"`
|
||||
|
||||
// MaxTotalConnections caps the total number of concurrent SSH connections
|
||||
// across all users. Once reached, new connections are rejected before the
|
||||
// (expensive) SSH handshake, so a surge past normal load cannot exhaust
|
||||
// memory/CPU and crash the server. 0 or empty uses the default (10000);
|
||||
// set to -1 to disable the global cap entirely.
|
||||
MaxTotalConnections int `json:"max_total_connections,omitempty"`
|
||||
|
||||
// NEW: Directory to serve the admin panel from
|
||||
AdminDir string `json:"admin_dir"`
|
||||
|
||||
@@ -259,17 +273,21 @@ type UDPGWConfig struct {
|
||||
// debug logs. A value of zero suppresses hex dumps. Default is 64.
|
||||
HexdumpN int `json:"hexdump"`
|
||||
// WriteChan sets the size of the buffered channel used for sending
|
||||
// reply frames back to the client. Larger values allow more queued
|
||||
// replies before blocking. The default is 4096.
|
||||
// reply frames back to the client. Default and hard ceiling is 1024:
|
||||
// values above the ceiling are clamped down, so an oversized value left in
|
||||
// an old config cannot bloat per-client memory. Zero uses the default.
|
||||
WriteChan int `json:"write_chan"`
|
||||
// UDPBindIP, if non‑empty, causes each per‑client UDP socket to bind to
|
||||
// the specified local IP address. The port is chosen automatically.
|
||||
UDPBindIP string `json:"udp_bind"`
|
||||
// UDPRBuf sets the size of the UDP socket read buffer in bytes. The
|
||||
// default is 8 MiB. Setting this to zero uses the default.
|
||||
// UDPRBuf sets the per-client UDP socket read buffer in bytes. This is
|
||||
// KERNEL memory allocated per connected client. Default and hard ceiling is
|
||||
// 512 KiB; larger values (including old 8 MiB configs) are clamped down so
|
||||
// thousands of clients cannot exhaust kernel memory. Zero uses the default.
|
||||
UDPRBuf int `json:"udp_rbuf"`
|
||||
// UDPWBuf sets the size of the UDP socket write buffer in bytes. The
|
||||
// default is 8 MiB. Setting this to zero uses the default.
|
||||
// UDPWBuf sets the per-client UDP socket write buffer in bytes. Default and
|
||||
// hard ceiling is 512 KiB; larger values are clamped down. Zero uses the
|
||||
// default.
|
||||
UDPWBuf int `json:"udp_wbuf"`
|
||||
// MapTTL controls how long a destination->connID mapping remains
|
||||
// valid after the last packet from that destination. Expressed as a
|
||||
@@ -292,6 +310,12 @@ type UDPGWConfig struct {
|
||||
// growth if a client sprays packets to many unique destinations.
|
||||
// Default is 32768.
|
||||
MaxMapEntries int `json:"max_map_entries"`
|
||||
// MaxClients caps the total number of concurrent UDPGW client TCP
|
||||
// connections. Once reached, new clients are rejected (their TCP socket is
|
||||
// closed immediately) instead of being accepted, so a surge past normal
|
||||
// load cannot exhaust memory and crash the server. Default is 10000.
|
||||
// A value <= 0 uses the default.
|
||||
MaxClients int `json:"max_clients"`
|
||||
|
||||
// AutoRestartInterval controls a watchdog that periodically hard-restarts
|
||||
// the integrated UDPGW listener and closes all connected UDPGW clients. Empty,
|
||||
@@ -494,25 +518,37 @@ func mbpsToBytesPerSec(mbps int) int64 {
|
||||
return int64(mbps) * 1024 * 1024 / 8
|
||||
}
|
||||
|
||||
// copyBufSize is the per-direction relay buffer for tunnel traffic. SSH tunnel
|
||||
// clients (mobile injection apps) push many low-bandwidth flows, so a large
|
||||
// buffer wastes heap with no throughput benefit. 16 KiB keeps per-channel heap
|
||||
// low across thousands of concurrent direct-tcpip channels.
|
||||
const copyBufSize = 16 * 1024
|
||||
|
||||
var copyBufPool = sync.Pool{
|
||||
New: func() interface{} { b := make([]byte, 32*1024); return &b },
|
||||
New: func() interface{} { b := make([]byte, copyBufSize); return &b },
|
||||
}
|
||||
|
||||
// copyWithRateLimit relays src->dst through a pooled buffer. Every copy path
|
||||
// (rate-limited or not) goes through the pool: previously the lim==nil path used
|
||||
// io.Copy, which allocates a fresh 32 KiB buffer per direction per channel and
|
||||
// never pools it — at thousands of channels that churn dominated GC pressure.
|
||||
func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written int64, err error) {
|
||||
if lim == nil {
|
||||
return io.Copy(dst, src)
|
||||
}
|
||||
|
||||
bufp := copyBufPool.Get().(*[]byte)
|
||||
buf := *bufp
|
||||
defer copyBufPool.Put(bufp)
|
||||
ctx := context.Background()
|
||||
|
||||
var ctx context.Context
|
||||
if lim != nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
for {
|
||||
nr, er := src.Read(buf)
|
||||
if nr > 0 {
|
||||
if err := lim.WaitN(ctx, nr); err != nil {
|
||||
return written, err
|
||||
if lim != nil {
|
||||
if err := lim.WaitN(ctx, nr); err != nil {
|
||||
return written, err
|
||||
}
|
||||
}
|
||||
|
||||
nw, ew := dst.Write(buf[:nr])
|
||||
@@ -542,17 +578,17 @@ func copyWithRateLimit(dst io.Writer, src io.Reader, lim *rate.Limiter) (written
|
||||
|
||||
func parseSSHIdleTimeout(raw string) time.Duration {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultSSHIdleTimeout
|
||||
if raw == "" || raw == "0" || raw == "0s" || strings.EqualFold(raw, "off") || strings.EqualFold(raw, "disabled") {
|
||||
return 0
|
||||
}
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
log.Printf("invalid ssh_idle_timeout %q: %v; using default %s", raw, err, defaultSSHIdleTimeout)
|
||||
return defaultSSHIdleTimeout
|
||||
log.Printf("invalid ssh_idle_timeout %q: %v; disabling SSH idle timeout", raw, err)
|
||||
return 0
|
||||
}
|
||||
if d < 0 {
|
||||
log.Printf("invalid negative ssh_idle_timeout %q; using default %s", raw, defaultSSHIdleTimeout)
|
||||
return defaultSSHIdleTimeout
|
||||
log.Printf("invalid negative ssh_idle_timeout %q; disabling SSH idle timeout", raw)
|
||||
return 0
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -571,6 +607,35 @@ func getSSHIdleTimeout() time.Duration {
|
||||
return d
|
||||
}
|
||||
|
||||
// defaultMaxTotalConns is the global concurrent SSH connection cap used when
|
||||
// max_total_connections is unset (0). Keeps headroom above normal load while
|
||||
// preventing an unbounded surge from exhausting memory/CPU.
|
||||
const defaultMaxTotalConns = 10000
|
||||
|
||||
var (
|
||||
// activeSSHConns is the current number of in-flight SSH connections.
|
||||
activeSSHConns int64
|
||||
// maxTotalConns is the global cap; 0 means unlimited. Set from config.
|
||||
maxTotalConns int64 = defaultMaxTotalConns
|
||||
// sshConnsRejected counts connections turned away at the global cap.
|
||||
sshConnsRejected int64
|
||||
)
|
||||
|
||||
// setMaxTotalConnsFromConfig applies the max_total_connections config value:
|
||||
// 0 -> default, <0 -> disabled (unlimited), >0 -> that exact cap.
|
||||
func setMaxTotalConnsFromConfig(v int) {
|
||||
var lim int64
|
||||
switch {
|
||||
case v < 0:
|
||||
lim = 0
|
||||
case v == 0:
|
||||
lim = defaultMaxTotalConns
|
||||
default:
|
||||
lim = int64(v)
|
||||
}
|
||||
atomic.StoreInt64(&maxTotalConns, lim)
|
||||
}
|
||||
|
||||
// activityConn tracks real SSH transport activity in both directions. The idle
|
||||
// monitor uses this instead of a read deadline so download-only or upload-only
|
||||
// tunnels are considered live and are not disconnected.
|
||||
@@ -1257,9 +1322,22 @@ func NewStore(dsn string) (*Store, error) {
|
||||
if err := store.EnsureManagedServersSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := store.EnsureBotSchema(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
const sshPasswordPrefix = "enc:v1:ssh:"
|
||||
|
||||
func sealSSHPassword(password string) (string, error) {
|
||||
return sealCredential(sshPasswordPrefix, password)
|
||||
}
|
||||
|
||||
func openSSHPassword(password string) (string, error) {
|
||||
return openCredential(sshPasswordPrefix, password)
|
||||
}
|
||||
|
||||
func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS ssh_users (
|
||||
@@ -1287,6 +1365,39 @@ func (s *Store) EnsureUsersSchema(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.migrateSSHPasswords(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateSSHPasswords(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT username, password FROM ssh_users WHERE password <> '' AND password NOT LIKE 'enc:v1:ssh:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyPassword struct {
|
||||
username string
|
||||
password string
|
||||
}
|
||||
var legacy []legacyPassword
|
||||
for rows.Next() {
|
||||
var item legacyPassword
|
||||
if err := rows.Scan(&item.username, &item.password); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealSSHPassword(item.password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt SSH password for %s: %w", item.username, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE ssh_users SET password=$2 WHERE username=$1`, item.username, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1323,6 +1434,10 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
&totpSecret, &totpPeriod, &totpWindow, &totpDigits, &allowStaticPassword, &ownerUsername); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password, err = openSSHPassword(password)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt SSH password for %s: %w", username, err)
|
||||
}
|
||||
|
||||
cfg := UserConfig{
|
||||
Username: username,
|
||||
@@ -1358,7 +1473,11 @@ func (s *Store) LoadUsers(ctx context.Context) (map[string]*UserState, error) {
|
||||
|
||||
// UpsertUser creates or updates a row in ssh_users.
|
||||
func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
storedPassword, err := sealSSHPassword(u.Password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt SSH password: %w", err)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
INSERT INTO ssh_users (
|
||||
username, password, max_connections, expires_at, limit_mbps_up, limit_mbps_down,
|
||||
totp_secret, totp_period, totp_window, totp_digits, allow_static_password, owner_username
|
||||
@@ -1376,7 +1495,7 @@ func (s *Store) UpsertUser(ctx context.Context, u UserConfig) error {
|
||||
totp_digits = EXCLUDED.totp_digits,
|
||||
allow_static_password = EXCLUDED.allow_static_password`,
|
||||
// owner_username is intentionally excluded from UPDATE — ownership is set at creation only.
|
||||
u.Username, u.Password, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.Username, storedPassword, u.MaxConnections, u.ExpiresAt, u.LimitMbpsUp, u.LimitMbpsDown,
|
||||
u.TOTPSecret, u.TOTPPeriod, u.TOTPWindow, u.TOTPDigits, u.AllowStaticPassword, u.OwnerUsername)
|
||||
return err
|
||||
}
|
||||
@@ -1493,6 +1612,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// SSH user management (session required; role-filtered inside handlers)
|
||||
mux.Handle("/api/users", sessionMiddleware(http.HandlerFunc(handleListUsers)))
|
||||
mux.Handle("/api/users/create", sessionMiddleware(http.HandlerFunc(handleCreateUser(store))))
|
||||
mux.Handle("/api/users/renew", sessionMiddleware(http.HandlerFunc(handleRenewSSHUser(store))))
|
||||
mux.Handle("/api/users/delete", sessionMiddleware(http.HandlerFunc(handleDeleteUser(store))))
|
||||
|
||||
// Server stats: visible to authenticated sessions; reset remains superadmin-only.
|
||||
@@ -1502,13 +1622,19 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store))))
|
||||
mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs)))
|
||||
mux.Handle("/api/system/logs/reset", saSession(http.HandlerFunc(handleSystemLogsReset)))
|
||||
mux.Handle("/api/system/update-status", saSession(http.HandlerFunc(handleUpdateStatus)))
|
||||
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
|
||||
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
|
||||
|
||||
// Superadmin-only: reseller management
|
||||
mux.Handle("/api/resellers", saSession(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", saSession(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/delete", saSession(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
// Hierarchical reseller management. Scope checks inside each handler limit a
|
||||
// reseller to its direct children; superadmins retain global management.
|
||||
mux.Handle("/api/resellers", sessionMiddleware(http.HandlerFunc(handleListResellers(store))))
|
||||
mux.Handle("/api/resellers/create", sessionMiddleware(http.HandlerFunc(handleCreateReseller(store))))
|
||||
mux.Handle("/api/resellers/action", sessionMiddleware(http.HandlerFunc(handleResellerAction(store))))
|
||||
mux.Handle("/api/resellers/delete", sessionMiddleware(http.HandlerFunc(handleDeleteReseller(store))))
|
||||
mux.Handle("/api/resellers/audit", sessionMiddleware(http.HandlerFunc(handleResellerAudit(store))))
|
||||
// Called master-to-node with the managed server's superadmin session.
|
||||
mux.Handle("/api/resellers/runtime", saSession(http.HandlerFunc(handleResellerRuntime(store))))
|
||||
|
||||
// Master/slave server management. Superadmins can add slave nodes; all authenticated
|
||||
// users can read the enabled server list to pick where accounts are created.
|
||||
@@ -1528,6 +1654,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
mux.Handle("/api/xray/inbounds", sessionMiddleware(http.HandlerFunc(handleXrayInbounds)))
|
||||
mux.Handle("/api/xray/clients/add", sessionMiddleware(http.HandlerFunc(handleXrayClientAdd)))
|
||||
mux.Handle("/api/xray/clients/update", sessionMiddleware(http.HandlerFunc(handleXrayClientUpdate)))
|
||||
mux.Handle("/api/xray/clients/renew", sessionMiddleware(http.HandlerFunc(handleRenewXrayClient(store))))
|
||||
mux.Handle("/api/xray/clients/remove", sessionMiddleware(http.HandlerFunc(handleXrayClientRemove)))
|
||||
|
||||
// Superadmin-only: TLS certificate generation
|
||||
@@ -1542,6 +1669,18 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
// Superadmin-only: server config (read/write config.json + live banner apply)
|
||||
mux.Handle("/api/server/config", saSession(http.HandlerFunc(handleServerConfig)))
|
||||
|
||||
// Superadmin-only: Telegram sales bot management
|
||||
mux.Handle("/api/bot/config", saSession(http.HandlerFunc(handleBotConfig(store))))
|
||||
mux.Handle("/api/bot/plans", saSession(http.HandlerFunc(handleBotPlans(store))))
|
||||
mux.Handle("/api/bot/credit-packages", saSession(http.HandlerFunc(handleBotCreditPackages(store))))
|
||||
mux.Handle("/api/bot/users", saSession(http.HandlerFunc(handleBotUsers(store))))
|
||||
mux.Handle("/api/bot/transactions", saSession(http.HandlerFunc(handleBotTransactions(store))))
|
||||
mux.Handle("/api/bot/settings", saSession(http.HandlerFunc(handleBotSettings(store))))
|
||||
mux.Handle("/api/bot/test", saSession(http.HandlerFunc(handleBotTest(store))))
|
||||
|
||||
// Public: Mercado Pago payment webhook (validated by signature inside).
|
||||
mux.Handle("/api/mp/webhook", http.HandlerFunc(handleMPWebhook))
|
||||
|
||||
// Public: user/UUID check — no auth, CORS *.
|
||||
mux.Handle("/check", http.HandlerFunc(handleCheck))
|
||||
|
||||
@@ -1553,7 +1692,16 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
|
||||
|
||||
go func() {
|
||||
log.Printf("Admin HTTP (panel + API) listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: securePanelHandler(mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 2 * time.Minute,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
}
|
||||
if err := server.ListenAndServe(); err != nil {
|
||||
log.Printf("admin http error: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -1642,6 +1790,7 @@ type UserPayload struct {
|
||||
AllowStaticPassword bool `json:"allow_static_password"`
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
|
||||
func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
@@ -1655,39 +1804,148 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64*1024)
|
||||
var p UserPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&p); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if p.Username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
if err := validateSSHUserPayload(&p); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, p.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableSSH {
|
||||
http.Error(w, "SSH creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, ownerErr := remoteSSHUserInfo(ctx, ms, p.Username)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
currentOwner := ""
|
||||
if exists {
|
||||
currentOwner = strings.TrimSpace(fmt.Sprint(row["owner_username"]))
|
||||
}
|
||||
if exists && currentOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.PreserveExpires = true
|
||||
p.MaxConnections = jsonInt(row["max_connections"])
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, jsonInt(row["max_connections"]), p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !exists {
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
p.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(ctx, ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
p.ServerID = ""
|
||||
body, _ := json.Marshal(p)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodPost, "/api/users/create", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
writeBadGatewayError(w, "create SSH account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, creditOwner, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(ctx)
|
||||
var existingLocalExpiry string
|
||||
var existingLocalUser bool
|
||||
var existingLocalMax int
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var existingOwner string
|
||||
var expiresAt sql.NullTime
|
||||
err := store.db.QueryRowContext(ctx,
|
||||
`SELECT owner_username, expires_at, max_connections FROM ssh_users WHERE username=$1`,
|
||||
p.Username).Scan(&existingOwner, &expiresAt, &existingLocalMax)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err == nil && existingOwner != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
existingLocalUser = err == nil
|
||||
if expiresAt.Valid {
|
||||
existingLocalExpiry = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
if existingLocalUser {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(p.ExpiresAt) != "" && resellerExpiryExtended(existingLocalExpiry, p.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
p.ExpiresAt = existingLocalExpiry
|
||||
p.MaxConnections = existingLocalMax
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(ctx, store, sess.Username, existingLocalMax, p.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if p.PreserveExpires && !existingLocalUser {
|
||||
var expiresAt sql.NullTime
|
||||
if err := store.db.QueryRowContext(ctx,
|
||||
`SELECT expires_at FROM ssh_users WHERE username=$1`, p.Username).Scan(&expiresAt); err == nil && expiresAt.Valid {
|
||||
p.ExpiresAt = expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
// Decide what password to use:
|
||||
// - if payload has non-empty password -> use it
|
||||
// - else try to read existing password from DB
|
||||
@@ -1716,26 +1974,33 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
} else {
|
||||
password = existing
|
||||
password, err = openSSHPassword(existing)
|
||||
if err != nil {
|
||||
log.Printf("failed to decrypt existing password for %s: %v", p.Username, err)
|
||||
http.Error(w, "stored credential is unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine owner and enforce reseller quota
|
||||
sess := sessionFromCtx(ctx)
|
||||
// Determine owner and enforce reseller quota. Credit accounts spend one
|
||||
// credit per allowed connection (minimum one) and receive 31 days.
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
isNewUser := false
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
// Enforce user limit — only count on new user creation
|
||||
var existsInDB bool
|
||||
_ = store.db.QueryRowContext(ctx,
|
||||
`SELECT TRUE FROM ssh_users WHERE username=$1`, p.Username,
|
||||
).Scan(&existsInDB)
|
||||
if !existsInDB {
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if ok && owner.MaxUsers > 0 && countOwnedQuota(ctx, store, sess.Username) >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
if !existingLocalUser {
|
||||
isNewUser = true
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(ctx, store, sess.Username, "ssh:"+p.Username, p.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
p.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(p.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(p.OwnerUsername)
|
||||
@@ -1757,6 +2022,9 @@ func handleCreateUser(store *Store) http.HandlerFunc {
|
||||
}
|
||||
|
||||
if err := store.UpsertUser(ctx, cfg); err != nil {
|
||||
if isNewUser && chargedCredits {
|
||||
refundResellerProvisionCredits(ctx, store, ownerUsername, creditCost, "ssh:"+p.Username)
|
||||
}
|
||||
log.Printf("failed to upsert user: %v", err)
|
||||
http.Error(w, "db error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -1781,15 +2049,15 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
username := r.URL.Query().Get("username")
|
||||
if username == "" {
|
||||
http.Error(w, "username required", http.StatusBadRequest)
|
||||
username := strings.TrimSpace(r.URL.Query().Get("username"))
|
||||
if err := validateAccountUsername(username); err != nil {
|
||||
http.Error(w, "invalid username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
if ms, remote, err := managedServerFromID(ctx, store, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(ctx); sess != nil && sess.Role == RoleReseller && !remoteSSHUserOwned(ctx, ms, username, sess.Username) {
|
||||
@@ -1799,7 +2067,7 @@ func handleDeleteUser(store *Store) http.HandlerFunc {
|
||||
remotePath := "/api/users/delete?username=" + url.QueryEscape(username)
|
||||
status, data, ct, err := proxyManagedServer(ctx, ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete SSH account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2018,6 +2286,19 @@ func handleConn(tcpConn net.Conn, config *ssh.ServerConfig) {
|
||||
trackedConn := newActivityConn(tcpConn)
|
||||
defer trackedConn.Close()
|
||||
|
||||
// Global connection cap: reject before the expensive SSH handshake so a
|
||||
// surge past normal load cannot exhaust memory/CPU. Count first, then check,
|
||||
// so the defer always balances the increment.
|
||||
live := atomic.AddInt64(&activeSSHConns, 1)
|
||||
defer atomic.AddInt64(&activeSSHConns, -1)
|
||||
if lim := atomic.LoadInt64(&maxTotalConns); lim > 0 && live > lim {
|
||||
r := atomic.AddInt64(&sshConnsRejected, 1)
|
||||
if r == 1 || r%1000 == 0 {
|
||||
log.Printf("ssh: global connection cap reached (%d); rejected %d connection(s) so far", lim, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent goroutine leaks from clients that connect but never complete the SSH handshake.
|
||||
_ = trackedConn.SetReadDeadline(time.Now().Add(sshHandshakeTimeout))
|
||||
|
||||
@@ -2124,7 +2405,7 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
return
|
||||
}
|
||||
|
||||
target := fmt.Sprintf("%s:%d", req.Host, req.Port)
|
||||
target := net.JoinHostPort(req.Host, strconv.FormatUint(uint64(req.Port), 10))
|
||||
log.Printf("direct-tcpip: user=%s connecting to %s from %s:%d",
|
||||
u.Cfg.Username, target, req.OriginAddr, req.OriginPort)
|
||||
|
||||
@@ -2142,8 +2423,17 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
return
|
||||
}
|
||||
|
||||
// Shared closer: when either copy direction finishes (including a
|
||||
// half-close that never completes), both sides are force-closed so the
|
||||
// other direction unblocks. Close is idempotent, so calling it from both
|
||||
// directions is safe and no separate waiter goroutine is needed.
|
||||
closeAll := func() {
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
// Drain channel requests concurrently so the peer isn't left waiting.
|
||||
go func() {
|
||||
defer ch.Close()
|
||||
for req := range reqs {
|
||||
if req.WantReply {
|
||||
req.Reply(false, nil)
|
||||
@@ -2151,22 +2441,8 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
}
|
||||
}()
|
||||
|
||||
// Use a WaitGroup + shared closer so that when either copy direction
|
||||
// finishes (including a half-close that never completes), both sides
|
||||
// are force-closed and the other goroutine is unblocked. Without
|
||||
// this, a backend that issues CloseWrite but never closes the read
|
||||
// side would leave the downstream goroutine blocked indefinitely,
|
||||
// leaking a goroutine, a rate-limiter, and the SSH channel.
|
||||
var wg sync.WaitGroup
|
||||
closeAll := func() {
|
||||
_ = backend.Close()
|
||||
_ = ch.Close()
|
||||
}
|
||||
|
||||
// upstream: SSH channel -> backend
|
||||
wg.Add(1)
|
||||
// upstream: SSH channel -> backend, in its own goroutine.
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = copyWithRateLimit(backend, ch, upLimiter)
|
||||
// Signal to the backend that we are done writing.
|
||||
if cw, ok := backend.(interface{ CloseWrite() error }); ok {
|
||||
@@ -2175,19 +2451,11 @@ func handleDirectTCPIP(newChan ssh.NewChannel, u *UserState, upLimiter, downLimi
|
||||
closeAll()
|
||||
}()
|
||||
|
||||
// downstream: backend -> SSH channel
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = copyWithRateLimit(ch, backend, downLimiter)
|
||||
closeAll()
|
||||
}()
|
||||
|
||||
// Wait for both goroutines to finish, then ensure everything is closed.
|
||||
go func() {
|
||||
wg.Wait()
|
||||
closeAll()
|
||||
}()
|
||||
// downstream: backend -> SSH channel, run in this goroutine.
|
||||
// handleDirectTCPIP already runs as its own goroutine (see handleConn),
|
||||
// so reusing it here avoids spawning a third goroutine per channel.
|
||||
_, _ = copyWithRateLimit(ch, backend, downLimiter)
|
||||
closeAll()
|
||||
}
|
||||
|
||||
func handleDummySession(newChan ssh.NewChannel) {
|
||||
@@ -2760,7 +3028,23 @@ func main() {
|
||||
configPath := flag.String("config", "", "path to JSON config file (default: ./config.json if present, otherwise /opt/sshpanel/config.json)")
|
||||
quietFlag := flag.Bool("quiet", false, "override config and disable logs")
|
||||
userCountFlag := flag.Bool("usercount", false, "show per-user connection counters (single line)")
|
||||
hashAdminPasswordStdin := flag.Bool("hash-admin-password-stdin", false, "read an admin password from stdin and print a bcrypt hash")
|
||||
flag.Parse()
|
||||
if *hashAdminPasswordStdin {
|
||||
password, readErr := io.ReadAll(io.LimitReader(os.Stdin, 1025))
|
||||
if readErr != nil {
|
||||
log.Fatalf("read admin password: %v", readErr)
|
||||
}
|
||||
if err := validateAdminPassword(string(password)); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
hash, hashErr := hashAdminPassword(string(password))
|
||||
if hashErr != nil {
|
||||
log.Fatal(hashErr)
|
||||
}
|
||||
fmt.Println(hash)
|
||||
return
|
||||
}
|
||||
|
||||
resolvedConfigPath := resolveMainConfigPath(*configPath)
|
||||
cfg, userMap, err := loadConfig(resolvedConfigPath)
|
||||
@@ -2805,6 +3089,8 @@ func main() {
|
||||
log.Printf("failed to load admin users: %v", err)
|
||||
}
|
||||
startResellerExpiryChecker(store)
|
||||
// Start the Telegram sales bot if enabled in the DB config.
|
||||
startBotService(store)
|
||||
}
|
||||
|
||||
// Optional: initialize interface totals persistence (best-effort).
|
||||
@@ -2843,6 +3129,7 @@ func main() {
|
||||
} else {
|
||||
log.Printf("iface totals persistence disabled: %v", err)
|
||||
}
|
||||
startManagedResellerStateSync(store)
|
||||
}
|
||||
|
||||
// start background collector for CPU + interface stats
|
||||
@@ -2863,6 +3150,9 @@ func main() {
|
||||
|
||||
// Start the integrated Xray-core subprocess if configured.
|
||||
initXrayManager(cfg.Xray)
|
||||
if store != nil {
|
||||
reconcileLocalResellerRuntimeStates(store)
|
||||
}
|
||||
|
||||
// Global banner text (from config or file) — stored in a global so the
|
||||
// admin API can update it on the fly without a restart.
|
||||
@@ -3006,6 +3296,7 @@ func main() {
|
||||
// Initialise default per-connection bandwidth limits and SSH inactivity cleanup.
|
||||
setDefaultLimits(cfg.DefaultLimitMbpsUp, cfg.DefaultLimitMbpsDown)
|
||||
setSSHIdleTimeoutFromConfig(cfg.SSHIdleTimeout)
|
||||
setMaxTotalConnsFromConfig(cfg.MaxTotalConnections)
|
||||
|
||||
// Initialise listener pools (used for initial startup and hot-reload alike).
|
||||
publicPool = newListenerPool(serveHTTP80)
|
||||
|
||||
+300
-40
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -15,6 +16,44 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const managedCredentialPrefix = "enc:v1:"
|
||||
|
||||
func sealManagedCredential(plain string) (string, error) {
|
||||
return sealCredential(managedCredentialPrefix, plain)
|
||||
}
|
||||
|
||||
func openManagedCredential(stored string) (string, error) {
|
||||
return openCredential(managedCredentialPrefix, stored)
|
||||
}
|
||||
|
||||
func managedServerHTTPClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func remoteErrorSnippet(data []byte) string {
|
||||
const limit = 4096
|
||||
truncated := len(data) > limit
|
||||
if truncated {
|
||||
data = data[:limit]
|
||||
}
|
||||
value := strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return ' '
|
||||
}
|
||||
return r
|
||||
}, string(data))
|
||||
value = strings.TrimSpace(value)
|
||||
if truncated {
|
||||
value += "…"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ManagedServer struct {
|
||||
ID int
|
||||
Name string
|
||||
@@ -66,7 +105,43 @@ func (s *Store) EnsureManagedServersSchema(ctx context.Context) error {
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.migrateManagedServerCredentials(ctx)
|
||||
}
|
||||
|
||||
func (s *Store) migrateManagedServerCredentials(ctx context.Context) error {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, admin_key FROM managed_servers WHERE admin_key <> '' AND admin_key NOT LIKE 'enc:v1:%'`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
type legacyCredential struct {
|
||||
id int
|
||||
key string
|
||||
}
|
||||
var legacy []legacyCredential
|
||||
for rows.Next() {
|
||||
var item legacyCredential
|
||||
if err := rows.Scan(&item.id, &item.key); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
legacy = append(legacy, item)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range legacy {
|
||||
sealed, err := sealManagedCredential(item.key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt managed server credential %d: %w", item.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `UPDATE managed_servers SET admin_key=$2 WHERE id=$1`, item.id, sealed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error) {
|
||||
@@ -83,6 +158,11 @@ func (s *Store) ListManagedServers(ctx context.Context) ([]*ManagedServer, error
|
||||
if err := rows.Scan(&ms.ID, &ms.Name, &ms.BaseURL, &ms.AdminUsername, &ms.AdminKey, &ms.EnableSSH, &ms.EnableXray, &ms.IsActive, &ms.CreatedAt, &ms.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
out = append(out, ms)
|
||||
}
|
||||
return out, rows.Err()
|
||||
@@ -100,12 +180,17 @@ func (s *Store) GetManagedServer(ctx context.Context, id int) (*ManagedServer, e
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plainKey, err := openManagedCredential(ms.AdminKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ms.AdminKey = plainKey
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload) (*ManagedServer, error) {
|
||||
name := strings.TrimSpace(p.Name)
|
||||
baseURL := normalizeManagedServerBaseURL(p.BaseURL)
|
||||
baseURL, baseURLErr := validateManagedServerBaseURL(p.BaseURL)
|
||||
adminUsername := strings.TrimSpace(p.AdminUsername)
|
||||
if adminUsername == "" {
|
||||
adminUsername = "admin"
|
||||
@@ -113,8 +198,17 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("server name required")
|
||||
}
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("base url required")
|
||||
if len(name) > 120 || strings.IndexFunc(name, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid server name")
|
||||
}
|
||||
if len(adminUsername) > 128 || strings.IndexFunc(adminUsername, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin username")
|
||||
}
|
||||
if len(p.AdminKey) > 4096 || strings.IndexFunc(p.AdminKey, func(r rune) bool { return r < 0x20 || r == 0x7f }) >= 0 {
|
||||
return nil, fmt.Errorf("invalid admin credential")
|
||||
}
|
||||
if baseURLErr != nil {
|
||||
return nil, baseURLErr
|
||||
}
|
||||
if p.ID != "" && p.ID != "local" {
|
||||
id, err := strconv.Atoi(p.ID)
|
||||
@@ -127,10 +221,14 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
SET name=$2, base_url=$3, admin_username=$4, enable_ssh=$5, enable_xray=$6, is_active=$7, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
} else {
|
||||
sealedKey, sealErr := sealManagedCredential(p.AdminKey)
|
||||
if sealErr != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", sealErr)
|
||||
}
|
||||
_, err = s.db.ExecContext(ctx, `
|
||||
UPDATE managed_servers
|
||||
SET name=$2, base_url=$3, admin_username=$4, admin_key=$5, enable_ssh=$6, enable_xray=$7, is_active=$8, updated_at=NOW()
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
WHERE id=$1`, id, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -140,8 +238,12 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
if strings.TrimSpace(p.AdminKey) == "" {
|
||||
return nil, fmt.Errorf("admin key/password required")
|
||||
}
|
||||
sealedKey, err := sealManagedCredential(p.AdminKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt admin credential: %w", err)
|
||||
}
|
||||
var id int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
err = s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO managed_servers (name, base_url, admin_username, admin_key, enable_ssh, enable_xray, is_active)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
ON CONFLICT (base_url) DO UPDATE SET
|
||||
@@ -152,7 +254,7 @@ func (s *Store) UpsertManagedServer(ctx context.Context, p ManagedServerPayload)
|
||||
enable_xray=EXCLUDED.enable_xray,
|
||||
is_active=EXCLUDED.is_active,
|
||||
updated_at=NOW()
|
||||
RETURNING id`, name, baseURL, adminUsername, p.AdminKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
RETURNING id`, name, baseURL, adminUsername, sealedKey, p.EnableSSH, p.EnableXray, p.IsActive).Scan(&id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -193,21 +295,39 @@ func localManagedServerDTO() ManagedServerDTO {
|
||||
}
|
||||
|
||||
func normalizeManagedServerBaseURL(raw string) string {
|
||||
normalized, _ := validateManagedServerBaseURL(raw)
|
||||
return normalized
|
||||
}
|
||||
|
||||
func validateManagedServerBaseURL(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("base url required")
|
||||
}
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
lowerRaw := strings.ToLower(raw)
|
||||
if !strings.HasPrefix(lowerRaw, "http://") && !strings.HasPrefix(lowerRaw, "https://") {
|
||||
raw = "http://" + raw
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return ""
|
||||
return "", fmt.Errorf("invalid base url")
|
||||
}
|
||||
u.Path = strings.TrimRight(u.Path, "/")
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return "", fmt.Errorf("base url must use http or https")
|
||||
}
|
||||
if u.User != nil {
|
||||
return "", fmt.Errorf("base url must not contain credentials")
|
||||
}
|
||||
if u.Path != "" && u.Path != "/" {
|
||||
return "", fmt.Errorf("base url must not contain a path")
|
||||
}
|
||||
if ip := net.ParseIP(u.Hostname()); ip != nil && (ip.IsUnspecified() || ip.IsMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) {
|
||||
return "", fmt.Errorf("base url uses a forbidden address")
|
||||
}
|
||||
u.Path = ""
|
||||
u.RawQuery = ""
|
||||
u.Fragment = ""
|
||||
return strings.TrimRight(u.String(), "/")
|
||||
return strings.TrimRight(u.String(), "/"), nil
|
||||
}
|
||||
|
||||
func requestedServerID(r *http.Request) string {
|
||||
@@ -243,6 +363,36 @@ func managedServerFromID(ctx context.Context, store *Store, id string) (*Managed
|
||||
return ms, true, nil
|
||||
}
|
||||
|
||||
func writeManagedServerSelectionError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
switch err.Error() {
|
||||
case "invalid server id", "server not found", "server is disabled":
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
case "database not configured":
|
||||
http.Error(w, err.Error(), http.StatusServiceUnavailable)
|
||||
default:
|
||||
writeInternalError(w, "select managed server", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeManagedServerSaveError(w http.ResponseWriter, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
message := err.Error()
|
||||
safe := message == "server name required" || message == "invalid server name" ||
|
||||
message == "invalid admin username" || message == "invalid admin credential" ||
|
||||
message == "invalid server id" || message == "admin key/password required" ||
|
||||
strings.HasPrefix(message, "base url") || message == "invalid base url"
|
||||
if safe {
|
||||
http.Error(w, message, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeInternalError(w, "save managed server", err)
|
||||
}
|
||||
|
||||
func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
body, _ := json.Marshal(map[string]string{"username": ms.AdminUsername, "password": ms.AdminKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ms.BaseURL+"/api/auth/login", bytes.NewReader(body))
|
||||
@@ -250,7 +400,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
client := managedServerHTTPClient(15 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -258,7 +408,7 @@ func remoteLoginToken(ctx context.Context, ms *ManagedServer) (string, error) {
|
||||
defer resp.Body.Close()
|
||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 128*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("remote login failed: %s", strings.TrimSpace(string(data)))
|
||||
return "", fmt.Errorf("remote login failed with HTTP %d: %q", resp.StatusCode, remoteErrorSnippet(data))
|
||||
}
|
||||
var out struct {
|
||||
Token string `json:"token"`
|
||||
@@ -286,7 +436,7 @@ func proxyManagedServer(ctx context.Context, ms *ManagedServer, method, path str
|
||||
}
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
req.Header.Set("X-Session-Token", token)
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := managedServerHTTPClient(30 * time.Second)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, nil, "", err
|
||||
@@ -306,6 +456,13 @@ func handleManagedProxyOrLocal(store *Store, local http.HandlerFunc) http.Handle
|
||||
}
|
||||
|
||||
func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentType string) {
|
||||
if status >= http.StatusInternalServerError {
|
||||
if len(body) > 0 {
|
||||
log.Printf("managed server returned HTTP %d: %q", status, remoteErrorSnippet(body))
|
||||
}
|
||||
body = []byte("managed server request failed\n")
|
||||
contentType = "text/plain; charset=utf-8"
|
||||
}
|
||||
if contentType != "" {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
}
|
||||
@@ -321,7 +478,7 @@ func writeProxyResponse(w http.ResponseWriter, status int, body []byte, contentT
|
||||
func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store *Store, remotePath string, body []byte, filterOwner string) bool {
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, requestedServerID(r))
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return true
|
||||
}
|
||||
if !remote {
|
||||
@@ -343,7 +500,7 @@ func proxyManagedServerFromRequest(w http.ResponseWriter, r *http.Request, store
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, remotePath, body, r.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server request", err)
|
||||
return true
|
||||
}
|
||||
if status >= 200 && status < 300 && filterOwner != "" && strings.Contains(ct, "json") {
|
||||
@@ -434,7 +591,7 @@ func handleServers(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, err := store.UpsertManagedServer(r.Context(), p)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSaveError(w, err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -503,17 +660,17 @@ func handleServerTest(store *Store) http.HandlerFunc {
|
||||
}
|
||||
token, err := remoteLoginToken(r.Context(), ms)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server login", err)
|
||||
return
|
||||
}
|
||||
_ = token
|
||||
status, data, _, err := proxyManagedServer(r.Context(), ms, http.MethodGet, "/api/auth/me", nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
http.Error(w, strings.TrimSpace(string(data)), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "test managed server session", fmt.Errorf("HTTP %d: %q", status, remoteErrorSnippet(data)))
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -543,7 +700,7 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
ms, remote, err := managedServerFromID(r.Context(), store, id)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
}
|
||||
if !remote {
|
||||
@@ -552,54 +709,157 @@ func handleManagedServerConfig(store *Store) http.HandlerFunc {
|
||||
}
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, r.Method, "/api/server/config", body, "application/json")
|
||||
if err != nil {
|
||||
log.Printf("managed server config proxy %s: %v", ms.BaseURL, err)
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "proxy managed server configuration", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
}
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
if owner == "" || username == "" {
|
||||
return false
|
||||
func remoteSSHUserInfo(ctx context.Context, ms *ManagedServer, username string) (map[string]interface{}, bool, error) {
|
||||
if username == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var rows []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &rows); err != nil {
|
||||
return false
|
||||
return nil, false, err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if fmt.Sprint(row["username"]) == username && fmt.Sprint(row["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(row["username"]) == username {
|
||||
return row, true, nil
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
if owner == "" || uuid == "" {
|
||||
return false
|
||||
func remoteSSHUserOwner(ctx context.Context, ms *ManagedServer, username string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteSSHUserInfo(ctx, ms, username)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
|
||||
func remoteSSHUserOwned(ctx context.Context, ms *ManagedServer, username, owner string) bool {
|
||||
actualOwner, exists, err := remoteSSHUserOwner(ctx, ms, username)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
func remoteXrayClientInfo(ctx context.Context, ms *ManagedServer, uuid string) (map[string]interface{}, bool, error) {
|
||||
if uuid == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
return false
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return false
|
||||
return nil, false, err
|
||||
}
|
||||
for _, ib := range inbounds {
|
||||
clients, _ := ib["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
m, _ := c.(map[string]interface{})
|
||||
if fmt.Sprint(m["id"]) == uuid && fmt.Sprint(m["owner_username"]) == owner {
|
||||
return true
|
||||
if fmt.Sprint(m["id"]) == uuid {
|
||||
m["inbound_tag"] = fmt.Sprint(ib["tag"])
|
||||
return m, true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwner(ctx context.Context, ms *ManagedServer, uuid string) (owner string, exists bool, err error) {
|
||||
row, exists, err := remoteXrayClientInfo(ctx, ms, uuid)
|
||||
if err != nil || !exists {
|
||||
return "", exists, err
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(row["owner_username"])), true, nil
|
||||
}
|
||||
|
||||
func remoteXrayClientOwned(ctx context.Context, ms *ManagedServer, uuid, owner string) bool {
|
||||
actualOwner, exists, err := remoteXrayClientOwner(ctx, ms, uuid)
|
||||
return err == nil && exists && actualOwner == owner
|
||||
}
|
||||
|
||||
type resellerQuotaUsage struct {
|
||||
Weighted int
|
||||
SSHAccounts int
|
||||
XrayAccounts int
|
||||
}
|
||||
|
||||
func ownedQuotaUsageAcrossManagedServers(ctx context.Context, store *Store, owner string) (resellerQuotaUsage, error) {
|
||||
usage := resellerQuotaUsage{}
|
||||
if owner == "" {
|
||||
return usage, nil
|
||||
}
|
||||
usage.Weighted = countOwnedQuota(ctx, store, owner)
|
||||
usage.SSHAccounts = countOwnedUsers(owner)
|
||||
usage.XrayAccounts = countOwnedXrayClients(ctx, store, owner)
|
||||
if store == nil {
|
||||
return usage, nil
|
||||
}
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, ms := range servers {
|
||||
// Count every configured node and both account types. Temporarily disabling
|
||||
// a node or a protocol must not release its committed reseller quota.
|
||||
status, data, _, err := proxyManagedServer(ctx, ms, http.MethodGet, "/api/users", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote users returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var users []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &users); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, user := range users {
|
||||
if strings.TrimSpace(fmt.Sprint(user["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(user["max_connections"]))
|
||||
usage.SSHAccounts++
|
||||
}
|
||||
}
|
||||
status, data, _, err = proxyManagedServer(ctx, ms, http.MethodGet, "/api/xray/inbounds", nil, "application/json")
|
||||
if err != nil || status < 200 || status >= 300 {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("remote Xray inbounds returned HTTP %d", status)
|
||||
}
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
var inbounds []map[string]interface{}
|
||||
if err := json.Unmarshal(data, &inbounds); err != nil {
|
||||
return resellerQuotaUsage{}, err
|
||||
}
|
||||
for _, inbound := range inbounds {
|
||||
clients, _ := inbound["clients"].([]interface{})
|
||||
for _, client := range clients {
|
||||
item, _ := client.(map[string]interface{})
|
||||
if strings.TrimSpace(fmt.Sprint(item["owner_username"])) == owner {
|
||||
usage.Weighted += resellerProvisionCost(jsonInt(item["max_conns"]))
|
||||
usage.XrayAccounts++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return usage, nil
|
||||
}
|
||||
|
||||
func countOwnedQuotaAcrossManagedServers(ctx context.Context, store *Store, owner string) (int, error) {
|
||||
usage, err := ownedQuotaUsageAcrossManagedServers(ctx, store, owner)
|
||||
return usage.Weighted, err
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func handleSystemLogsReset(w http.ResponseWriter, r *http.Request) {
|
||||
path := panelLogFilePath()
|
||||
maxBytes := panelLogMaxBytes()
|
||||
if err := truncatePanelLog(path, maxBytes, "manual clean from admin panel"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "clear panel log", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNormalizeQuotaMode(t *testing.T) {
|
||||
for input, expected := range map[string]string{
|
||||
"": QuotaModeSlots,
|
||||
"slots": QuotaModeSlots,
|
||||
"Validade": QuotaModeSlots,
|
||||
"credits": QuotaModeCredit,
|
||||
"Credito": QuotaModeCredit,
|
||||
} {
|
||||
if got := normalizeQuotaMode(input); got != expected {
|
||||
t.Fatalf("normalizeQuotaMode(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerProvisionCost(t *testing.T) {
|
||||
for input, expected := range map[int]int{-10: 1, 0: 1, 1: 1, 3: 3} {
|
||||
if got := resellerProvisionCost(input); got != expected {
|
||||
t.Fatalf("resellerProvisionCost(%d) = %d, want %d", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListResellerSubtree(t *testing.T) {
|
||||
all := []*AdminUser{
|
||||
{Username: "root", Role: RoleReseller},
|
||||
{Username: "child-a", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "child-b", Role: RoleReseller, ParentUsername: "root"},
|
||||
{Username: "grandchild", Role: RoleReseller, ParentUsername: "child-a"},
|
||||
{Username: "admin", Role: RoleSuperAdmin},
|
||||
}
|
||||
got := listResellerSubtree(all, "root")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("subtree size = %d, want 4", len(got))
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, user := range got {
|
||||
seen[user.Username] = true
|
||||
}
|
||||
for _, username := range []string{"root", "child-a", "child-b", "grandchild"} {
|
||||
if !seen[username] {
|
||||
t.Fatalf("subtree does not contain %q", username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerCanManageOnlyDirectChildren(t *testing.T) {
|
||||
sess := &AdminSession{Username: "parent", Role: RoleReseller}
|
||||
if !resellerCanManage(sess, &AdminUser{Username: "child", Role: RoleReseller, ParentUsername: "parent"}) {
|
||||
t.Fatal("parent could not manage its direct child")
|
||||
}
|
||||
if resellerCanManage(sess, &AdminUser{Username: "grandchild", Role: RoleReseller, ParentUsername: "child"}) {
|
||||
t.Fatal("parent was allowed to skip a hierarchy level")
|
||||
}
|
||||
admin := &AdminSession{Username: "admin", Role: RoleSuperAdmin}
|
||||
if !resellerCanManage(admin, &AdminUser{Username: "any", Role: RoleReseller}) {
|
||||
t.Fatal("superadmin could not manage a reseller")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResellerExpiryExtensionDetection(t *testing.T) {
|
||||
existing := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second)
|
||||
if resellerExpiryExtended(existing.Format(time.RFC3339), existing.Format(time.RFC3339)) {
|
||||
t.Fatal("unchanged expiration was treated as an extension")
|
||||
}
|
||||
if !resellerExpiryExtended(existing.Format(time.RFC3339), existing.Add(time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("later expiration was not treated as an extension")
|
||||
}
|
||||
if resellerTimeExtended(&existing, existing.Add(-time.Hour).Format(time.RFC3339)) {
|
||||
t.Fatal("shorter expiration was treated as an extension")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewalExpiryUsesLaterBase(t *testing.T) {
|
||||
future := time.Now().Add(72 * time.Hour)
|
||||
got := renewalExpiry(&future, 30)
|
||||
want := future.AddDate(0, 0, 30)
|
||||
if got.Sub(want) > time.Second || want.Sub(got) > time.Second {
|
||||
t.Fatalf("renewal expiry = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminAccountChainUsesPasswordFreeRuntimeState(t *testing.T) {
|
||||
parent := "runtime-parent-test"
|
||||
child := "runtime-child-test"
|
||||
adminUsers.delete(parent)
|
||||
adminUsers.delete(child)
|
||||
defer resellerRuntimeStates.delete(parent)
|
||||
defer resellerRuntimeStates.delete(child)
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: true})
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: child, ParentUsername: parent, IsActive: true})
|
||||
if err := adminAccountChainActive(child); err != nil {
|
||||
t.Fatalf("active replicated hierarchy was rejected: %v", err)
|
||||
}
|
||||
|
||||
resellerRuntimeStates.set(ResellerRuntimeState{OwnerUsername: parent, IsActive: false})
|
||||
if err := adminAccountChainActive(child); err == nil {
|
||||
t.Fatal("child remained active while its replicated parent was suspended")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ResellerRuntimeState is a password-free ownership record replicated from a
|
||||
// master panel to its managed nodes. It lets a node enforce reseller
|
||||
// suspension and parent hierarchy locally without copying login credentials.
|
||||
type ResellerRuntimeState struct {
|
||||
OwnerUsername string
|
||||
ParentUsername string
|
||||
IsActive bool
|
||||
ExpiresAt *time.Time
|
||||
}
|
||||
|
||||
type resellerRuntimeStateCacheT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]ResellerRuntimeState
|
||||
}
|
||||
|
||||
var resellerRuntimeStates = &resellerRuntimeStateCacheT{m: make(map[string]ResellerRuntimeState)}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) get(username string) (ResellerRuntimeState, bool) {
|
||||
m.mu.RLock()
|
||||
state, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return state, ok
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) set(state ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m[state.OwnerUsername] = state
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) list() []ResellerRuntimeState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ResellerRuntimeState, 0, len(m.m))
|
||||
for _, state := range m.m {
|
||||
out = append(out, state)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *resellerRuntimeStateCacheT) replaceAll(states []ResellerRuntimeState) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]ResellerRuntimeState, len(states))
|
||||
for _, state := range states {
|
||||
m.m[state.OwnerUsername] = state
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Store) ListResellerRuntimeStates(ctx context.Context) ([]ResellerRuntimeState, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT owner_username, parent_username, is_active, expires_at
|
||||
FROM reseller_runtime_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ResellerRuntimeState
|
||||
for rows.Next() {
|
||||
var state ResellerRuntimeState
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&state.OwnerUsername, &state.ParentUsername, &state.IsActive, &expiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
state.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, state)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertResellerRuntimeState(ctx context.Context, state ResellerRuntimeState) error {
|
||||
var expiresAt interface{}
|
||||
if state.ExpiresAt != nil {
|
||||
expiresAt = *state.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO reseller_runtime_state
|
||||
(owner_username, parent_username, is_active, expires_at, updated_at)
|
||||
VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (owner_username) DO UPDATE SET
|
||||
parent_username=EXCLUDED.parent_username,
|
||||
is_active=EXCLUDED.is_active,
|
||||
expires_at=EXCLUDED.expires_at,
|
||||
updated_at=NOW()`,
|
||||
state.OwnerUsername, state.ParentUsername, state.IsActive, expiresAt)
|
||||
if err == nil {
|
||||
resellerRuntimeStates.set(state)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteResellerRuntimeState(ctx context.Context, owner string) error {
|
||||
if _, err := s.db.ExecContext(ctx, `DELETE FROM reseller_runtime_state WHERE owner_username=$1`, owner); err != nil {
|
||||
return err
|
||||
}
|
||||
resellerRuntimeStates.delete(owner)
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeChainActive(username string) error {
|
||||
seen := make(map[string]bool)
|
||||
now := time.Now()
|
||||
for depth := 0; username != "" && depth < 128; depth++ {
|
||||
if seen[username] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[username] = true
|
||||
state, ok := resellerRuntimeStates.get(username)
|
||||
if !ok {
|
||||
return fmt.Errorf("reseller runtime state not found")
|
||||
}
|
||||
if !state.IsActive {
|
||||
return fmt.Errorf("reseller account suspended")
|
||||
}
|
||||
if state.ExpiresAt != nil && now.After(*state.ExpiresAt) {
|
||||
return fmt.Errorf("reseller account expired")
|
||||
}
|
||||
username = strings.TrimSpace(state.ParentUsername)
|
||||
}
|
||||
if username != "" {
|
||||
return fmt.Errorf("reseller hierarchy is too deep")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resellerRuntimeStateFor(owner string, effectiveActive bool) (ResellerRuntimeState, error) {
|
||||
u, ok := adminUsers.get(owner)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return ResellerRuntimeState{}, fmt.Errorf("reseller account not found")
|
||||
}
|
||||
return ResellerRuntimeState{
|
||||
OwnerUsername: u.Username,
|
||||
ParentUsername: u.ParentUsername,
|
||||
IsActive: effectiveActive,
|
||||
ExpiresAt: u.ExpiresAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// syncOwnerChainToManagedServer makes account creation on a managed node safe:
|
||||
// every parent is installed before the child, and no password/hash is sent.
|
||||
func syncOwnerChainToManagedServer(ctx context.Context, ms *ManagedServer, owner string) error {
|
||||
var chain []*AdminUser
|
||||
seen := make(map[string]bool)
|
||||
for current := strings.TrimSpace(owner); current != ""; {
|
||||
if seen[current] {
|
||||
return fmt.Errorf("reseller hierarchy cycle detected")
|
||||
}
|
||||
seen[current] = true
|
||||
u, ok := adminUsers.get(current)
|
||||
if !ok || u.Role != RoleReseller {
|
||||
return fmt.Errorf("reseller account not found")
|
||||
}
|
||||
chain = append(chain, u)
|
||||
current = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
for i := len(chain) - 1; i >= 0; i-- {
|
||||
state, err := resellerRuntimeStateFor(chain[i].Username, adminAccountChainActive(chain[i].Username) == nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := resellerRuntimePayloadFromState(state, "sync")
|
||||
if err := sendResellerRuntimeToServer(ctx, ms, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAllResellerRuntimeStates repairs legacy managed nodes after an upgrade.
|
||||
// It runs asynchronously and never prevents the local panel from starting.
|
||||
func startManagedResellerStateSync(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
servers, err := store.ListManagedServers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller state sync: %v", err)
|
||||
return
|
||||
}
|
||||
users := adminUsers.list()
|
||||
sort.SliceStable(users, func(i, j int) bool {
|
||||
return resellerHierarchyDepth(users[i].Username) < resellerHierarchyDepth(users[j].Username)
|
||||
})
|
||||
for _, ms := range servers {
|
||||
for _, u := range users {
|
||||
if u.Role != RoleReseller {
|
||||
continue
|
||||
}
|
||||
action := "suspend"
|
||||
active := adminAccountChainActive(u.Username) == nil
|
||||
if active {
|
||||
action = "reactivate"
|
||||
}
|
||||
state, stateErr := resellerRuntimeStateFor(u.Username, active)
|
||||
if stateErr != nil {
|
||||
continue
|
||||
}
|
||||
if sendErr := sendResellerRuntimeToServer(ctx, ms, resellerRuntimePayloadFromState(state, action)); sendErr != nil {
|
||||
log.Printf("reseller state sync to %s for %s: %v", ms.Name, u.Username, sendErr)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// reconcileLocalResellerRuntimeStates reapplies replicated ownership state
|
||||
// after a managed node restarts.
|
||||
func reconcileLocalResellerRuntimeStates(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
ctx := context.Background()
|
||||
for _, state := range resellerRuntimeStates.list() {
|
||||
action := "suspend"
|
||||
if resellerRuntimeChainActive(state.OwnerUsername) == nil {
|
||||
action = "reactivate"
|
||||
}
|
||||
if err := applyOwnerRuntimeLocal(ctx, store, state.OwnerUsername, action); err != nil {
|
||||
log.Printf("reconcile local reseller runtime for %s: %v", state.OwnerUsername, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resellerHierarchyDepth(username string) int {
|
||||
seen := make(map[string]bool)
|
||||
depth := 0
|
||||
for username != "" && depth < 128 {
|
||||
if seen[username] {
|
||||
return 128
|
||||
}
|
||||
seen[username] = true
|
||||
u, ok := adminUsers.get(username)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
depth++
|
||||
username = strings.TrimSpace(u.ParentUsername)
|
||||
}
|
||||
return depth
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdminPasswordHashAndLegacyUpgrade(t *testing.T) {
|
||||
password := "correct-horse-battery-staple"
|
||||
hash, err := hashAdminPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if valid, upgrade := verifyAdminPassword(hash, password); !valid || upgrade {
|
||||
t.Fatalf("bcrypt verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
if valid, _ := verifyAdminPassword(hash, "wrong-password"); valid {
|
||||
t.Fatal("wrong bcrypt password was accepted")
|
||||
}
|
||||
legacy := legacyAdminPasswordHash(password)
|
||||
if valid, upgrade := verifyAdminPassword(legacy, password); !valid || !upgrade {
|
||||
t.Fatalf("legacy verification = valid %v, upgrade %v", valid, upgrade)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSDomainRejectsTraversal(t *testing.T) {
|
||||
for _, value := range []string{"../root", `..\\root`, "/absolute", "host\nname"} {
|
||||
if _, _, err := normalizeTLSDomain(value, true); err == nil {
|
||||
t.Fatalf("normalizeTLSDomain(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if domain, _, err := normalizeTLSDomain("vpn.example.com", false); err != nil || domain != "vpn.example.com" {
|
||||
t.Fatalf("valid domain rejected: %q, %v", domain, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedServerURLValidation(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"ftp://example.com", "https://user:pass@example.com", "https://example.com/admin", "http://169.254.10.20",
|
||||
} {
|
||||
if _, err := validateManagedServerBaseURL(value); err == nil {
|
||||
t.Fatalf("validateManagedServerBaseURL(%q) accepted unsafe value", value)
|
||||
}
|
||||
}
|
||||
if got, err := validateManagedServerBaseURL("https://node.example.com/"); err != nil || got != "https://node.example.com" {
|
||||
t.Fatalf("valid managed server URL = %q, %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteErrorSnippetIsBoundedAndSingleLine(t *testing.T) {
|
||||
input := make([]byte, 5000)
|
||||
for i := range input {
|
||||
input[i] = 'x'
|
||||
}
|
||||
copy(input, []byte("first\nsecond\r\tsecret"))
|
||||
got := remoteErrorSnippet(input)
|
||||
if len(got) > 4100 {
|
||||
t.Fatalf("remote error snippet is too long: %d", len(got))
|
||||
}
|
||||
for _, r := range got {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
t.Fatalf("remote error snippet retained control character %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMPSignatureRequiresSecretAndValidHMAC(t *testing.T) {
|
||||
const (
|
||||
secret = "test-secret-with-enough-entropy"
|
||||
dataID = "123456789"
|
||||
requestID = "request-123"
|
||||
)
|
||||
ts := time.Now().Format("150405")
|
||||
manifest := "id:" + dataID + ";request-id:" + requestID + ";ts:" + ts + ";"
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
_, _ = mac.Write([]byte(manifest))
|
||||
signature := "ts=" + ts + ",v1=" + hex.EncodeToString(mac.Sum(nil))
|
||||
if !verifyMPSignature(signature, requestID, dataID, secret) {
|
||||
t.Fatal("valid Mercado Pago signature was rejected")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "") {
|
||||
t.Fatal("unsigned webhook mode was accepted")
|
||||
}
|
||||
if verifyMPSignature(signature, requestID, dataID, "wrong-secret") {
|
||||
t.Fatal("signature with wrong secret was accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAdminRequestBody = 8 << 20
|
||||
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeBadGatewayError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("%s: %v", operation, err)
|
||||
}
|
||||
http.Error(w, "managed server request failed", http.StatusBadGateway)
|
||||
}
|
||||
|
||||
// securePanelHandler applies baseline browser protections and a global request
|
||||
// body ceiling. Endpoint-specific handlers may impose a smaller limit.
|
||||
func securePanelHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), payment=()")
|
||||
w.Header().Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; base-uri 'none'; frame-ancestors 'none'; object-src 'none'; form-action 'self'; img-src 'self' data:; connect-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'")
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
if r.Body != nil && r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestBody)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// writeFileAtomic replaces a sensitive configuration file without leaving a
|
||||
// partially written file behind after a crash or interrupted request.
|
||||
func writeFileAtomic(path string, data []byte, mode os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".dragoncore-*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
if err := tmp.Chmod(mode); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
return fmt.Errorf("replace %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -66,7 +66,7 @@ func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read server configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -78,11 +78,15 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024+1))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 512*1024 {
|
||||
http.Error(w, "config exceeds 512 KiB", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
@@ -110,8 +114,8 @@ func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "marshal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(globalCfgPath, out, 0o644); err != nil {
|
||||
http.Error(w, "failed to write config: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(globalCfgPath, out, 0o600); err != nil {
|
||||
writeInternalError(w, "write server configuration", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+110
-37
@@ -4,21 +4,57 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/mail"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsCertsDir = "/opt/sshpanel/certs"
|
||||
|
||||
var (
|
||||
tlsDNSNamePattern = regexp.MustCompile(`^(?:\*\.)?(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
tlsStoreNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
)
|
||||
|
||||
func normalizeTLSDomain(raw string, allowWildcard bool) (domain string, dirName string, err error) {
|
||||
domain = strings.TrimSuffix(strings.TrimSpace(raw), ".")
|
||||
if domain == "" || len(domain) > 253 || strings.ContainsAny(domain, "/\\\x00\r\n") {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
if ip := net.ParseIP(domain); ip != nil {
|
||||
return domain, strings.ReplaceAll(domain, ":", "_"), nil
|
||||
}
|
||||
if strings.HasPrefix(domain, "*.") && !allowWildcard {
|
||||
return "", "", fmt.Errorf("wildcard domains are not supported by this operation")
|
||||
}
|
||||
if !tlsDNSNamePattern.MatchString(domain) {
|
||||
return "", "", fmt.Errorf("invalid domain")
|
||||
}
|
||||
dirName = strings.ReplaceAll(domain, "*", "_wildcard_")
|
||||
return domain, dirName, nil
|
||||
}
|
||||
|
||||
func normalizeTLSStoreName(raw string) (string, error) {
|
||||
name := strings.TrimSpace(raw)
|
||||
if !tlsStoreNamePattern.MatchString(name) {
|
||||
return "", fmt.Errorf("name must use only letters, numbers, dot, underscore, or hyphen")
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
// handleTLSGenerateSelfSigned generates a self-signed TLS certificate for the
|
||||
// given domain, writes it to /opt/sshpanel/certs/<domain>/, and returns the paths.
|
||||
func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,14 +65,20 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
domain, dirName, err := normalizeTLSDomain(req.Domain, true)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, req.Domain)
|
||||
certDir := filepath.Join(tlsCertsDir, dirName)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
@@ -44,43 +86,48 @@ func handleTLSGenerateSelfSigned(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS private key", err)
|
||||
return
|
||||
}
|
||||
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||
serialNumber, err := rand.Int(rand.Reader, serialLimit)
|
||||
if err != nil {
|
||||
http.Error(w, "serial generation failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: req.Domain},
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{CommonName: domain},
|
||||
NotBefore: time.Now().Add(-time.Minute),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{req.Domain},
|
||||
DNSNames: []string{domain},
|
||||
}
|
||||
if net.ParseIP(domain) != nil {
|
||||
tmpl.DNSNames = nil
|
||||
tmpl.IPAddresses = []net.IP{net.ParseIP(domain)}
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "generate TLS certificate", err)
|
||||
return
|
||||
}
|
||||
cf, err := os.OpenFile(certFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(cf, &pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
cf.Close()
|
||||
|
||||
privDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
http.Error(w, "marshal key: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "encode TLS private key", err)
|
||||
return
|
||||
}
|
||||
kf, err := os.OpenFile(keyFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
if err := writeFileAtomic(certFile, certPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := writeFileAtomic(keyFile, keyPEM, 0o600); err != nil {
|
||||
writeInternalError(w, "write TLS private key", err)
|
||||
return
|
||||
}
|
||||
_ = pem.Encode(kf, &pem.Block{Type: "EC PRIVATE KEY", Bytes: privDER})
|
||||
kf.Close()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -100,21 +147,33 @@ func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16*1024)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" || req.Email == "" {
|
||||
http.Error(w, "domain and email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", req.Email, "-d", req.Domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
http.Error(w, fmt.Sprintf("certbot failed: %v\n%s", err, string(out)), http.StatusInternalServerError)
|
||||
domain, _, err := normalizeTLSDomain(req.Domain, false)
|
||||
if err != nil || net.ParseIP(domain) != nil {
|
||||
http.Error(w, "a valid DNS domain is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
email := strings.TrimSpace(req.Email)
|
||||
parsedEmail, err := mail.ParseAddress(email)
|
||||
if err != nil || parsedEmail.Address != email || len(email) > 254 {
|
||||
http.Error(w, "valid email required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem"
|
||||
cmd := exec.Command("certbot", "certonly", "--standalone", "--non-interactive",
|
||||
"--agree-tos", "-m", email, "-d", domain)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
writeInternalError(w, "obtain Let's Encrypt certificate", fmt.Errorf("certbot: %w: %s", err, strings.TrimSpace(string(out))))
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + domain + "/privkey.pem"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
@@ -136,28 +195,42 @@ func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 2<<20)
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" || req.Cert == "" || req.Key == "" {
|
||||
http.Error(w, "name, cert, and key required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
name := filepath.Base(req.Name)
|
||||
if name == "." || name == "/" || name == "" {
|
||||
http.Error(w, "invalid name", http.StatusBadRequest)
|
||||
name, err := normalizeTLSStoreName(req.Name)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Cert) > 1024*1024 || len(req.Key) > 1024*1024 {
|
||||
http.Error(w, "certificate or key is too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
pair, err := tls.X509KeyPair([]byte(req.Cert), []byte(req.Key))
|
||||
if err != nil || len(pair.Certificate) == 0 {
|
||||
http.Error(w, "certificate and private key are invalid or do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, err := x509.ParseCertificate(pair.Certificate[0]); err != nil {
|
||||
http.Error(w, "invalid leaf certificate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "create uploaded TLS certificate directory", err)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
if err := os.WriteFile(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
http.Error(w, "write cert: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(certFile, []byte(req.Cert), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS certificate", err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
if err := writeFileAtomic(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
writeInternalError(w, "write uploaded TLS private key", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
+58
-11
@@ -25,10 +25,24 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Safe ceilings applied regardless of config, so oversized values left in an
|
||||
// existing config.json cannot bloat per-client memory at scale.
|
||||
const (
|
||||
udpgwSocketBufferMax = 512 * 1024 // per-client UDP socket buffer (kernel memory)
|
||||
udpgwWriteChanMax = 1024 // per-client reply queue slots
|
||||
udpgwDefaultMaxClients = 10000 // total concurrent client cap
|
||||
)
|
||||
|
||||
var (
|
||||
udpgwMu sync.Mutex
|
||||
udpgwLn net.Listener
|
||||
udpgwClients = make(map[net.Conn]struct{})
|
||||
// udpgwClientLimit is the max concurrent clients (0 = unlimited). Set when
|
||||
// the listener starts and read on every accept, all under udpgwMu.
|
||||
udpgwClientLimit int
|
||||
// udpgwClientsRejected counts clients turned away at the cap, for logging
|
||||
// and future stats. Guarded by udpgwMu.
|
||||
udpgwClientsRejected int64
|
||||
|
||||
udpgwAutoMu sync.Mutex
|
||||
udpgwAutoCancel context.CancelFunc
|
||||
@@ -71,6 +85,17 @@ func registerUDPGWClient(conn net.Conn) bool {
|
||||
_ = conn.Close()
|
||||
return false
|
||||
}
|
||||
// Reject past the hard client cap so a surge cannot exhaust memory.
|
||||
if udpgwClientLimit > 0 && len(udpgwClients) >= udpgwClientLimit {
|
||||
udpgwClientsRejected++
|
||||
rejected := udpgwClientsRejected
|
||||
_ = conn.Close()
|
||||
// Log the first rejection and then every 1000th to avoid log spam.
|
||||
if rejected == 1 || rejected%1000 == 0 {
|
||||
log.Printf("udpgw: client cap reached (%d); rejected %d client(s) so far", udpgwClientLimit, rejected)
|
||||
}
|
||||
return false
|
||||
}
|
||||
udpgwClients[conn] = struct{}{}
|
||||
return true
|
||||
}
|
||||
@@ -131,21 +156,29 @@ func startUDPGWInstance(cfg *UDPGWConfig) error {
|
||||
} else {
|
||||
c.hexdumpN = 64
|
||||
}
|
||||
if cfg.WriteChan > 0 {
|
||||
// Per-client outgoing frame queue. A large queue costs ~24 B/slot of heap
|
||||
// per client even when empty; at thousands of clients that adds up. UDP is
|
||||
// lossy by nature, so a smaller queue that drops under backpressure is fine.
|
||||
// The value is clamped to udpgwWriteChanMax so an oversized value left in an
|
||||
// old config.json is ignored; only smaller custom values are honored.
|
||||
c.writeChan = udpgwWriteChanMax
|
||||
if cfg.WriteChan > 0 && cfg.WriteChan < udpgwWriteChanMax {
|
||||
c.writeChan = cfg.WriteChan
|
||||
} else {
|
||||
c.writeChan = 4096
|
||||
}
|
||||
c.udpBindIP = cfg.UDPBindIP
|
||||
if cfg.UDPRBuf > 0 {
|
||||
// Per-client UDP socket buffers are KERNEL memory, allocated per connected
|
||||
// client. 8 MB per socket is fine for a single process-wide listener, but
|
||||
// here every tunnel user gets its own socket, so a large value multiplied by
|
||||
// thousands of clients can exhaust kernel memory (especially if
|
||||
// net.core.rmem_max was raised for DNSTT). Clamp to udpgwSocketBufferMax so
|
||||
// old large config values are ignored; only smaller custom values are used.
|
||||
c.udpRBuf = udpgwSocketBufferMax
|
||||
if cfg.UDPRBuf > 0 && cfg.UDPRBuf < udpgwSocketBufferMax {
|
||||
c.udpRBuf = cfg.UDPRBuf
|
||||
} else {
|
||||
c.udpRBuf = 8 * 1024 * 1024
|
||||
}
|
||||
if cfg.UDPWBuf > 0 {
|
||||
c.udpWBuf = udpgwSocketBufferMax
|
||||
if cfg.UDPWBuf > 0 && cfg.UDPWBuf < udpgwSocketBufferMax {
|
||||
c.udpWBuf = cfg.UDPWBuf
|
||||
} else {
|
||||
c.udpWBuf = 8 * 1024 * 1024
|
||||
}
|
||||
// Parse durations with fallback defaults.
|
||||
if cfg.MapTTL != "" {
|
||||
@@ -191,6 +224,13 @@ func startUDPGWInstance(cfg *UDPGWConfig) error {
|
||||
} else {
|
||||
c.maxMapEntries = 32768
|
||||
}
|
||||
// Total concurrent client cap. New clients past this are rejected so a
|
||||
// surge (e.g. well past normal load) cannot exhaust memory and crash.
|
||||
if cfg.MaxClients > 0 {
|
||||
c.maxClients = cfg.MaxClients
|
||||
} else {
|
||||
c.maxClients = udpgwDefaultMaxClients
|
||||
}
|
||||
// Start listening.
|
||||
ln, err := net.Listen("tcp", c.listen)
|
||||
if err != nil {
|
||||
@@ -198,12 +238,14 @@ func startUDPGWInstance(cfg *UDPGWConfig) error {
|
||||
return fmt.Errorf("udpgw: listen failed on %s: %w", c.listen, err)
|
||||
}
|
||||
|
||||
// Register as the active listener so stopUDPGW can close it.
|
||||
// Register as the active listener so stopUDPGW can close it, and publish
|
||||
// the current client cap so registerUDPGWClient can enforce it.
|
||||
udpgwMu.Lock()
|
||||
if udpgwLn != nil {
|
||||
_ = udpgwLn.Close()
|
||||
}
|
||||
udpgwLn = ln
|
||||
udpgwClientLimit = c.maxClients
|
||||
udpgwMu.Unlock()
|
||||
|
||||
if c.debug {
|
||||
@@ -329,6 +371,7 @@ type internalUDPGWConfig struct {
|
||||
idleTimeout time.Duration
|
||||
maxClientConns int
|
||||
maxMapEntries int
|
||||
maxClients int
|
||||
}
|
||||
|
||||
// udpDestKey identifies a destination IPv4:port for the UDP gateway. A
|
||||
@@ -365,7 +408,11 @@ func handleUDPGWClient(conn net.Conn, c *internalUDPGWConfig) {
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
br := bufio.NewReaderSize(conn, 256*1024)
|
||||
// 32 KiB is ample for reading length-prefixed UDPGW frames (which are
|
||||
// MTU-sized in practice). bufio serves reads larger than its buffer by
|
||||
// reading straight into the caller's slice, so max-frame reads still work.
|
||||
// The old 256 KiB buffer wasted ~224 KiB of heap per connected client.
|
||||
br := bufio.NewReaderSize(conn, 32*1024)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Bind a UDP socket for this client. Use cfg.udpBindIP if provided.
|
||||
|
||||
@@ -36,6 +36,10 @@ MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SOURCE_DIR=""
|
||||
RESTART_NEEDED=false
|
||||
BUILD_COMMIT=""
|
||||
BUILD_BRANCH=""
|
||||
BUILD_TIME=""
|
||||
BUILD_REPO_URL=""
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
|
||||
@@ -49,6 +53,38 @@ MOUNTPOINT_BIN="$(command -v mountpoint 2>/dev/null || echo /usr/bin/mountpoint)
|
||||
TOUCH_BIN="$(command -v touch 2>/dev/null || echo /usr/bin/touch)"
|
||||
CHMOD_BIN="$(command -v chmod 2>/dev/null || echo /usr/bin/chmod)"
|
||||
|
||||
trusted_go_sha256() {
|
||||
local manifest="${3:-}" manifest_value=""
|
||||
if [[ -n "${GO_SHA256:-}" ]]; then
|
||||
printf '%s\n' "$GO_SHA256"
|
||||
return 0
|
||||
fi
|
||||
if [[ -f "$manifest" ]]; then
|
||||
manifest_value="$(awk -v version="$1" -v arch="$2" '$1 == version && $2 == arch {print $3; exit}' "$manifest")"
|
||||
if [[ -n "$manifest_value" ]]; then
|
||||
printf '%s\n' "$manifest_value"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
case "$1:$2" in
|
||||
1.25.12:amd64) printf '%s\n' '234828b7a89e0e303d2556310ee549fbcf253d28de937bac3da13d6294262ac1' ;;
|
||||
1.25.12:arm64) printf '%s\n' '8b5884aef89600aef5b0b051fb971f11f49bb996521e911f30f02a66884f7bd2' ;;
|
||||
1.25.12:armv6l) printf '%s\n' '6cd7311c02c73ba0b482a1cf8c885268edf23519261bf4b5cef3353ad934d1f1' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
verify_sha256_file() {
|
||||
local expected="$1" file="$2" actual
|
||||
command -v sha256sum >/dev/null 2>&1 || error "sha256sum is required to verify downloaded binaries"
|
||||
[[ "$expected" =~ ^[0-9a-fA-F]{64}$ ]] || error "Invalid SHA-256 value for $file"
|
||||
actual="$(sha256sum "$file" | awk '{print $1}')"
|
||||
if [[ "${actual,,}" != "${expected,,}" ]]; then
|
||||
rm -f "$file"
|
||||
error "Checksum verification failed for $file"
|
||||
fi
|
||||
}
|
||||
|
||||
require_systemd() {
|
||||
SYSTEMCTL_BIN="$(command -v systemctl 2>/dev/null || true)"
|
||||
if [[ -z "$SYSTEMCTL_BIN" ]]; then
|
||||
@@ -245,7 +281,7 @@ prepare_source_from_git() {
|
||||
}
|
||||
|
||||
install_go_if_needed() {
|
||||
local go_version machine goarch go_url current_go need_go
|
||||
local go_version machine goarch go_url go_expected_sha256 current_go need_go
|
||||
go_version="$(awk '$1 == "go" {print $2; exit}' "$SOURCE_DIR/go.mod" 2>/dev/null || echo "1.22.5")"
|
||||
need_go=true
|
||||
|
||||
@@ -266,12 +302,16 @@ install_go_if_needed() {
|
||||
x86_64) goarch="amd64" ;;
|
||||
aarch64) goarch="arm64" ;;
|
||||
armv7l) goarch="armv6l" ;;
|
||||
*) goarch="amd64" ;;
|
||||
*) error "Unsupported CPU architecture: $machine" ;;
|
||||
esac
|
||||
go_expected_sha256="$(trusted_go_sha256 "$go_version" "$goarch" "$SOURCE_DIR/go-checksums.txt" || true)"
|
||||
[[ -n "$go_expected_sha256" ]] || error "No trusted Go checksum for ${go_version}/${goarch}; set GO_SHA256 explicitly"
|
||||
go_url="https://go.dev/dl/go${go_version}.linux-${goarch}.tar.gz"
|
||||
info " Downloading Go ${go_version} (${goarch})..."
|
||||
need_cmd wget
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$go_url"
|
||||
verify_sha256_file "$go_expected_sha256" /tmp/go.tar.gz
|
||||
info " Go archive checksum verified"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
@@ -288,9 +328,18 @@ build_binary() {
|
||||
cd "$SOURCE_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
|
||||
BUILD_COMMIT="$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)"
|
||||
BUILD_BRANCH="${UPDATE_REF:-$(git -C "$SOURCE_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)}"
|
||||
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
BUILD_REPO_URL="$REPO_URL"
|
||||
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
|
||||
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
|
||||
|
||||
go mod download
|
||||
go mod tidy
|
||||
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
|
||||
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o /tmp/sshpanel_new .
|
||||
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
|
||||
info " Build complete."
|
||||
}
|
||||
|
||||
@@ -332,6 +381,14 @@ apply_update() {
|
||||
chmod 755 "$INSTALL_DIR/sshpanel"
|
||||
info " Binary updated."
|
||||
|
||||
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
|
||||
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
|
||||
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
|
||||
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
|
||||
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
|
||||
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
|
||||
info " Build metadata updated."
|
||||
|
||||
rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
|
||||
info " Admin panel updated."
|
||||
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUpdateRepoURL = "https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git"
|
||||
defaultUpdateBranch = "main"
|
||||
updateStatusCacheTTL = 5 * time.Minute
|
||||
updateCheckTimeout = 12 * time.Second
|
||||
)
|
||||
|
||||
// These values are injected by install.sh/update.sh with -ldflags. The
|
||||
// runtime/debug fallback keeps the endpoint useful for normal git builds.
|
||||
var (
|
||||
buildCommit = ""
|
||||
buildBranch = ""
|
||||
buildTime = ""
|
||||
buildRepoURL = ""
|
||||
)
|
||||
|
||||
type updateStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
UpToDate bool `json:"up_to_date"`
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
LocalModified bool `json:"local_modified"`
|
||||
CurrentCommit string `json:"current_commit,omitempty"`
|
||||
CurrentCommitShort string `json:"current_commit_short,omitempty"`
|
||||
LatestCommit string `json:"latest_commit,omitempty"`
|
||||
LatestCommitShort string `json:"latest_commit_short,omitempty"`
|
||||
Branch string `json:"branch"`
|
||||
BuildTime string `json:"build_time,omitempty"`
|
||||
RepoURL string `json:"repo_url"`
|
||||
RepoWebURL string `json:"repo_web_url"`
|
||||
CheckedAt string `json:"checked_at"`
|
||||
Cached bool `json:"cached"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
var updateStatusCache struct {
|
||||
sync.Mutex
|
||||
checkedAt time.Time
|
||||
response updateStatusResponse
|
||||
}
|
||||
|
||||
func handleUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
|
||||
force := r.URL.Query().Get("refresh") == "1"
|
||||
resp := getUpdateStatus(r.Context(), force)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func getUpdateStatus(parent context.Context, force bool) updateStatusResponse {
|
||||
updateStatusCache.Lock()
|
||||
defer updateStatusCache.Unlock()
|
||||
|
||||
if !force && !updateStatusCache.checkedAt.IsZero() && time.Since(updateStatusCache.checkedAt) < updateStatusCacheTTL {
|
||||
resp := updateStatusCache.response
|
||||
resp.Cached = true
|
||||
return resp
|
||||
}
|
||||
|
||||
resp := checkRemoteUpdate(parent)
|
||||
updateStatusCache.checkedAt = time.Now()
|
||||
updateStatusCache.response = resp
|
||||
return resp
|
||||
}
|
||||
|
||||
func checkRemoteUpdate(parent context.Context) updateStatusResponse {
|
||||
repoURL := firstNonEmptyTrimmed(
|
||||
strings.TrimSpace(os.Getenv("DRAGON_UPDATE_REPO_URL")),
|
||||
readSingleLineFile("/opt/sshpanel/.installed_repo_url"),
|
||||
strings.TrimSpace(buildRepoURL),
|
||||
defaultUpdateRepoURL,
|
||||
)
|
||||
branch := firstNonEmptyTrimmed(
|
||||
strings.TrimSpace(os.Getenv("DRAGON_UPDATE_BRANCH")),
|
||||
strings.TrimSpace(buildBranch),
|
||||
readSingleLineFile("/opt/sshpanel/.installed_branch"),
|
||||
defaultUpdateBranch,
|
||||
)
|
||||
currentCommit, localModified, resolvedBuildTime := resolveCurrentBuildInfo()
|
||||
if resolvedBuildTime == "" {
|
||||
resolvedBuildTime = readSingleLineFile("/opt/sshpanel/.installed_build_time")
|
||||
}
|
||||
if currentCommit == "" {
|
||||
currentCommit = normalizeGitCommit(readSingleLineFile("/opt/sshpanel/.installed_commit"))
|
||||
}
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
resp := updateStatusResponse{
|
||||
Status: "unknown",
|
||||
CurrentCommit: currentCommit,
|
||||
CurrentCommitShort: shortCommit(currentCommit),
|
||||
Branch: branch,
|
||||
BuildTime: resolvedBuildTime,
|
||||
RepoURL: safeRepoURL(repoURL),
|
||||
RepoWebURL: repoWebURL(repoURL),
|
||||
CheckedAt: now,
|
||||
LocalModified: localModified,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parent, updateCheckTimeout)
|
||||
defer cancel()
|
||||
|
||||
latestCommit, err := queryRemoteCommit(ctx, repoURL, branch)
|
||||
if err != nil {
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
resp.Error = "remote update check timed out"
|
||||
} else {
|
||||
resp.Error = "could not read the remote Git branch"
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
resp.LatestCommit = latestCommit
|
||||
resp.LatestCommitShort = shortCommit(latestCommit)
|
||||
|
||||
if currentCommit == "" {
|
||||
resp.Error = "current build commit is unavailable"
|
||||
return resp
|
||||
}
|
||||
|
||||
resp.Status, resp.UpToDate, resp.UpdateAvailable = classifyUpdateStatus(currentCommit, latestCommit, localModified)
|
||||
return resp
|
||||
}
|
||||
|
||||
func classifyUpdateStatus(currentCommit, latestCommit string, localModified bool) (status string, upToDate bool, updateAvailable bool) {
|
||||
if currentCommit == "" || latestCommit == "" {
|
||||
return "unknown", false, false
|
||||
}
|
||||
if strings.EqualFold(currentCommit, latestCommit) {
|
||||
if localModified {
|
||||
return "local_changes", false, false
|
||||
}
|
||||
return "up_to_date", true, false
|
||||
}
|
||||
return "update_available", false, true
|
||||
}
|
||||
|
||||
func queryRemoteCommit(ctx context.Context, repoURL, branch string) (string, error) {
|
||||
cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", "--heads", repoURL, "refs/heads/"+branch)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_TERMINAL_PROMPT=0",
|
||||
"GIT_ASKPASS=/bin/false",
|
||||
)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
fields := strings.Fields(string(out))
|
||||
if len(fields) < 2 {
|
||||
return "", errors.New("invalid git ls-remote response")
|
||||
}
|
||||
commit := normalizeGitCommit(fields[0])
|
||||
if commit == "" {
|
||||
return "", errors.New("invalid remote commit")
|
||||
}
|
||||
return commit, nil
|
||||
}
|
||||
|
||||
func resolveCurrentBuildInfo() (commit string, modified bool, builtAt string) {
|
||||
commit = normalizeGitCommit(buildCommit)
|
||||
builtAt = strings.TrimSpace(buildTime)
|
||||
|
||||
info, ok := debug.ReadBuildInfo()
|
||||
if !ok {
|
||||
return commit, false, builtAt
|
||||
}
|
||||
for _, setting := range info.Settings {
|
||||
switch setting.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = normalizeGitCommit(setting.Value)
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = strings.EqualFold(setting.Value, "true")
|
||||
case "vcs.time":
|
||||
if builtAt == "" {
|
||||
builtAt = strings.TrimSpace(setting.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return commit, modified, builtAt
|
||||
}
|
||||
|
||||
func normalizeGitCommit(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if strings.EqualFold(value, "unknown") || len(value) < 7 || len(value) > 64 {
|
||||
return ""
|
||||
}
|
||||
for _, r := range value {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func shortCommit(commit string) string {
|
||||
if len(commit) <= 12 {
|
||||
return commit
|
||||
}
|
||||
return commit[:12]
|
||||
}
|
||||
|
||||
func readSingleLineFile(path string) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
line := strings.TrimSpace(string(data))
|
||||
if idx := strings.IndexByte(line, '\n'); idx >= 0 {
|
||||
line = strings.TrimSpace(line[:idx])
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func firstNonEmptyTrimmed(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func safeRepoURL(raw string) string {
|
||||
u, err := url.Parse(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return strings.TrimSpace(raw)
|
||||
}
|
||||
u.User = nil
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func repoWebURL(raw string) string {
|
||||
value := safeRepoURL(raw)
|
||||
return strings.TrimSuffix(value, ".git")
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeGitCommit(t *testing.T) {
|
||||
valid := "CF49340B9A1234567890ABCDEF1234567890ABCD"
|
||||
got := normalizeGitCommit(valid)
|
||||
want := "cf49340b9a1234567890abcdef1234567890abcd"
|
||||
if got != want {
|
||||
t.Fatalf("normalizeGitCommit() = %q, want %q", got, want)
|
||||
}
|
||||
for _, value := range []string{"", "unknown", "xyz1234", "123 4567", "123456"} {
|
||||
if got := normalizeGitCommit(value); got != "" {
|
||||
t.Fatalf("normalizeGitCommit(%q) = %q, want empty", value, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassifyUpdateStatus(t *testing.T) {
|
||||
const current = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
const latest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
|
||||
status, upToDate, updateAvailable := classifyUpdateStatus(current, current, false)
|
||||
if status != "up_to_date" || !upToDate || updateAvailable {
|
||||
t.Fatalf("same clean commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
|
||||
status, upToDate, updateAvailable = classifyUpdateStatus(current, current, true)
|
||||
if status != "local_changes" || upToDate || updateAvailable {
|
||||
t.Fatalf("modified commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
|
||||
status, upToDate, updateAvailable = classifyUpdateStatus(current, latest, false)
|
||||
if status != "update_available" || upToDate || !updateAvailable {
|
||||
t.Fatalf("different commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoWebURLRemovesCredentialsAndGitSuffix(t *testing.T) {
|
||||
got := repoWebURL("https://user:secret@git.example.test/owner/repo.git")
|
||||
want := "https://git.example.test/owner/repo"
|
||||
if got != want {
|
||||
t.Fatalf("repoWebURL() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryRemoteCommit(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell-script git stub is Unix-specific")
|
||||
}
|
||||
const want = "cf49340b9a1234567890abcdef1234567890abcd"
|
||||
dir := t.TempDir()
|
||||
gitPath := filepath.Join(dir, "git")
|
||||
script := "#!/bin/sh\nprintf '%s\\trefs/heads/main\\n' '" + want + "'\n"
|
||||
if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
got, err := queryRemoteCommit(context.Background(), "https://git.example.test/owner/repo.git", "main")
|
||||
if err != nil {
|
||||
t.Fatalf("queryRemoteCommit() error = %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("queryRemoteCommit() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+167
-10
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -46,6 +48,10 @@ func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS total_downlink_bytes BIGINT NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS last_active TIMESTAMPTZ`,
|
||||
`ALTER TABLE xray_clients ADD COLUMN IF NOT EXISTS active_connections INT NOT NULL DEFAULT 0`,
|
||||
// Keep legacy reseller-owned Xray accounts aligned with weighted quota
|
||||
// accounting. A reseller account always consumes at least one slot.
|
||||
`UPDATE xray_clients SET max_conns = 1
|
||||
WHERE owner_username <> '' AND max_conns < 1`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
@@ -231,8 +237,41 @@ func countOwnedXrayClients(ctx context.Context, store *Store, ownerUsername stri
|
||||
return n
|
||||
}
|
||||
|
||||
func (s *Store) SumXrayClientQuotaByOwner(ctx context.Context, ownerUsername string) (int, error) {
|
||||
if s == nil || ownerUsername == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var total int
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT COALESCE(SUM(GREATEST(max_conns, 1)), 0)
|
||||
FROM xray_clients WHERE owner_username=$1`, ownerUsername).Scan(&total)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func countOwnedSSHQuota(ownerUsername string) int {
|
||||
total := 0
|
||||
for _, user := range userMgr.List() {
|
||||
if user.Cfg.OwnerUsername == ownerUsername {
|
||||
total += resellerProvisionCost(user.Cfg.MaxConnections)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedXrayQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return 0
|
||||
}
|
||||
total, err := store.SumXrayClientQuotaByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
log.Printf("sum Xray quota for %s: %v", ownerUsername, err)
|
||||
return 0
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countOwnedQuota(ctx context.Context, store *Store, ownerUsername string) int {
|
||||
return countOwnedUsers(ownerUsername) + countOwnedXrayClients(ctx, store, ownerUsername)
|
||||
return countOwnedSSHQuota(ownerUsername) + countOwnedXrayQuota(ctx, store, ownerUsername)
|
||||
}
|
||||
|
||||
func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) {
|
||||
@@ -262,8 +301,111 @@ func removeOwnerXrayClients(ctx context.Context, store *Store, ownerUsername str
|
||||
}
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker runs a background goroutine that removes expired
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
// suspendOwnerXrayClients removes an owner's clients from the live Xray config
|
||||
// while keeping their metadata. That makes reseller suspension reversible.
|
||||
func suspendOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.InboundTag == "" || !present[client.InboundTag][client.UUID] {
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(client.InboundTag, client.UUID); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("suspend Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restoreOwnerXrayClients restores metadata-backed clients after a reseller is
|
||||
// reactivated. Existing entries are left untouched, so retries are idempotent.
|
||||
func restoreOwnerXrayClients(ctx context.Context, store *Store, ownerUsername string) error {
|
||||
if store == nil || ownerUsername == "" {
|
||||
return nil
|
||||
}
|
||||
clients, err := store.ListXrayClientsByOwner(ctx, ownerUsername)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
present := make(map[string]map[string]bool)
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
var failures []string
|
||||
for _, client := range clients {
|
||||
if client.ExpiresAt != nil && time.Now().After(*client.ExpiresAt) {
|
||||
continue
|
||||
}
|
||||
if client.InboundTag == "" {
|
||||
continue
|
||||
}
|
||||
clientsForInbound, ok := present[client.InboundTag]
|
||||
if !ok {
|
||||
failures = append(failures, client.UUID+": inbound "+client.InboundTag+" no longer exists")
|
||||
continue
|
||||
}
|
||||
if clientsForInbound[client.UUID] {
|
||||
continue
|
||||
}
|
||||
email := strings.TrimSpace(client.Email)
|
||||
if email == "" {
|
||||
email = strings.TrimSpace(client.Name)
|
||||
}
|
||||
if email == "" {
|
||||
email = client.UUID
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(client.InboundTag, client.UUID, email); err != nil {
|
||||
failures = append(failures, client.UUID+": "+err.Error())
|
||||
continue
|
||||
}
|
||||
clientsForInbound[client.UUID] = true
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
}
|
||||
if len(failures) > 0 {
|
||||
return fmt.Errorf("restore Xray clients: %s", strings.Join(failures, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker removes expired clients from the live config.
|
||||
// Reseller-owned metadata is retained so a paid renewal can restore access.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
@@ -282,19 +424,34 @@ func startXrayClientExpiryChecker(store *Store) {
|
||||
continue
|
||||
}
|
||||
needRestart := false
|
||||
present := make(map[string]map[string]bool)
|
||||
if inbounds, listErr := xrayMgr.ListInbounds(); listErr == nil {
|
||||
for _, inbound := range inbounds {
|
||||
present[inbound.Tag] = make(map[string]bool)
|
||||
for _, client := range inbound.Clients {
|
||||
present[inbound.Tag][client.UUID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
if m.OwnerUsername == "" {
|
||||
_ = store.DeleteXrayClientMeta(ctx, m.UUID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
if present[tag][m.UUID] {
|
||||
if err := xrayMgr.RemoveXrayClient(tag, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: remove %s from %s: %v", m.UUID, tag, err)
|
||||
} else {
|
||||
needRestart = true
|
||||
}
|
||||
}
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
if m.OwnerUsername == "" {
|
||||
if err := store.DeleteXrayClientMeta(ctx, m.UUID); err != nil {
|
||||
log.Printf("xray expiry: delete meta %s: %v", m.UUID, err)
|
||||
}
|
||||
}
|
||||
log.Printf("xray expiry: removed expired client %q (%s) from inbound %s", m.Name, m.UUID, tag)
|
||||
}
|
||||
|
||||
+231
-50
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -564,10 +565,32 @@ func (m *XrayManager) startNativeStatsFlusher() {
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
m.flushNativeStatsToDB()
|
||||
m.pruneStaleRuntimeStats()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// pruneStaleRuntimeStats drops runtime stat entries for clients that have no
|
||||
// active connections and have been idle well past the online window. Without
|
||||
// this, statsByEmail keeps one entry per email/UUID that has ever connected and
|
||||
// never shrinks. The retention comfortably exceeds the online-detection grace
|
||||
// window so CountOnlineUsers is unaffected; persistent traffic totals live in
|
||||
// the DB, so dropping the in-memory counter for a long-offline client is safe.
|
||||
func (m *XrayManager) pruneStaleRuntimeStats() {
|
||||
retention := 2 * m.onlineWindow()
|
||||
if retention < 5*time.Minute {
|
||||
retention = 5 * time.Minute
|
||||
}
|
||||
cutoff := time.Now().Add(-retention)
|
||||
m.statsMu.Lock()
|
||||
for email, st := range m.statsByEmail {
|
||||
if st.ActiveConnections <= 0 && (st.LastActive.IsZero() || st.LastActive.Before(cutoff)) {
|
||||
delete(m.statsByEmail, email)
|
||||
}
|
||||
}
|
||||
m.statsMu.Unlock()
|
||||
}
|
||||
|
||||
func (m *XrayManager) flushNativeStatsToDB() {
|
||||
if statsStore == nil {
|
||||
return
|
||||
@@ -1081,7 +1104,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = os.WriteFile(configFile, pretty, 0o600)
|
||||
_ = writeFileAtomic(configFile, pretty, 0o600)
|
||||
return pretty, nil
|
||||
}
|
||||
}
|
||||
@@ -1093,7 +1116,7 @@ func (m *XrayManager) readConfigLocked() ([]byte, error) {
|
||||
return nil, err
|
||||
} else if changed {
|
||||
data = forced
|
||||
_ = os.WriteFile(configFile, forced, 0o600)
|
||||
_ = writeFileAtomic(configFile, forced, 0o600)
|
||||
xrayLogf("xray: forced debug log output in file config")
|
||||
}
|
||||
return data, nil
|
||||
@@ -1119,7 +1142,7 @@ func (m *XrayManager) writeConfigLocked(data []byte) error {
|
||||
return fmt.Errorf("save Xray config to database: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(configFile, pretty, 0o600); err != nil {
|
||||
if err := writeFileAtomic(configFile, pretty, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "saved xray config")
|
||||
@@ -1204,7 +1227,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "database xray config")
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configFile)
|
||||
@@ -1230,7 +1253,7 @@ func (m *XrayManager) bootstrapConfigStoreLocked() error {
|
||||
if err := statsStore.UpsertXrayConfig(ctx, key, pretty); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
@@ -1297,7 +1320,7 @@ func (m *XrayManager) syncConfigFileFromStoreLocked() error {
|
||||
return err
|
||||
}
|
||||
m.importConfigClientsLocked(pretty, "database xray config")
|
||||
return os.WriteFile(configFile, pretty, 0o600)
|
||||
return writeFileAtomic(configFile, pretty, 0o600)
|
||||
}
|
||||
|
||||
// GetConfig reads the current Xray JSON config. With PostgreSQL enabled, the
|
||||
@@ -1319,6 +1342,11 @@ func (m *XrayManager) SetConfig(data []byte) error {
|
||||
if changed {
|
||||
xrayLogf("xray: added/repaired Stats API support and forced debug logs while saving config")
|
||||
}
|
||||
if m.cfg != nil && m.cfg.UseNative() {
|
||||
if err := validateNativeInboundBindings(patched); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return m.writeConfigLocked(patched)
|
||||
}
|
||||
|
||||
@@ -1856,7 +1884,7 @@ func handleXrayStart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "start Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1871,7 +1899,7 @@ func handleXrayStop(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "stop Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1886,7 +1914,7 @@ func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1911,7 +1939,7 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
case http.MethodGet:
|
||||
data, err := xrayMgr.GetConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "read Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
@@ -1923,8 +1951,19 @@ func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if !json.Valid(body) || json.Unmarshal(body, &raw) != nil || raw == nil {
|
||||
http.Error(w, "invalid Xray JSON configuration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if xrayMgr.useNativeMode() {
|
||||
if err := validateNativeInboundBindings(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.SetConfig(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeInternalError(w, "save Xray configuration", err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1945,13 +1984,13 @@ func handleXrayRepairStats(w http.ResponseWriter, r *http.Request) {
|
||||
wasRunning := xrayMgr.isRunningSnapshot()
|
||||
changed, err := xrayMgr.EnsureStatsAPIConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeInternalError(w, "repair Xray statistics configuration", err)
|
||||
return
|
||||
}
|
||||
restarted := false
|
||||
if wasRunning {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, "config repaired but restart failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "restart Xray after repairing statistics", err)
|
||||
return
|
||||
}
|
||||
restarted = true
|
||||
@@ -2266,7 +2305,7 @@ func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "list Xray inbounds", err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2419,32 +2458,88 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername string `json:"owner_username,omitempty"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.InboundTag == "" || req.UUID == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
req.InboundTag = strings.TrimSpace(req.InboundTag)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
req.OwnerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
if err := validateXrayClientFields(req.UUID, req.InboundTag, req.Email, req.Name, req.ExpiresAt, req.MaxConnections, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.OwnerUsername != "" {
|
||||
if err := validateAdminUsername(req.OwnerUsername); err != nil {
|
||||
http.Error(w, "invalid owner username", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if !ms.EnableXray {
|
||||
http.Error(w, "Xray creation is disabled for this server", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
chargedCredits, creditCost, creditOwner := false, 0, ""
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
_, exists, ownerErr := remoteXrayClientOwner(r.Context(), ms, req.UUID)
|
||||
if ownerErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if exists {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
chargedCredits, creditCost, ownerErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if ownerErr != nil {
|
||||
writeResellerProvisionError(w, ownerErr)
|
||||
return
|
||||
}
|
||||
creditOwner = sess.Username
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
req.OwnerUsername = sess.Username
|
||||
}
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
if syncErr := syncOwnerChainToManagedServer(r.Context(), ms, sess.Username); syncErr != nil {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
log.Printf("sync reseller %s to managed server %s: %v", sess.Username, ms.Name, syncErr)
|
||||
http.Error(w, "could not synchronize reseller state with the remote server", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/add", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
writeBadGatewayError(w, "create Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
if status < 200 || status >= 300 {
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, creditOwner, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
return
|
||||
}
|
||||
@@ -2458,21 +2553,17 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
ownerUsername := ""
|
||||
chargedCredits, creditCost := false, 0
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
ownerUsername = sess.Username
|
||||
if statsStore == nil {
|
||||
http.Error(w, "storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
owner, ok := adminUsers.get(sess.Username)
|
||||
if !ok || !owner.IsActive || (owner.ExpiresAt != nil && time.Now().After(*owner.ExpiresAt)) {
|
||||
if err := adminAccountChainActive(sess.Username); err != nil {
|
||||
http.Error(w, "reseller account suspended or expired", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if owner.MaxUsers > 0 && countOwnedQuota(r.Context(), statsStore, sess.Username) >= owner.MaxUsers {
|
||||
http.Error(w, fmt.Sprintf("user limit reached (%d)", owner.MaxUsers), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
} else if sess != nil && sess.Role == RoleSuperAdmin && strings.TrimSpace(req.OwnerUsername) != "" {
|
||||
ownerUsername = strings.TrimSpace(req.OwnerUsername)
|
||||
}
|
||||
@@ -2482,12 +2573,35 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "UUID already exists in database", http.StatusBadRequest)
|
||||
return
|
||||
} else if err != sql.ErrNoRows {
|
||||
http.Error(w, "database error: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "check Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
var quotaErr error
|
||||
chargedCredits, creditCost, quotaErr = authorizeResellerProvision(r.Context(), statsStore, sess.Username, "xray:"+req.UUID, req.MaxConnections)
|
||||
if quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
if expiry := resellerProvisionExpiry(sess.Username); expiry != "" {
|
||||
req.ExpiresAt = expiry
|
||||
}
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "already exists") {
|
||||
http.Error(w, "UUID already exists", http.StatusConflict)
|
||||
} else if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "add Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
@@ -2513,7 +2627,12 @@ func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
xrayLogf("xray: save meta for %s: %v", req.UUID, err)
|
||||
_ = xrayMgr.RemoveXrayClient(req.InboundTag, req.UUID)
|
||||
if chargedCredits {
|
||||
refundResellerProvisionCredits(r.Context(), statsStore, ownerUsername, creditCost, "xray:"+req.UUID)
|
||||
}
|
||||
http.Error(w, "could not save Xray client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
xrayMgr.restartIfExternalRunning()
|
||||
@@ -2528,34 +2647,70 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
UUID string `json:"uuid"`
|
||||
Name string `json:"name"`
|
||||
Email string `json:"email"`
|
||||
ExpiresAt string `json:"expires_at"`
|
||||
MaxConnections int `json:"max_connections"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
PreserveExpires bool `json:"preserve_expires,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UUID == "" {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
req.UUID = strings.TrimSpace(req.UUID)
|
||||
req.Email = strings.TrimSpace(req.Email)
|
||||
req.Name = strings.TrimSpace(req.Name)
|
||||
if err := validateXrayClientFields(req.UUID, "", req.Email, req.Name, req.ExpiresAt, req.MaxConnections, false); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.ServerID) > 32 || hasAccountControlCharacters(req.ServerID) {
|
||||
http.Error(w, "invalid server id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, req.ServerID); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, req.UUID, sess.Username) {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
row, exists, infoErr := remoteXrayClientInfo(r.Context(), ms, req.UUID)
|
||||
if infoErr != nil {
|
||||
http.Error(w, "could not verify remote ownership", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
http.Error(w, "Xray account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(fmt.Sprint(row["owner_username"])) != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
oldMaxConnections := jsonInt(row["max_conns"])
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = oldMaxConnections
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, oldMaxConnections, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
req.ServerID = ""
|
||||
body, _ := json.Marshal(req)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodPost, "/api/xray/clients/update", body, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "update Xray account on managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2566,16 +2721,34 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
quotaUnlock := lockResellerQuota(sess.Username)
|
||||
defer quotaUnlock()
|
||||
}
|
||||
existing, err := statsStore.GetXrayClientMeta(r.Context(), req.UUID)
|
||||
if err != nil {
|
||||
http.Error(w, "client metadata not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
sess := sessionFromCtx(r.Context())
|
||||
if sess != nil && sess.Role == RoleReseller && existing.OwnerUsername != sess.Username {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if sess != nil && sess.Role == RoleReseller {
|
||||
if owner, ok := adminUsers.get(sess.Username); ok && normalizeQuotaMode(owner.QuotaMode) == QuotaModeCredit {
|
||||
if strings.TrimSpace(req.ExpiresAt) != "" && resellerTimeExtended(existing.ExpiresAt, req.ExpiresAt) {
|
||||
http.Error(w, "use the renew action to extend a credit account", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
req.PreserveExpires = true
|
||||
req.MaxConnections = existing.MaxConns
|
||||
}
|
||||
if quotaErr := authorizeResellerQuotaChange(r.Context(), statsStore, sess.Username, existing.MaxConns, req.MaxConnections); quotaErr != nil {
|
||||
writeResellerProvisionError(w, quotaErr)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
@@ -2585,6 +2758,9 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
OwnerUsername: existing.OwnerUsername,
|
||||
MaxConns: req.MaxConnections,
|
||||
}
|
||||
if req.PreserveExpires {
|
||||
meta.ExpiresAt = existing.ExpiresAt
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
if t, err := time.Parse(layout, req.ExpiresAt); err == nil {
|
||||
@@ -2594,7 +2770,7 @@ func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
writeInternalError(w, "update Xray client metadata", err)
|
||||
return
|
||||
}
|
||||
if req.Email != "" {
|
||||
@@ -2613,13 +2789,13 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
inboundTag := r.URL.Query().Get("inbound_tag")
|
||||
uuid := r.URL.Query().Get("uuid")
|
||||
if inboundTag == "" || uuid == "" {
|
||||
http.Error(w, "inbound_tag and uuid required", http.StatusBadRequest)
|
||||
uuid := strings.TrimSpace(r.URL.Query().Get("uuid"))
|
||||
if err := validateXrayClientFields(uuid, inboundTag, "", "", "", 0, true); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if ms, remote, err := managedServerFromID(r.Context(), statsStore, requestedServerID(r)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
writeManagedServerSelectionError(w, err)
|
||||
return
|
||||
} else if remote {
|
||||
if sess := sessionFromCtx(r.Context()); sess != nil && sess.Role == RoleReseller && !remoteXrayClientOwned(r.Context(), ms, uuid, sess.Username) {
|
||||
@@ -2629,7 +2805,7 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
remotePath := "/api/xray/clients/remove?inbound_tag=" + url.QueryEscape(inboundTag) + "&uuid=" + url.QueryEscape(uuid)
|
||||
status, data, ct, err := proxyManagedServer(r.Context(), ms, http.MethodDelete, remotePath, nil, "application/json")
|
||||
if err != nil {
|
||||
http.Error(w, "remote server error: "+err.Error(), http.StatusBadGateway)
|
||||
writeBadGatewayError(w, "delete Xray account from managed server", err)
|
||||
return
|
||||
}
|
||||
writeProxyResponse(w, status, data, ct)
|
||||
@@ -2653,7 +2829,12 @@ func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
lowerErr := strings.ToLower(err.Error())
|
||||
if strings.Contains(lowerErr, "inbound") && strings.Contains(lowerErr, "not found") {
|
||||
http.Error(w, "inbound not found", http.StatusBadRequest)
|
||||
} else {
|
||||
writeInternalError(w, "remove Xray client", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
|
||||
+144
-16
@@ -62,7 +62,7 @@ type nativeXrayClient struct {
|
||||
// nativeInbound is a single listener built from one JSON inbound entry.
|
||||
type nativeInbound struct {
|
||||
tag string
|
||||
protocol string // "vless" | "vmess"
|
||||
protocol string // "vless" | "vmess" | "ssh" (XHTTP->SSH tunnel)
|
||||
listen string // bind host, default 0.0.0.0
|
||||
port int
|
||||
transport string // "tcp" | "ws" | "xhttp" | ...
|
||||
@@ -110,6 +110,18 @@ type nativeXrayServer struct {
|
||||
|
||||
var nativeXray = &nativeXrayServer{}
|
||||
|
||||
// nativeXHTTPListener groups all XHTTP inbounds that bind the same address.
|
||||
// XHTTP is ordinary HTTP at the transport layer, so routing by the configured
|
||||
// path lets VLESS/VMess and the DragonCore SSH tunnel safely share one TLS port.
|
||||
// The most specific path wins: for example /ssh/ is checked before /.
|
||||
type nativeXHTTPListener struct {
|
||||
addr string
|
||||
inbounds []*nativeInbound
|
||||
tlsConfig *tls.Config
|
||||
security string
|
||||
headerSize int
|
||||
}
|
||||
|
||||
// nativeRunning reports whether the in-process Xray listeners are up.
|
||||
func (s *nativeXrayServer) nativeRunning() bool {
|
||||
s.mu.Lock()
|
||||
@@ -140,7 +152,18 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
|
||||
var opened []net.Listener
|
||||
active := make(map[string]*nativeInbound, len(inbounds))
|
||||
xhttpGroups := make(map[string][]*nativeInbound)
|
||||
var xhttpOrder []string
|
||||
for _, ib := range inbounds {
|
||||
if ib.isXHTTP() {
|
||||
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
|
||||
if _, exists := xhttpGroups[addr]; !exists {
|
||||
xhttpOrder = append(xhttpOrder, addr)
|
||||
}
|
||||
xhttpGroups[addr] = append(xhttpGroups[addr], ib)
|
||||
active[ib.tag] = ib
|
||||
continue
|
||||
}
|
||||
addr := net.JoinHostPort(ib.listen, strconv.Itoa(ib.port))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
@@ -151,25 +174,40 @@ func (s *nativeXrayServer) start(configFile string) error {
|
||||
return fmt.Errorf("native xray: listen %s (inbound %q): %w", addr, ib.tag, err)
|
||||
}
|
||||
|
||||
serveLn := ln
|
||||
if ib.isXHTTP() {
|
||||
// HTTP/XHTTP needs a real http.Server because one logical XHTTP
|
||||
// session can span several HTTP requests/connections. TLS is therefore
|
||||
// wrapped at listener level instead of inside serve().
|
||||
if ib.security == "tls" {
|
||||
serveLn = tls.NewListener(ln, ib.tlsConfig)
|
||||
}
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray xhttp listener %s", addr), func() { ib.serveXHTTPListener(serveLn) })
|
||||
} else {
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(serveLn) })
|
||||
}
|
||||
opened = append(opened, ln)
|
||||
xrayGo(fmt.Sprintf("native xray accept loop %s", addr), func() { ib.acceptLoop(ln) })
|
||||
active[ib.tag] = ib
|
||||
xrayLogf("native xray: serving %s/%s on %s (inbound %q, security=%s, %d clients)",
|
||||
ib.protocol, ib.transport, addr, ib.tag, orNone(ib.security), ib.clientCount())
|
||||
}
|
||||
|
||||
for _, addr := range xhttpOrder {
|
||||
group, err := newNativeXHTTPListener(addr, xhttpGroups[addr])
|
||||
if err != nil {
|
||||
for _, l := range opened {
|
||||
_ = l.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
for _, l := range opened {
|
||||
_ = l.Close()
|
||||
}
|
||||
return fmt.Errorf("native xray: listen %s (shared XHTTP): %w", addr, err)
|
||||
}
|
||||
serveLn := net.Listener(ln)
|
||||
if group.security == "tls" {
|
||||
serveLn = tls.NewListener(ln, group.tlsConfig)
|
||||
}
|
||||
opened = append(opened, serveLn)
|
||||
xrayGo(fmt.Sprintf("native xray shared xhttp listener %s", addr), func() { group.serve(serveLn) })
|
||||
for _, ib := range group.inbounds {
|
||||
xrayLogf("native xray: serving %s/%s on %s%s (inbound %q, security=%s, %d clients)",
|
||||
ib.protocol, ib.transport, addr, ib.path, ib.tag, orNone(ib.security), ib.clientCount())
|
||||
}
|
||||
}
|
||||
|
||||
s.listeners = opened
|
||||
s.inboundsByTag = active
|
||||
s.running = true
|
||||
@@ -941,6 +979,79 @@ type nativeInboundJSON struct {
|
||||
} `json:"streamSettings"`
|
||||
}
|
||||
|
||||
// validateNativeInboundBindings checks the listener topology before a visual or
|
||||
// raw-JSON update replaces the running native config. It intentionally mirrors
|
||||
// the startup rules so an invalid shared-port edit is rejected before the last
|
||||
// working file is overwritten.
|
||||
func validateNativeInboundBindings(data []byte) error {
|
||||
var cf nativeXrayConfigFile
|
||||
if err := json.Unmarshal(data, &cf); err != nil {
|
||||
return fmt.Errorf("native xray: parse config: %w", err)
|
||||
}
|
||||
type binding struct {
|
||||
tag string
|
||||
xhttp bool
|
||||
path string
|
||||
security string
|
||||
certFile string
|
||||
keyFile string
|
||||
}
|
||||
groups := make(map[string][]binding)
|
||||
for _, in := range cf.Inbounds {
|
||||
proto := strings.ToLower(strings.TrimSpace(in.Protocol))
|
||||
if proto != "ssh" && !xrayClientProtos[proto] {
|
||||
continue
|
||||
}
|
||||
port, ok := parseSinglePort(in.Port)
|
||||
if !ok {
|
||||
return fmt.Errorf("native xray: inbound %q has an unsupported port", in.Tag)
|
||||
}
|
||||
network := strings.ToLower(firstNonEmpty(in.StreamSettings.Network, "tcp"))
|
||||
isXHTTP := network == "xhttp" || network == "splithttp"
|
||||
if proto == "ssh" && !isXHTTP {
|
||||
return fmt.Errorf("native xray: inbound %q protocol ssh requires XHTTP", in.Tag)
|
||||
}
|
||||
item := binding{tag: in.Tag, xhttp: isXHTTP, security: strings.ToLower(strings.TrimSpace(in.StreamSettings.Security))}
|
||||
if item.security == "none" {
|
||||
item.security = ""
|
||||
}
|
||||
if isXHTTP {
|
||||
xh := mergeNativeXHTTPSettings(in.StreamSettings.XHTTPSettings, in.StreamSettings.SplitHTTPSettings)
|
||||
item.path = normalizeXHTTPPath(firstNonEmpty(xh.Path, "/xhttp"))
|
||||
}
|
||||
if len(in.StreamSettings.TLSSettings.Certificates) > 0 {
|
||||
item.certFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].CertificateFile)
|
||||
item.keyFile = strings.TrimSpace(in.StreamSettings.TLSSettings.Certificates[0].KeyFile)
|
||||
}
|
||||
host := normalizeNativeListenHost(firstNonEmpty(in.Listen, "0.0.0.0"))
|
||||
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||
groups[addr] = append(groups[addr], item)
|
||||
}
|
||||
for addr, items := range groups {
|
||||
if len(items) < 2 {
|
||||
continue
|
||||
}
|
||||
paths := make(map[string]string, len(items))
|
||||
first := items[0]
|
||||
for _, item := range items {
|
||||
if !item.xhttp {
|
||||
return fmt.Errorf("native xray: multiple inbounds on %s require XHTTP path routing", addr)
|
||||
}
|
||||
if previous, exists := paths[item.path]; exists {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, item.tag, item.path, addr)
|
||||
}
|
||||
paths[item.path] = item.tag
|
||||
if item.security != first.security {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
|
||||
}
|
||||
if item.security == "tls" && (item.certFile != first.certFile || item.keyFile != first.keyFile) {
|
||||
return fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseNativeInbounds reads the Xray config file and returns one nativeInbound
|
||||
// per servable client-bearing inbound. Unsupported inbounds (api dokodemo-door,
|
||||
// freedom, etc.) are silently skipped.
|
||||
@@ -959,7 +1070,11 @@ func parseNativeInbounds(configFile string) ([]*nativeInbound, error) {
|
||||
var out []*nativeInbound
|
||||
for _, in := range cf.Inbounds {
|
||||
proto := strings.ToLower(strings.TrimSpace(in.Protocol))
|
||||
if !xrayClientProtos[proto] {
|
||||
// "ssh" is a DragonCore extension: an XHTTP inbound whose decoded byte
|
||||
// stream is handed to the SSH server (handleConn) instead of a proxy
|
||||
// protocol. It carries no proxy clients (auth is the SSH account), so it
|
||||
// intentionally bypasses the client-bearing protocol gate below.
|
||||
if proto != "ssh" && !xrayClientProtos[proto] {
|
||||
continue // only vless/vmess/trojan carry clients; skip api/freedom/etc.
|
||||
}
|
||||
port, ok := parseSinglePort(in.Port)
|
||||
@@ -1021,6 +1136,19 @@ func parseNativeInbounds(configFile string) ([]*nativeInbound, error) {
|
||||
ib.tlsConfig = tc
|
||||
}
|
||||
|
||||
// XHTTP->SSH inbounds have no proxy clients: the SSH handshake performed by
|
||||
// handleConn is the authentication step. Skip proxy-client loading and the
|
||||
// clientCount()==0 gate below, but enforce that "ssh" is only valid on the
|
||||
// XHTTP transport (that is the only path that reaches dispatchXHTTPConn).
|
||||
if ib.protocol == "ssh" {
|
||||
if !ib.isXHTTP() {
|
||||
xrayLogf("native xray: inbound %q protocol \"ssh\" requires xhttp transport; skipping", in.Tag)
|
||||
continue
|
||||
}
|
||||
out = append(out, ib)
|
||||
continue
|
||||
}
|
||||
|
||||
configClients := in.Settings.Clients
|
||||
if len(in.Settings.Users) > 0 {
|
||||
configClients = append(configClients, in.Settings.Users...)
|
||||
|
||||
+75
-13
@@ -10,12 +10,62 @@ import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSharedXHTTPListenerRoutesMostSpecificPath(t *testing.T) {
|
||||
root := &nativeInbound{tag: "shared-proxy-xhttp", protocol: "vless", transport: "xhttp", path: "/", xhttpHost: "proxy.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
|
||||
ssh := &nativeInbound{tag: "shared-ssh-xhttp", protocol: "ssh", transport: "xhttp", path: "/ssh", xhttpHost: "ssh.example", xhttpSessions: make(map[string]*nativeXHTTPSession)}
|
||||
group, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{root, ssh})
|
||||
if err != nil {
|
||||
t.Fatalf("new shared XHTTP listener: %v", err)
|
||||
}
|
||||
|
||||
sshReq := httptest.NewRequest(http.MethodOptions, "http://ssh.example/ssh/session", nil)
|
||||
sshRec := httptest.NewRecorder()
|
||||
group.ServeHTTP(sshRec, sshReq)
|
||||
if sshRec.Code != http.StatusOK {
|
||||
t.Fatalf("/ssh routed to root instead of SSH inbound: status=%d", sshRec.Code)
|
||||
}
|
||||
|
||||
rootReq := httptest.NewRequest(http.MethodOptions, "http://proxy.example/session", nil)
|
||||
rootRec := httptest.NewRecorder()
|
||||
group.ServeHTTP(rootRec, rootReq)
|
||||
if rootRec.Code != http.StatusOK {
|
||||
t.Fatalf("/ routed incorrectly: status=%d", rootRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedXHTTPListenerRejectsDuplicatePath(t *testing.T) {
|
||||
a := &nativeInbound{tag: "a", transport: "xhttp", path: "/same"}
|
||||
b := &nativeInbound{tag: "b", transport: "xhttp", path: "/same/"}
|
||||
if _, err := newNativeXHTTPListener("127.0.0.1:443", []*nativeInbound{a, b}); err == nil {
|
||||
t.Fatal("expected duplicate normalized XHTTP path to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNativeSharedXHTTPConfig(t *testing.T) {
|
||||
valid := []byte(`{"inbounds":[
|
||||
{"tag":"legacy-proxy","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/legacy"}}},
|
||||
{"tag":"ssh","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/ssh"}}}
|
||||
]}`)
|
||||
if err := validateNativeInboundBindings(valid); err != nil {
|
||||
t.Fatalf("valid shared endpoint rejected: %v", err)
|
||||
}
|
||||
|
||||
duplicate := []byte(`{"inbounds":[
|
||||
{"tag":"a","listen":"0.0.0.0","port":443,"protocol":"vless","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same"}}},
|
||||
{"tag":"b","listen":"0.0.0.0","port":443,"protocol":"ssh","streamSettings":{"network":"xhttp","xhttpSettings":{"path":"/same/"}}}
|
||||
]}`)
|
||||
if err := validateNativeInboundBindings(duplicate); err == nil {
|
||||
t.Fatal("duplicate normalized XHTTP path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// startEchoServer starts a TCP server that echoes everything back and returns
|
||||
// its port and a cleanup func.
|
||||
func startEchoServer(t *testing.T) (int, func()) {
|
||||
@@ -1068,18 +1118,30 @@ func TestVLESSMuxSlowDialDoesNotBlockOtherSessions(t *testing.T) {
|
||||
t.Fatalf("write fast mux frame: %v", err)
|
||||
}
|
||||
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
if meta.sessionID != 2 || meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
got, err := readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session payload: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
for {
|
||||
meta, err := readNativeMuxMetadata(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read fast session response meta (head-of-line stall?): %v", err)
|
||||
}
|
||||
var got []byte
|
||||
if meta.option&nativeMuxOptionData != 0 {
|
||||
got, err = readNativeMuxDataBlock(conn)
|
||||
if err != nil {
|
||||
t.Fatalf("read mux response payload: %v", err)
|
||||
}
|
||||
}
|
||||
// Some networks reject TEST-NET immediately instead of letting the dial
|
||||
// time out. Ignore that session-1 error frame and keep waiting for the
|
||||
// independent fast session.
|
||||
if meta.sessionID != 2 {
|
||||
continue
|
||||
}
|
||||
if meta.status != nativeMuxStatusKeep || meta.option&nativeMuxOptionData == 0 {
|
||||
t.Fatalf("expected session 2 keep-data frame, got: %#v", meta)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("fast session echo mismatch: got %q want %q", got, want)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@ const (
|
||||
|
||||
defaultNativeXHTTPMaxSessions = 16384
|
||||
defaultNativeXHTTPBufferedPosts = 512
|
||||
|
||||
// Do not impose an application-level lifetime on a connected XHTTP VPN
|
||||
// session. The official Xray server keeps a connected session for the
|
||||
// lifetime of its stream-down GET; request cancellation and I/O errors own
|
||||
// cleanup. A fixed five-minute sweeper incorrectly killed healthy but idle
|
||||
// VPNs. Zero disables the connected-session sweeper.
|
||||
fixedNativeXHTTPIdleMS = 0
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -77,3 +84,6 @@ func nativeXHTTPBufferedPostLimit() int { return defaultNativeXHTTPBufferedPosts
|
||||
func nativeMuxUDPIdleTimeout() time.Duration {
|
||||
return fixedNativeMuxUDPIdleMS * time.Millisecond
|
||||
}
|
||||
func nativeXHTTPIdleTimeout() time.Duration {
|
||||
return fixedNativeXHTTPIdleMS * time.Millisecond
|
||||
}
|
||||
|
||||
+196
-15
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -97,28 +98,161 @@ func mergeNativeXHTTPSettings(primary, fallback nativeXHTTPSettingsJSON) nativeX
|
||||
return out
|
||||
}
|
||||
|
||||
func newNativeXHTTPListener(addr string, inbounds []*nativeInbound) (*nativeXHTTPListener, error) {
|
||||
if len(inbounds) == 0 {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no inbounds", addr)
|
||||
}
|
||||
group := &nativeXHTTPListener{addr: addr, inbounds: append([]*nativeInbound(nil), inbounds...)}
|
||||
paths := make(map[string]string, len(inbounds))
|
||||
for i, ib := range group.inbounds {
|
||||
if ib == nil || !ib.isXHTTP() {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s contains a non-XHTTP inbound", addr)
|
||||
}
|
||||
ib.path = normalizeXHTTPPath(ib.path)
|
||||
if previous, exists := paths[ib.path]; exists {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds %q and %q use the same path %s on %s", previous, ib.tag, ib.path, addr)
|
||||
}
|
||||
paths[ib.path] = ib.tag
|
||||
if size := ib.xhttpServerMaxHeaderBytes(); size > group.headerSize {
|
||||
group.headerSize = size
|
||||
}
|
||||
if i == 0 {
|
||||
group.security = ib.security
|
||||
group.tlsConfig = ib.tlsConfig
|
||||
continue
|
||||
}
|
||||
if ib.security != group.security {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS setting", addr)
|
||||
}
|
||||
if group.security == "tls" && !sameNativeTLSCertificate(group.tlsConfig, ib.tlsConfig) {
|
||||
return nil, fmt.Errorf("native xray: XHTTP inbounds sharing %s must use the same TLS certificate", addr)
|
||||
}
|
||||
}
|
||||
if group.security == "tls" && group.tlsConfig == nil {
|
||||
return nil, fmt.Errorf("native xray: shared XHTTP listener %s has no TLS configuration", addr)
|
||||
}
|
||||
return group, nil
|
||||
}
|
||||
|
||||
func sameNativeTLSCertificate(a, b *tls.Config) bool {
|
||||
if a == nil || b == nil || len(a.Certificates) == 0 || len(b.Certificates) == 0 {
|
||||
return a == b
|
||||
}
|
||||
ac := a.Certificates[0].Certificate
|
||||
bc := b.Certificates[0].Certificate
|
||||
if len(ac) == 0 || len(bc) == 0 || len(ac[0]) != len(bc[0]) {
|
||||
return false
|
||||
}
|
||||
return string(ac[0]) == string(bc[0])
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) serveXHTTPListener(ln net.Listener) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray XHTTP listener inbound=%q addr=%s", ib.tag, ln.Addr()))
|
||||
group, err := newNativeXHTTPListener(ln.Addr().String(), []*nativeInbound{ib})
|
||||
if err != nil {
|
||||
xrayLogf("native xray: XHTTP listener %q rejected: %v", ib.tag, err)
|
||||
return
|
||||
}
|
||||
group.serve(ln)
|
||||
}
|
||||
|
||||
func (g *nativeXHTTPListener) serve(ln net.Listener) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray shared XHTTP listener addr=%s", ln.Addr()))
|
||||
h2s := &http2.Server{}
|
||||
handler := http.Handler(ib)
|
||||
handler := http.Handler(g)
|
||||
// Official Xray accepts plaintext HTTP/1.1 and h2c on non-TLS XHTTP
|
||||
// listeners, and negotiates h2/http1 through ALPN on TLS listeners. Without
|
||||
// h2c, some clients/CDNs can reach the port but the request never reaches the
|
||||
// XHTTP handler, which makes the proxy look dead with no useful target logs.
|
||||
if ib.security != "tls" {
|
||||
handler = h2c.NewHandler(ib, h2s)
|
||||
if g.security != "tls" {
|
||||
handler = h2c.NewHandler(g, h2s)
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 4 * time.Second,
|
||||
MaxHeaderBytes: ib.xhttpServerMaxHeaderBytes(),
|
||||
MaxHeaderBytes: g.headerSize,
|
||||
}
|
||||
if ib.security == "tls" && ib.tlsConfig != nil {
|
||||
srv.TLSConfig = ib.tlsConfig
|
||||
if g.security == "tls" && g.tlsConfig != nil {
|
||||
srv.TLSConfig = g.tlsConfig
|
||||
_ = http2.ConfigureServer(srv, h2s)
|
||||
}
|
||||
// Each path has its own session namespace and idle sweeper.
|
||||
stopSweep := make(chan struct{})
|
||||
defer close(stopSweep)
|
||||
for _, ib := range g.inbounds {
|
||||
ib := ib
|
||||
xrayGo(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag), func() { ib.sweepXHTTPSessions(stopSweep) })
|
||||
}
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) && !isListenerClosed(err) {
|
||||
xrayLogf("native xray: XHTTP server for inbound %q stopped: %v", ib.tag, err)
|
||||
xrayLogf("native xray: shared XHTTP server on %s stopped: %v", g.addr, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP picks the longest configured path. This makes a root XHTTP inbound
|
||||
// coexist with more specific services such as /ssh without allowing the root
|
||||
// handler to steal the SSH session path.
|
||||
func (g *nativeXHTTPListener) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var selected *nativeInbound
|
||||
selectedLen := -1
|
||||
for _, ib := range g.inbounds {
|
||||
if _, ok := ib.matchXHTTPPath(r.URL.Path); ok && len(ib.path) > selectedLen {
|
||||
selected = ib
|
||||
selectedLen = len(ib.path)
|
||||
}
|
||||
}
|
||||
if selected == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
selected.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// sweepXHTTPSessions periodically evicts connected XHTTP sessions that have seen
|
||||
// no traffic (in either direction) within the idle timeout. This is the safety
|
||||
// net behind the per-request context watch in handleXHTTPDownload; it only ever
|
||||
// touches sessions whose lastSeen has genuinely gone stale, so an active tunnel
|
||||
// (which refreshes lastSeen via nativeXHTTPConn.onActivity) is never reaped.
|
||||
func (ib *nativeInbound) sweepXHTTPSessions(stop <-chan struct{}) {
|
||||
defer xrayRecover(fmt.Sprintf("native xray XHTTP idle sweeper inbound=%q", ib.tag))
|
||||
idle := nativeXHTTPIdleTimeout()
|
||||
if idle <= 0 {
|
||||
return
|
||||
}
|
||||
interval := idle / 4
|
||||
if interval < 15*time.Second {
|
||||
interval = 15 * time.Second
|
||||
}
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
ib.reapStaleXHTTPSessions(idle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (ib *nativeInbound) reapStaleXHTTPSessions(idle time.Duration) {
|
||||
now := time.Now()
|
||||
var stale []*nativeXHTTPSession
|
||||
ib.xhttpMu.Lock()
|
||||
for id, s := range ib.xhttpSessions {
|
||||
s.mu.Lock()
|
||||
connected := s.connected
|
||||
last := s.lastSeen
|
||||
s.mu.Unlock()
|
||||
// Unconnected sessions have their own 30s reaper; only reap connected
|
||||
// ones that have gone idle past the timeout.
|
||||
if connected && now.Sub(last) >= idle {
|
||||
delete(ib.xhttpSessions, id)
|
||||
stale = append(stale, s)
|
||||
}
|
||||
}
|
||||
ib.xhttpMu.Unlock()
|
||||
for _, s := range stale {
|
||||
xrayTracef("native xray: xhttp idle sweep closing session=%q inbound=%q", s.id, ib.tag)
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +616,7 @@ func (ib *nativeInbound) handleXHTTPPacketUpload(w http.ResponseWriter, r *http.
|
||||
return
|
||||
}
|
||||
xrayTracef("native xray: xhttp packet-up push failed inbound=%q session=%q seq=%d: %v", ib.tag, sess.id, seq, err)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
http.Error(w, "xhttp session queue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
@@ -636,16 +770,37 @@ func (ib *nativeInbound) handleXHTTPDownload(w http.ResponseWriter, r *http.Requ
|
||||
var reader io.Reader = sess.queue
|
||||
resp := newNativeXHTTPResponseWriter(w)
|
||||
xc := &nativeXHTTPConn{
|
||||
reader: reader,
|
||||
writer: resp,
|
||||
remote: remote,
|
||||
local: dummyLocalAddr(r),
|
||||
reader: reader,
|
||||
writer: resp,
|
||||
remote: remote,
|
||||
local: dummyLocalAddr(r),
|
||||
onActivity: sess.touch,
|
||||
}
|
||||
xc.onClose = func() {
|
||||
resp.close()
|
||||
sess.close()
|
||||
}
|
||||
|
||||
// When the download GET is cancelled (client gone, or a CDN closes the
|
||||
// origin stream after its own idle timeout) the HTTP request context fires.
|
||||
// Closing xc unblocks the tunnel's uplink reader (via sess.close -> queue
|
||||
// close) and closes the backend, so handleXHTTPDownload returns and its
|
||||
// deferred deleteXHTTPSession runs. Without this watch an idle tunnel whose
|
||||
// client vanished silently would never be torn down. The goroutine exits on
|
||||
// sess.done once the session closes for any reason.
|
||||
go func() {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
// The stream-down HTTP request is the lifetime owner of an XHTTP
|
||||
// session. Log the actual transport cancellation so a CDN/proxy
|
||||
// timeout can be distinguished from a server idle policy.
|
||||
xrayLogf("native xray: xhttp stream-down ended inbound=%q session=%q remote=%s err=%v",
|
||||
ib.tag, sessionID, r.RemoteAddr, r.Context().Err())
|
||||
_ = xc.Close()
|
||||
case <-sess.done:
|
||||
}
|
||||
}()
|
||||
|
||||
ib.dispatchXHTTPConn(xc, remote)
|
||||
_ = xc.Close()
|
||||
}
|
||||
@@ -657,6 +812,17 @@ func (ib *nativeInbound) dispatchXHTTPConn(xc net.Conn, remote net.Addr) {
|
||||
ib.handleVLESS(xc, remote)
|
||||
case "vmess":
|
||||
ib.handleVMess(xc, remote)
|
||||
case "ssh":
|
||||
// XHTTP->SSH tunnel: the decoded stream is a raw SSH transport. Hand it to
|
||||
// the same SSH handler the TLS/DNSTT listeners use so tunneled clients
|
||||
// authenticate with ordinary SSH accounts. getSSHConfig() is the live,
|
||||
// hot-reloadable config; it can be nil only before the SSH server is set up.
|
||||
cfg := getSSHConfig()
|
||||
if cfg == nil {
|
||||
xrayLogf("native xray: inbound %q XHTTP->SSH has no SSH config available yet", ib.tag)
|
||||
return
|
||||
}
|
||||
handleConn(xc, cfg)
|
||||
default:
|
||||
xrayLogf("native xray: inbound %q XHTTP protocol %q not supported", ib.tag, ib.protocol)
|
||||
}
|
||||
@@ -727,6 +893,11 @@ type nativeXHTTPConn struct {
|
||||
|
||||
closeOnce sync.Once
|
||||
onClose func()
|
||||
// onActivity, when set, is called after any successful read or write so the
|
||||
// owning session's lastSeen reflects real bidirectional traffic (not just
|
||||
// HTTP request arrivals). The idle sweeper relies on this to avoid reaping a
|
||||
// tunnel that is actively streaming in only one direction.
|
||||
onActivity func()
|
||||
}
|
||||
|
||||
func (c *nativeXHTTPConn) Read(p []byte) (int, error) {
|
||||
@@ -736,10 +907,20 @@ func (c *nativeXHTTPConn) Read(p []byte) (int, error) {
|
||||
c.deadlineMu.Unlock()
|
||||
_ = dr.SetReadDeadline(d)
|
||||
}
|
||||
return c.reader.Read(p)
|
||||
n, err := c.reader.Read(p)
|
||||
if n > 0 && c.onActivity != nil {
|
||||
c.onActivity()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *nativeXHTTPConn) Write(p []byte) (int, error) { return c.writer.Write(p) }
|
||||
func (c *nativeXHTTPConn) Write(p []byte) (int, error) {
|
||||
n, err := c.writer.Write(p)
|
||||
if n > 0 && c.onActivity != nil {
|
||||
c.onActivity()
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (c *nativeXHTTPConn) Close() error {
|
||||
c.closeOnce.Do(func() {
|
||||
|
||||
Reference in New Issue
Block a user