Launch
This commit is contained in:
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# Build output
|
||||
sshpanel
|
||||
sshpanel.bak
|
||||
*.bak
|
||||
|
||||
# Runtime/generated config
|
||||
.env
|
||||
config.json
|
||||
xray_config.json
|
||||
banner.txt
|
||||
|
||||
# Secrets / keys / certificates
|
||||
keys/
|
||||
certs/
|
||||
*.pem
|
||||
*.key
|
||||
ssh_host_*_key
|
||||
ssh_host_*_key.pub
|
||||
|
||||
# Logs / runtime data
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Local/editor
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
379
README.md
Normal file
379
README.md
Normal file
@@ -0,0 +1,379 @@
|
||||
# DragonCoreSSH V40
|
||||
|
||||
## PT-BR
|
||||
|
||||
DragonCoreSSH V40 é um painel/servidor em Go para SSH com HTTP Injection, painel web, PostgreSQL, integração com Xray-core e API pública para consultar status de usuário.
|
||||
|
||||
### Requisitos
|
||||
|
||||
- Servidor Linux com `systemd`
|
||||
- Acesso `root` ou `sudo`
|
||||
- Gerenciador de pacotes `apt`, `yum` ou `dnf`
|
||||
- Portas liberadas no firewall/security group conforme a configuração usada
|
||||
|
||||
Distribuições alvo:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
- CentOS / RHEL / Rocky / AlmaLinux
|
||||
- Fedora
|
||||
|
||||
### Instalação
|
||||
|
||||
Clone o projeto e execute o instalador:
|
||||
|
||||
```bash
|
||||
git clone <REPOSITORY_URL>
|
||||
cd <PROJECT_FOLDER>
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
Durante a instalação, o script instala/configura:
|
||||
|
||||
- Go
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- Binário do DragonCoreSSH V40
|
||||
- Serviço `systemd` chamado `sshpanel`
|
||||
- Painel web
|
||||
- Arquivos de runtime em `/opt/sshpanel`
|
||||
|
||||
Ao finalizar, o instalador mostra os dados principais:
|
||||
|
||||
```text
|
||||
Server IP
|
||||
SSH ports
|
||||
VLESS port
|
||||
VLESS UUID
|
||||
Admin panel URL
|
||||
Admin login
|
||||
Admin password
|
||||
Admin token
|
||||
```
|
||||
|
||||
### Caminhos principais
|
||||
|
||||
```text
|
||||
/opt/sshpanel/sshpanel
|
||||
/opt/sshpanel/.env
|
||||
/opt/sshpanel/config.json
|
||||
/opt/sshpanel/xray_config.json
|
||||
/opt/sshpanel/admin/
|
||||
/opt/sshpanel/logs/panel.log
|
||||
/etc/systemd/system/sshpanel.service
|
||||
```
|
||||
|
||||
### Portas padrão
|
||||
|
||||
```text
|
||||
80 SSH com HTTP Injection
|
||||
8080 SSH extra com HTTP Injection
|
||||
9090 Painel web + API pública /check
|
||||
10086 Xray VLESS
|
||||
10088 SOCKS local em 127.0.0.1
|
||||
```
|
||||
|
||||
### Comandos úteis
|
||||
|
||||
Ver status do serviço:
|
||||
|
||||
```bash
|
||||
systemctl status sshpanel --no-pager -l
|
||||
```
|
||||
|
||||
Ver logs pelo `journalctl`:
|
||||
|
||||
```bash
|
||||
journalctl -u sshpanel -f
|
||||
```
|
||||
|
||||
Ver log direto do painel:
|
||||
|
||||
```bash
|
||||
tail -f /opt/sshpanel/logs/panel.log
|
||||
```
|
||||
|
||||
Reiniciar serviço:
|
||||
|
||||
```bash
|
||||
systemctl restart sshpanel
|
||||
```
|
||||
|
||||
### Atualização
|
||||
|
||||
Entre na nova pasta do código e execute:
|
||||
|
||||
```bash
|
||||
sudo bash update.sh
|
||||
```
|
||||
|
||||
O update recompila o binário e atualiza os arquivos do painel web, mantendo as configurações e dados existentes.
|
||||
|
||||
### API pública CheckUser
|
||||
|
||||
Endpoint:
|
||||
|
||||
```http
|
||||
GET /check
|
||||
```
|
||||
|
||||
URL padrão:
|
||||
|
||||
```text
|
||||
http://SERVER_IP:9090/check
|
||||
```
|
||||
|
||||
Consultar usuário SSH:
|
||||
|
||||
```bash
|
||||
curl "http://SERVER_IP:9090/check?user=testuser"
|
||||
```
|
||||
|
||||
Consultar UUID Xray:
|
||||
|
||||
```bash
|
||||
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
|
||||
```
|
||||
|
||||
Se `user` e `uuid` forem enviados juntos, `user` tem prioridade.
|
||||
|
||||
Resposta de sucesso:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "testuser",
|
||||
"count_connections": 1,
|
||||
"expiration_date": "31/12/2026",
|
||||
"expiration_days": 243,
|
||||
"limit_connections": 2
|
||||
}
|
||||
```
|
||||
|
||||
Conta ilimitada:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "testuser",
|
||||
"count_connections": 0,
|
||||
"expiration_date": "Unlimited",
|
||||
"expiration_days": -1,
|
||||
"limit_connections": 1
|
||||
}
|
||||
```
|
||||
|
||||
Campos da resposta:
|
||||
|
||||
| Campo | Tipo | Descrição |
|
||||
| --- | --- | --- |
|
||||
| `username` | string | Usuário SSH, nome do cliente Xray ou UUID. |
|
||||
| `count_connections` | number | Conexões SSH ativas no momento. |
|
||||
| `expiration_date` | string | Data de expiração em `DD/MM/YYYY` ou `Unlimited`. |
|
||||
| `expiration_days` | number | Dias restantes. `-1` significa ilimitado. |
|
||||
| `limit_connections` | number | Limite máximo de conexões. |
|
||||
|
||||
Erros comuns:
|
||||
|
||||
```json
|
||||
{"error":"user or uuid parameter required"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"user not found"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"uuid not found"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"database not configured"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## EN-US
|
||||
|
||||
DragonCoreSSH V40 is a Go-based SSH HTTP Injection server with a web panel, PostgreSQL, Xray-core integration, and a public API for checking user status.
|
||||
|
||||
### Requirements
|
||||
|
||||
- Linux server with `systemd`
|
||||
- `root` or `sudo` access
|
||||
- `apt`, `yum`, or `dnf` package manager
|
||||
- Required ports opened in the firewall/security group
|
||||
|
||||
Target distributions:
|
||||
|
||||
- Ubuntu / Debian / Linux Mint
|
||||
- CentOS / RHEL / Rocky / AlmaLinux
|
||||
- Fedora
|
||||
|
||||
### Installation
|
||||
|
||||
Clone the project and run the installer:
|
||||
|
||||
```bash
|
||||
git clone <REPOSITORY_URL>
|
||||
cd <PROJECT_FOLDER>
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
During installation, the script installs/configures:
|
||||
|
||||
- Go
|
||||
- PostgreSQL
|
||||
- Xray-core
|
||||
- DragonCoreSSH V40 binary
|
||||
- `systemd` service named `sshpanel`
|
||||
- Web panel
|
||||
- Runtime files in `/opt/sshpanel`
|
||||
|
||||
When finished, the installer prints the main access details:
|
||||
|
||||
```text
|
||||
Server IP
|
||||
SSH ports
|
||||
VLESS port
|
||||
VLESS UUID
|
||||
Admin panel URL
|
||||
Admin login
|
||||
Admin password
|
||||
Admin token
|
||||
```
|
||||
|
||||
### Main paths
|
||||
|
||||
```text
|
||||
/opt/sshpanel/sshpanel
|
||||
/opt/sshpanel/.env
|
||||
/opt/sshpanel/config.json
|
||||
/opt/sshpanel/xray_config.json
|
||||
/opt/sshpanel/admin/
|
||||
/opt/sshpanel/logs/panel.log
|
||||
/etc/systemd/system/sshpanel.service
|
||||
```
|
||||
|
||||
### Default ports
|
||||
|
||||
```text
|
||||
80 SSH with HTTP Injection
|
||||
8080 Extra SSH with HTTP Injection
|
||||
9090 Web panel + public /check API
|
||||
10086 Xray VLESS
|
||||
10088 Local SOCKS on 127.0.0.1
|
||||
```
|
||||
|
||||
### Useful commands
|
||||
|
||||
Check service status:
|
||||
|
||||
```bash
|
||||
systemctl status sshpanel --no-pager -l
|
||||
```
|
||||
|
||||
Follow logs with `journalctl`:
|
||||
|
||||
```bash
|
||||
journalctl -u sshpanel -f
|
||||
```
|
||||
|
||||
Follow panel log file:
|
||||
|
||||
```bash
|
||||
tail -f /opt/sshpanel/logs/panel.log
|
||||
```
|
||||
|
||||
Restart service:
|
||||
|
||||
```bash
|
||||
systemctl restart sshpanel
|
||||
```
|
||||
|
||||
### Update
|
||||
|
||||
Enter the new source-code folder and run:
|
||||
|
||||
```bash
|
||||
sudo bash update.sh
|
||||
```
|
||||
|
||||
The update script rebuilds the binary and updates the web panel files while keeping existing configuration and user data.
|
||||
|
||||
### Public CheckUser API
|
||||
|
||||
Endpoint:
|
||||
|
||||
```http
|
||||
GET /check
|
||||
```
|
||||
|
||||
Default URL:
|
||||
|
||||
```text
|
||||
http://SERVER_IP:9090/check
|
||||
```
|
||||
|
||||
Check SSH username:
|
||||
|
||||
```bash
|
||||
curl "http://SERVER_IP:9090/check?user=testuser"
|
||||
```
|
||||
|
||||
Check Xray UUID:
|
||||
|
||||
```bash
|
||||
curl "http://SERVER_IP:9090/check?uuid=a499cb67-6c73-43cc-a84d-92cbb68d22d1"
|
||||
```
|
||||
|
||||
If both `user` and `uuid` are sent, `user` has priority.
|
||||
|
||||
Success response:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "testuser",
|
||||
"count_connections": 1,
|
||||
"expiration_date": "31/12/2026",
|
||||
"expiration_days": 243,
|
||||
"limit_connections": 2
|
||||
}
|
||||
```
|
||||
|
||||
Unlimited account:
|
||||
|
||||
```json
|
||||
{
|
||||
"username": "testuser",
|
||||
"count_connections": 0,
|
||||
"expiration_date": "Unlimited",
|
||||
"expiration_days": -1,
|
||||
"limit_connections": 1
|
||||
}
|
||||
```
|
||||
|
||||
Response fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| `username` | string | SSH username, Xray client name, or UUID. |
|
||||
| `count_connections` | number | Current active SSH connections. |
|
||||
| `expiration_date` | string | Expiration date in `DD/MM/YYYY` or `Unlimited`. |
|
||||
| `expiration_days` | number | Remaining days. `-1` means unlimited. |
|
||||
| `limit_connections` | number | Maximum connection limit. |
|
||||
|
||||
Common errors:
|
||||
|
||||
```json
|
||||
{"error":"user or uuid parameter required"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"user not found"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"uuid not found"}
|
||||
```
|
||||
|
||||
```json
|
||||
{"error":"database not configured"}
|
||||
```
|
||||
2150
admin/index.html
Normal file
2150
admin/index.html
Normal file
File diff suppressed because it is too large
Load Diff
682
auth.go
Normal file
682
auth.go
Normal file
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "superadmin"
|
||||
RoleReseller = "reseller"
|
||||
sessionTTL = 12 * time.Hour
|
||||
)
|
||||
|
||||
// ---------- AdminUser ----------
|
||||
|
||||
type AdminUser struct {
|
||||
ID int
|
||||
Username string
|
||||
PasswordHash string
|
||||
Role string
|
||||
MaxUsers int
|
||||
ExpiresAt *time.Time
|
||||
IsActive bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// ---------- Session store (in-memory) ----------
|
||||
|
||||
type AdminSession struct {
|
||||
Token string
|
||||
UserID int
|
||||
Username string
|
||||
Role string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
type sessionStoreT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]*AdminSession
|
||||
}
|
||||
|
||||
var sessions = &sessionStoreT{m: make(map[string]*AdminSession)}
|
||||
|
||||
func (s *sessionStoreT) Create(userID int, username, role string) *AdminSession {
|
||||
b := make([]byte, 32)
|
||||
_, _ = rand.Read(b)
|
||||
tok := hex.EncodeToString(b)
|
||||
sess := &AdminSession{
|
||||
Token: tok,
|
||||
UserID: userID,
|
||||
Username: username,
|
||||
Role: role,
|
||||
ExpiresAt: time.Now().Add(sessionTTL),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.m[tok] = sess
|
||||
s.mu.Unlock()
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Get(token string) *AdminSession {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
sess := s.m[token]
|
||||
s.mu.RUnlock()
|
||||
if sess == nil || time.Now().After(sess.ExpiresAt) {
|
||||
return nil
|
||||
}
|
||||
return sess
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) Delete(token string) {
|
||||
s.mu.Lock()
|
||||
delete(s.m, token)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *sessionStoreT) cleanup() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
for tok, sess := range s.m {
|
||||
if now.After(sess.ExpiresAt) {
|
||||
delete(s.m, tok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- In-memory AdminUser cache ----------
|
||||
|
||||
type adminUserMgrT struct {
|
||||
mu sync.RWMutex
|
||||
m map[string]*AdminUser
|
||||
}
|
||||
|
||||
var adminUsers = &adminUserMgrT{m: make(map[string]*AdminUser)}
|
||||
|
||||
func (m *adminUserMgrT) set(u *AdminUser) {
|
||||
m.mu.Lock()
|
||||
m.m[u.Username] = u
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *adminUserMgrT) get(username string) (*AdminUser, bool) {
|
||||
m.mu.RLock()
|
||||
u, ok := m.m[username]
|
||||
m.mu.RUnlock()
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (m *adminUserMgrT) delete(username string) {
|
||||
m.mu.Lock()
|
||||
delete(m.m, username)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *adminUserMgrT) list() []*AdminUser {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*AdminUser, 0, len(m.m))
|
||||
for _, u := range m.m {
|
||||
cp := *u
|
||||
out = append(out, &cp)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *adminUserMgrT) replaceAll(users []*AdminUser) {
|
||||
m.mu.Lock()
|
||||
m.m = make(map[string]*AdminUser, len(users))
|
||||
for _, u := range users {
|
||||
cp := *u
|
||||
m.m[u.Username] = &cp
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---------- Context helpers ----------
|
||||
|
||||
type ctxKeyAdmin struct{}
|
||||
|
||||
func withSession(ctx context.Context, s *AdminSession) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyAdmin{}, s)
|
||||
}
|
||||
|
||||
func sessionFromCtx(ctx context.Context) *AdminSession {
|
||||
s, _ := ctx.Value(ctxKeyAdmin{}).(*AdminSession)
|
||||
return s
|
||||
}
|
||||
|
||||
// ---------- Middleware ----------
|
||||
|
||||
// sessionMiddleware requires a valid X-Session-Token header.
|
||||
func sessionMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := r.Header.Get("X-Session-Token")
|
||||
s := sessions.Get(token)
|
||||
if s == nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(withSession(r.Context(), s)))
|
||||
})
|
||||
}
|
||||
|
||||
// superAdminOnly wraps a handler to require role == superadmin.
|
||||
// Must be used AFTER sessionMiddleware (session must be in context).
|
||||
func superAdminOnly(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionFromCtx(r.Context())
|
||||
if s == nil || s.Role != RoleSuperAdmin {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// saSession chains sessionMiddleware + superAdminOnly.
|
||||
func saSession(next http.Handler) http.Handler {
|
||||
return sessionMiddleware(superAdminOnly(next))
|
||||
}
|
||||
|
||||
// ---------- Password hashing ----------
|
||||
|
||||
func hashAdminPassword(pw string) string {
|
||||
h := sha256.Sum256([]byte(pw))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// ---------- DB methods on Store ----------
|
||||
|
||||
func (s *Store) EnsureAdminUsersSchema(ctx context.Context) error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'reseller',
|
||||
max_users INT NOT NULL DEFAULT 30,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`,
|
||||
`ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT ''`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
if _, err := s.db.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("EnsureAdminUsersSchema: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) GetAdminUserByUsername(ctx context.Context, username string) (*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,
|
||||
&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
|
||||
}
|
||||
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`)
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) UpsertAdminUser(ctx context.Context, u *AdminUser) error {
|
||||
var expiresAt interface{}
|
||||
if u.ExpiresAt != nil {
|
||||
expiresAt = *u.ExpiresAt
|
||||
}
|
||||
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,
|
||||
).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)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) DeleteAdminUser(ctx context.Context, username string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM admin_users WHERE username=$1`, username)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) SetAdminUserActive(ctx context.Context, username string, active bool) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE admin_users SET is_active=$1 WHERE username=$2`, active, username)
|
||||
return err
|
||||
}
|
||||
|
||||
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
|
||||
WHERE role=$1 AND is_active=TRUE AND expires_at IS NOT NULL AND expires_at < NOW()`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
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
|
||||
WHERE role=$1 AND is_active=FALSE AND (expires_at IS NULL OR expires_at > NOW())`,
|
||||
RoleReseller)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanAdminUsers(rows)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
u.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// BootstrapSuperAdmin creates a default "admin" superadmin if none exists.
|
||||
// Returns the generated password, or "" if a superadmin already existed.
|
||||
func (s *Store) BootstrapSuperAdmin(ctx context.Context) (string, error) {
|
||||
var count int
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*) FROM admin_users WHERE role=$1`, RoleSuperAdmin,
|
||||
).Scan(&count); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if count > 0 {
|
||||
return "", nil
|
||||
}
|
||||
b := make([]byte, 10)
|
||||
_, _ = rand.Read(b)
|
||||
pw := hex.EncodeToString(b)
|
||||
u := &AdminUser{
|
||||
Username: "admin",
|
||||
PasswordHash: hashAdminPassword(pw),
|
||||
Role: RoleSuperAdmin,
|
||||
MaxUsers: 0,
|
||||
IsActive: true,
|
||||
}
|
||||
if err := s.UpsertAdminUser(ctx, u); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return pw, nil
|
||||
}
|
||||
|
||||
// loadAdminUsersIntoCache reloads all admin_users rows into the in-memory cache.
|
||||
func loadAdminUsersIntoCache(ctx context.Context, store *Store) error {
|
||||
users, err := store.ListAdminUsers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
adminUsers.replaceAll(users)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------- Owner check (called from SSH auth callbacks) ----------
|
||||
|
||||
// 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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// disconnectOwnerUsers forcibly closes all active SSH connections for users owned by owner.
|
||||
func disconnectOwnerUsers(ownerUsername string) {
|
||||
for _, u := range userMgr.List() {
|
||||
if u.Cfg.OwnerUsername == ownerUsername {
|
||||
userMgr.DisconnectUser(u.Cfg.Username)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// countOwnedUsers counts SSH users in memory that belong to owner.
|
||||
func countOwnedUsers(ownerUsername string) int {
|
||||
n := 0
|
||||
for _, u := range userMgr.List() {
|
||||
if u.Cfg.OwnerUsername == ownerUsername {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// ---------- Reseller expiry background checker ----------
|
||||
|
||||
func startResellerExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(60 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx := context.Background()
|
||||
|
||||
// Expire active resellers past their deadline
|
||||
expired, err := store.ListExpiredResellers(ctx)
|
||||
if err != nil {
|
||||
log.Printf("reseller expiry check: %v", err)
|
||||
}
|
||||
for _, u := range expired {
|
||||
log.Printf("reseller %s expired — suspending", u.Username)
|
||||
if err := store.SetAdminUserActive(ctx, u.Username, false); err != nil {
|
||||
log.Printf("reseller expiry: %v", err)
|
||||
continue
|
||||
}
|
||||
u.IsActive = false
|
||||
adminUsers.set(u)
|
||||
disconnectOwnerUsers(u.Username)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
u.IsActive = true
|
||||
adminUsers.set(u)
|
||||
}
|
||||
|
||||
sessions.cleanup()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// ---------- HTTP handlers ----------
|
||||
|
||||
func handleLogin(store *Store) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Username == "" || req.Password == "" {
|
||||
http.Error(w, "username and password required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := store.GetAdminUserByUsername(r.Context(), req.Username)
|
||||
if err != nil {
|
||||
log.Printf("login db: %v", err)
|
||||
http.Error(w, "server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if u == nil || u.PasswordHash != hashAdminPassword(req.Password) {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
sess := sessions.Create(u.ID, u.Username, u.Role)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"token": sess.Token,
|
||||
"username": u.Username,
|
||||
"role": u.Role,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
sessions.Delete(r.Header.Get("X-Session-Token"))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleMe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
s := sessionFromCtx(r.Context())
|
||||
resp := map[string]interface{}{
|
||||
"username": s.Username,
|
||||
"role": s.Role,
|
||||
}
|
||||
if s.Role == RoleReseller {
|
||||
if u, ok := adminUsers.get(s.Username); ok {
|
||||
resp["max_users"] = u.MaxUsers
|
||||
resp["used_users"] = countOwnedUsers(s.Username)
|
||||
resp["expires_at"] = u.ExpiresAt
|
||||
resp["is_active"] = u.IsActive
|
||||
}
|
||||
}
|
||||
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"`
|
||||
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: countOwnedUsers(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 reseller was reactivated, users can reconnect automatically.
|
||||
// Reconnect of existing SSH connections happens via the expiry checker.
|
||||
|
||||
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)
|
||||
adminUsers.delete(username)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
121
check_api.go
Normal file
121
check_api.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckResponse is returned by the public /check endpoint.
|
||||
type CheckResponse struct {
|
||||
Username string `json:"username"`
|
||||
CountConnections int `json:"count_connections"`
|
||||
ExpirationDate string `json:"expiration_date"`
|
||||
ExpirationDays int `json:"expiration_days"`
|
||||
LimitConnections int `json:"limit_connections"`
|
||||
}
|
||||
|
||||
// handleCheck is the public user-check API. No authentication required.
|
||||
// Accepts ?user=<ssh-username> or ?uuid=<xray-uuid> (or both; user takes priority).
|
||||
// Returns JSON matching CheckResponse.
|
||||
func handleCheck(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
username := q.Get("user")
|
||||
uuid := q.Get("uuid")
|
||||
|
||||
if username == "" && uuid == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(`{"error":"user or uuid parameter required"}`))
|
||||
return
|
||||
}
|
||||
|
||||
if username != "" {
|
||||
checkSSHUser(w, username)
|
||||
return
|
||||
}
|
||||
checkXrayUUID(w, r, uuid)
|
||||
}
|
||||
|
||||
func checkSSHUser(w http.ResponseWriter, username string) {
|
||||
u, ok := userMgr.Get(username)
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"user not found"}`))
|
||||
return
|
||||
}
|
||||
|
||||
u.mu.Lock()
|
||||
activeConns := u.ActiveConns
|
||||
maxConns := u.Cfg.MaxConnections
|
||||
expiresAt := u.ExpiresAt
|
||||
u.mu.Unlock()
|
||||
|
||||
resp := CheckResponse{
|
||||
Username: username,
|
||||
CountConnections: activeConns,
|
||||
LimitConnections: maxConns,
|
||||
}
|
||||
fillExpiry(&resp, expiresAt)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func checkXrayUUID(w http.ResponseWriter, r *http.Request, uuid string) {
|
||||
if statsStore == nil {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
_, _ = w.Write([]byte(`{"error":"database not configured"}`))
|
||||
return
|
||||
}
|
||||
|
||||
meta, err := statsStore.GetXrayClientMeta(r.Context(), uuid)
|
||||
if err == sql.ErrNoRows {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write([]byte(`{"error":"uuid not found"}`))
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"error":"database error"}`))
|
||||
return
|
||||
}
|
||||
|
||||
displayName := meta.Name
|
||||
if displayName == "" {
|
||||
displayName = meta.UUID
|
||||
}
|
||||
|
||||
resp := CheckResponse{
|
||||
Username: displayName,
|
||||
CountConnections: 0,
|
||||
LimitConnections: meta.MaxConns,
|
||||
}
|
||||
fillExpiry(&resp, meta.ExpiresAt)
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
func fillExpiry(resp *CheckResponse, expiresAt *time.Time) {
|
||||
if expiresAt == nil {
|
||||
resp.ExpirationDate = "Unlimited"
|
||||
resp.ExpirationDays = -1
|
||||
return
|
||||
}
|
||||
resp.ExpirationDate = expiresAt.Local().Format("02/01/2006")
|
||||
days := int(time.Until(*expiresAt).Hours() / 24)
|
||||
if days < 0 {
|
||||
days = 0
|
||||
}
|
||||
resp.ExpirationDays = days
|
||||
}
|
||||
1158
dnstt_integration.go
Normal file
1158
dnstt_integration.go
Normal file
File diff suppressed because it is too large
Load Diff
20
go.mod
Normal file
20
go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module shell2
|
||||
|
||||
go 1.25.4
|
||||
|
||||
require golang.org/x/crypto v0.45.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/time v0.14.0 // indirect
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0 // indirect
|
||||
)
|
||||
104
go.sum
Normal file
104
go.sum
Normal file
@@ -0,0 +1,104 @@
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
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/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=
|
||||
github.com/flynn/noise v1.0.0 h1:DlTHqmzmvcEiKj+4RYo/imoswx/4r6iBlCMfVtrMXpQ=
|
||||
github.com/flynn/noise v1.0.0/go.mod h1:xbMo+0i6+IGbYdJhF31t2eR1BIU0CYc12+BNAKwUTag=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.6 h1:ndNyv040zDGIDh8thGkXYjnFtiN02M1PVVF+JE/48xc=
|
||||
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/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
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/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/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=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
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/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=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
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/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
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=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
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/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
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=
|
||||
www.bamsoftware.com/git/dnstt.git v1.20241021.0/go.mod h1:J4kVFxhn2bZqSqfE9l7keNTtsc+dRR6+uNH4kPu5VIs=
|
||||
269
hotreload.go
Normal file
269
hotreload.go
Normal file
@@ -0,0 +1,269 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// ---------- Global SSH config ----------
|
||||
|
||||
var (
|
||||
sshCfgMu sync.RWMutex
|
||||
currentSSHCfg *ssh.ServerConfig
|
||||
)
|
||||
|
||||
func setSSHConfig(c *ssh.ServerConfig) {
|
||||
sshCfgMu.Lock()
|
||||
currentSSHCfg = c
|
||||
sshCfgMu.Unlock()
|
||||
}
|
||||
|
||||
func getSSHConfig() *ssh.ServerConfig {
|
||||
sshCfgMu.RLock()
|
||||
defer sshCfgMu.RUnlock()
|
||||
return currentSSHCfg
|
||||
}
|
||||
|
||||
// ---------- Dynamic TCP listener pool ----------
|
||||
|
||||
// listenerPool manages a set of net.Listener instances by address.
|
||||
// Calling Sync() opens new addresses and closes removed ones; unchanged
|
||||
// addresses are left untouched so existing connections are not disrupted.
|
||||
type listenerPool struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]net.Listener
|
||||
serve func(net.Listener) // goroutine launched for each new listener
|
||||
}
|
||||
|
||||
func newListenerPool(serve func(net.Listener)) *listenerPool {
|
||||
return &listenerPool{entries: make(map[string]net.Listener), serve: serve}
|
||||
}
|
||||
|
||||
// Sync ensures exactly addrs are listening. Returns errors for addresses
|
||||
// that could not be opened.
|
||||
func (p *listenerPool) Sync(addrs []string) []error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
want := make(map[string]bool, len(addrs))
|
||||
for _, a := range addrs {
|
||||
if a != "" {
|
||||
want[a] = true
|
||||
}
|
||||
}
|
||||
|
||||
for addr, ln := range p.entries {
|
||||
if !want[addr] {
|
||||
_ = ln.Close() // causes the serve goroutine to exit
|
||||
delete(p.entries, addr)
|
||||
log.Printf("hotreload: stopped %s", addr)
|
||||
}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for addr := range want {
|
||||
if _, ok := p.entries[addr]; ok {
|
||||
continue
|
||||
}
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("listen %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
p.entries[addr] = ln
|
||||
log.Printf("hotreload: listening on %s", addr)
|
||||
go p.serve(ln)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// ---------- Dynamic TLS listener pool ----------
|
||||
|
||||
type tlsListenerPool struct {
|
||||
mu sync.Mutex
|
||||
entries map[string]net.Listener
|
||||
}
|
||||
|
||||
func newTLSListenerPool() *tlsListenerPool {
|
||||
return &tlsListenerPool{entries: make(map[string]net.Listener)}
|
||||
}
|
||||
|
||||
// Sync ensures exactly forwarders are listening (matched by listen address).
|
||||
func (p *tlsListenerPool) Sync(forwarders []TLSForwarderConfig) []error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
want := make(map[string]TLSForwarderConfig, len(forwarders))
|
||||
for _, f := range forwarders {
|
||||
if f.Listen != "" {
|
||||
want[f.Listen] = f
|
||||
}
|
||||
}
|
||||
|
||||
for addr, ln := range p.entries {
|
||||
if _, ok := want[addr]; !ok {
|
||||
_ = ln.Close()
|
||||
delete(p.entries, addr)
|
||||
log.Printf("hotreload: stopped TLS %s", addr)
|
||||
}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for addr, fwd := range want {
|
||||
if _, ok := p.entries[addr]; ok {
|
||||
continue
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(fwd.CertFile, fwd.KeyFile)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("TLS cert/key %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
ln, err := tls.Listen("tcp", addr, &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("TLS listen %s: %w", addr, err))
|
||||
continue
|
||||
}
|
||||
p.entries[addr] = ln
|
||||
log.Printf("hotreload: TLS+SSH listening on %s", addr)
|
||||
go serveTLSSSH(ln)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// ---------- Global pool instances (initialised in main) ----------
|
||||
|
||||
var (
|
||||
publicPool *listenerPool // HTTP+SSH: listen + extra_listen
|
||||
localPool *listenerPool // raw SSH: local_ssh_listen
|
||||
tlsPool *tlsListenerPool // TLS forwarders
|
||||
)
|
||||
|
||||
// isListenerClosed reports whether err came from using a closed listener.
|
||||
func isListenerClosed(err error) bool {
|
||||
return errors.Is(err, net.ErrClosed)
|
||||
}
|
||||
|
||||
// ---------- Runtime settings ----------
|
||||
|
||||
var (
|
||||
defaultLimitsMu sync.RWMutex
|
||||
runtimeLimitMbpsUp int
|
||||
runtimeLimitMbpsDown int
|
||||
|
||||
adminHandlerMu sync.RWMutex
|
||||
adminHandler http.Handler
|
||||
)
|
||||
|
||||
func setDefaultLimits(up, down int) {
|
||||
defaultLimitsMu.Lock()
|
||||
runtimeLimitMbpsUp = up
|
||||
runtimeLimitMbpsDown = down
|
||||
defaultLimitsMu.Unlock()
|
||||
}
|
||||
|
||||
func getDefaultLimits() (up, down int) {
|
||||
defaultLimitsMu.RLock()
|
||||
defer defaultLimitsMu.RUnlock()
|
||||
return runtimeLimitMbpsUp, runtimeLimitMbpsDown
|
||||
}
|
||||
|
||||
func setAdminHandler(h http.Handler) {
|
||||
adminHandlerMu.Lock()
|
||||
adminHandler = h
|
||||
adminHandlerMu.Unlock()
|
||||
}
|
||||
|
||||
func getAdminHandler() http.Handler {
|
||||
adminHandlerMu.RLock()
|
||||
defer adminHandlerMu.RUnlock()
|
||||
return adminHandler
|
||||
}
|
||||
|
||||
// ---------- Full live reload ----------
|
||||
|
||||
// applyFullConfigReload applies every field in newCfg to the running server
|
||||
// without a process restart. Port changes, DNSTT/UDPGW changes, Xray changes,
|
||||
// and bandwidth defaults all take effect immediately.
|
||||
// The only field that still requires a restart is host_key_file.
|
||||
func applyFullConfigReload(newCfg *Config) {
|
||||
// Banner
|
||||
bt := newCfg.Banner
|
||||
if bt == "" && newCfg.BannerFile != "" {
|
||||
if data, err := os.ReadFile(newCfg.BannerFile); err == nil {
|
||||
bt = string(data)
|
||||
}
|
||||
}
|
||||
setBannerText(bt)
|
||||
|
||||
// Default per-connection bandwidth limits (picked up by new connections)
|
||||
setDefaultLimits(newCfg.DefaultLimitMbpsUp, newCfg.DefaultLimitMbpsDown)
|
||||
|
||||
// Quiet logging / user count display
|
||||
if newCfg.Quiet {
|
||||
log.SetOutput(io.Discard)
|
||||
} else if !newCfg.UserCount {
|
||||
log.SetOutput(os.Stderr)
|
||||
}
|
||||
userCountEnabled = newCfg.UserCount
|
||||
|
||||
// Admin panel directory (hot-swaps the file server on the next request)
|
||||
if newCfg.AdminDir != "" {
|
||||
setAdminHandler(http.FileServer(http.Dir(newCfg.AdminDir)))
|
||||
}
|
||||
|
||||
// Public SSH listeners (main listen + extra_listen)
|
||||
publicAddrs := append([]string{newCfg.Listen}, newCfg.ExtraListen...)
|
||||
for _, e := range publicPool.Sync(publicAddrs) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
}
|
||||
|
||||
// Local raw SSH listener
|
||||
var localAddrs []string
|
||||
if newCfg.LocalSSHListen != "" {
|
||||
localAddrs = []string{newCfg.LocalSSHListen}
|
||||
}
|
||||
for _, e := range localPool.Sync(localAddrs) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
}
|
||||
|
||||
// TLS forwarders
|
||||
for _, e := range tlsPool.Sync(newCfg.TLSForwarders) {
|
||||
log.Printf("hotreload: %v", e)
|
||||
}
|
||||
|
||||
// DNSTT — stop current instance (no-op if not running) then start new one
|
||||
stopDNSTT()
|
||||
startDNSTT(newCfg.DNSTT, getSSHConfig())
|
||||
|
||||
// UDPGW — same pattern
|
||||
stopUDPGW()
|
||||
startUDPGW(newCfg.UDPGW)
|
||||
|
||||
// Xray — update stored config then restart/stop as needed
|
||||
if newCfg.Xray != nil {
|
||||
xrayMgr.mu.Lock()
|
||||
xrayMgr.cfg = newCfg.Xray
|
||||
xrayMgr.mu.Unlock()
|
||||
if newCfg.Xray.Enabled {
|
||||
_ = xrayMgr.Restart()
|
||||
} else {
|
||||
_ = xrayMgr.Stop()
|
||||
}
|
||||
} else {
|
||||
_ = xrayMgr.Stop()
|
||||
}
|
||||
|
||||
setGlobalCfg(newCfg)
|
||||
}
|
||||
386
install.sh
Normal file
386
install.sh
Normal file
@@ -0,0 +1,386 @@
|
||||
#!/bin/bash
|
||||
# Auto-install script for SSH Panel + Xray-core (Ubuntu/Debian/CentOS)
|
||||
# Usage: sudo bash install.sh
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
|
||||
|
||||
# ── config ──────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="/opt/sshpanel"
|
||||
SERVICE_NAME="sshpanel"
|
||||
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")}"
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
|
||||
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} SSH Panel + Xray-core · Installer ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
|
||||
|
||||
# ── 1. OS detection ──────────────────────────────────────────────────────────
|
||||
info "[1/9] Detecting OS…"
|
||||
if [[ -f /etc/os-release ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
OS_ID="${ID:-unknown}"
|
||||
else
|
||||
OS_ID="unknown"
|
||||
fi
|
||||
|
||||
case "$OS_ID" in
|
||||
ubuntu|debian|linuxmint)
|
||||
PKG_UPDATE="apt-get update -qq"
|
||||
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
|
||||
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
|
||||
;;
|
||||
centos|rhel|rocky|almalinux)
|
||||
PKG_UPDATE="yum makecache -q"
|
||||
PKG_INSTALL="yum install -y"
|
||||
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
|
||||
;;
|
||||
fedora)
|
||||
PKG_UPDATE="dnf makecache -q"
|
||||
PKG_INSTALL="dnf install -y"
|
||||
PKG_DEPS="curl wget git gcc make postgresql-server postgresql-contrib ca-certificates unzip openssh-clients openssl"
|
||||
;;
|
||||
*)
|
||||
warn "Unknown OS '$OS_ID' — attempting apt-get…"
|
||||
PKG_UPDATE="apt-get update -qq"
|
||||
PKG_INSTALL="DEBIAN_FRONTEND=noninteractive apt-get install -y"
|
||||
PKG_DEPS="curl wget git build-essential postgresql postgresql-contrib ca-certificates unzip openssh-client openssl"
|
||||
;;
|
||||
esac
|
||||
info " OS: $OS_ID"
|
||||
|
||||
# ── 2. System dependencies ───────────────────────────────────────────────────
|
||||
info "[2/9] Installing system packages…"
|
||||
eval "$PKG_UPDATE"
|
||||
eval "$PKG_INSTALL $PKG_DEPS"
|
||||
|
||||
# ── 3. Go ────────────────────────────────────────────────────────────────────
|
||||
info "[3/9] Installing Go ${GO_VERSION}…"
|
||||
NEED_GO=true
|
||||
if command -v go &>/dev/null; then
|
||||
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||||
if [[ "$(printf '%s\n' "$GO_VERSION" "$CURRENT_GO" | sort -V | head -1)" == "$GO_VERSION" ]]; then
|
||||
info " Go $CURRENT_GO already installed — skipping"
|
||||
NEED_GO=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
esac
|
||||
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"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
chmod +x /etc/profile.d/go.sh
|
||||
fi
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
go version
|
||||
|
||||
# ── 4. Directory layout ──────────────────────────────────────────────────────
|
||||
info "[4/9] Setting up ${INSTALL_DIR}…"
|
||||
mkdir -p "$INSTALL_DIR/admin" "$INSTALL_DIR/keys" "$INSTALL_DIR/logs"
|
||||
|
||||
# ── 5. Build SSH panel binary ────────────────────────────────────────────────
|
||||
info "[5/9] Building SSH Panel binary…"
|
||||
cd "$SCRIPT_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
go mod download
|
||||
go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
|
||||
info " Binary: $INSTALL_DIR/sshpanel"
|
||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
||||
info " Admin panel copied"
|
||||
|
||||
# ── 6. Xray binary ──────────────────────────────────────────────────────────
|
||||
info "[6/9] 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" ;;
|
||||
esac
|
||||
XRAY_URL="https://github.com/XTLS/Xray-core/releases/download/${XRAY_VER}/Xray-linux-${XRAY_ARCH}.zip"
|
||||
info " Xray ${XRAY_VER} (${XRAY_ARCH})"
|
||||
wget -q --show-progress -O /tmp/xray.zip "$XRAY_URL"
|
||||
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
|
||||
"$INSTALL_DIR/xray" version
|
||||
|
||||
# ── 7. PostgreSQL ────────────────────────────────────────────────────────────
|
||||
info "[7/9] Configuring PostgreSQL…"
|
||||
case "$OS_ID" in
|
||||
centos|rhel|rocky|almalinux|fedora)
|
||||
postgresql-setup --initdb 2>/dev/null || true ;;
|
||||
esac
|
||||
systemctl start postgresql 2>/dev/null || service postgresql start 2>/dev/null || true
|
||||
systemctl enable postgresql 2>/dev/null || true
|
||||
|
||||
DB_NAME="sshpanel"
|
||||
DB_USER="sshpanel"
|
||||
DB_PASS=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 32 || true)
|
||||
if [[ ${#DB_PASS} -lt 32 ]]; then
|
||||
DB_PASS=$(openssl rand -hex 16 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
|
||||
su -c "psql -tc \"SELECT 1 FROM pg_roles WHERE rolname='${DB_USER}'\" | grep -q 1 || \
|
||||
psql -c \"CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres
|
||||
# Reinstall-safe: if the role already existed, make the new .env password valid.
|
||||
su -c "psql -c \"ALTER USER ${DB_USER} WITH PASSWORD '${DB_PASS}';\"" postgres
|
||||
|
||||
su -c "psql -tc \"SELECT 1 FROM pg_database WHERE datname='${DB_NAME}'\" | grep -q 1 || \
|
||||
psql -c \"CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};\"" postgres
|
||||
# Reinstall-safe: if the database already existed, make sshpanel its owner.
|
||||
su -c "psql -c \"ALTER DATABASE ${DB_NAME} OWNER TO ${DB_USER};\"" postgres
|
||||
|
||||
su -c "psql -d ${DB_NAME} -c \"
|
||||
CREATE TABLE IF NOT EXISTS ssh_users (
|
||||
username TEXT PRIMARY KEY,
|
||||
password TEXT NOT NULL DEFAULT '',
|
||||
max_connections INT NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
limit_mbps_up INT NOT NULL DEFAULT 0,
|
||||
limit_mbps_down INT NOT NULL DEFAULT 0,
|
||||
totp_secret TEXT NOT NULL DEFAULT '',
|
||||
totp_period INT NOT NULL DEFAULT 60,
|
||||
totp_window INT NOT NULL DEFAULT 1,
|
||||
totp_digits INT NOT NULL DEFAULT 6,
|
||||
allow_static_password BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
owner_username TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_secret TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_period INT NOT NULL DEFAULT 60;
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_window INT NOT NULL DEFAULT 1;
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS totp_digits INT NOT NULL DEFAULT 6;
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS allow_static_password BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE ssh_users ADD COLUMN IF NOT EXISTS owner_username TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE ssh_users ALTER COLUMN password SET DEFAULT '';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ssh_iface_totals (
|
||||
iface TEXT PRIMARY KEY,
|
||||
total_rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
total_tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS total_rx_bytes BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS total_tx_bytes BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS last_kernel_rx_bytes BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS last_kernel_tx_bytes BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE ssh_iface_totals ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admin_users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'reseller',
|
||||
max_users INT NOT NULL DEFAULT 30,
|
||||
expires_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_conns INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
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 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};
|
||||
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${DB_USER};
|
||||
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${DB_USER};
|
||||
\"" postgres
|
||||
|
||||
info " PostgreSQL database '${DB_NAME}' ready"
|
||||
|
||||
# ── 8. Config files ──────────────────────────────────────────────────────────
|
||||
info "[8/9] Generating config files…"
|
||||
|
||||
# Admin token
|
||||
ADMIN_TOKEN=$(tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48 || true)
|
||||
if [[ ${#ADMIN_TOKEN} -lt 48 ]]; then
|
||||
ADMIN_TOKEN=$(openssl rand -hex 24 2>/dev/null || date +%s%N)
|
||||
fi
|
||||
|
||||
# Admin panel login password. The web panel login is username/password;
|
||||
# ADMIN_TOKEN is only for bearer-token API access and is not the login password.
|
||||
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}')
|
||||
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)
|
||||
ON CONFLICT (username) DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = 'superadmin',
|
||||
max_users = 0,
|
||||
expires_at = NULL,
|
||||
is_active = TRUE;
|
||||
SQL
|
||||
|
||||
# .env
|
||||
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"
|
||||
|
||||
# SSH host key (RSA, required by current build)
|
||||
if [[ ! -f "$INSTALL_DIR/ssh_host_rsa_key" ]]; then
|
||||
ssh-keygen -t rsa -b 2048 -f "$INSTALL_DIR/ssh_host_rsa_key" -N "" -C "sshpanel-hostkey" -q
|
||||
info " Generated RSA host key"
|
||||
fi
|
||||
|
||||
# Server public IP (best-effort)
|
||||
SERVER_IP=$(curl -sf --max-time 5 https://checkip.amazonaws.com 2>/dev/null \
|
||||
|| curl -sf --max-time 5 https://api.ipify.org 2>/dev/null \
|
||||
|| hostname -I | awk '{print $1}')
|
||||
|
||||
# config.json
|
||||
cat > "$INSTALL_DIR/config.json" <<EOF
|
||||
{
|
||||
"listen": "0.0.0.0:80",
|
||||
"extra_listen": ["0.0.0.0:8080"],
|
||||
"local_ssh_listen": "127.0.0.1:2222",
|
||||
"host_key_file": "${INSTALL_DIR}/ssh_host_rsa_key",
|
||||
"quiet": false,
|
||||
"admin_dir": "${INSTALL_DIR}/admin",
|
||||
"banner_file": "${INSTALL_DIR}/banner.txt",
|
||||
"xray": {
|
||||
"enabled": true,
|
||||
"bin_path": "${INSTALL_DIR}/xray",
|
||||
"config_file": "${INSTALL_DIR}/xray_config.json"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
touch "$INSTALL_DIR/banner.txt"
|
||||
|
||||
# UUID for default VLESS client
|
||||
UUID=$(cat /proc/sys/kernel/random/uuid 2>/dev/null \
|
||||
|| python3 -c "import uuid; print(uuid.uuid4())" 2>/dev/null \
|
||||
|| echo "11111111-2222-3333-4444-555555555555")
|
||||
|
||||
# xray_config.json (default VLESS + SOCKS inbounds — no geoip routing needed)
|
||||
cat > "$INSTALL_DIR/xray_config.json" <<EOF
|
||||
{
|
||||
"log": { "loglevel": "warning" },
|
||||
"inbounds": [
|
||||
{
|
||||
"tag": "vless-in",
|
||||
"port": 10086,
|
||||
"listen": "0.0.0.0",
|
||||
"protocol": "vless",
|
||||
"settings": {
|
||||
"clients": [{ "id": "${UUID}", "level": 0 }],
|
||||
"decryption": "none"
|
||||
},
|
||||
"streamSettings": { "network": "tcp" }
|
||||
},
|
||||
{
|
||||
"tag": "socks-local",
|
||||
"port": 10088,
|
||||
"listen": "127.0.0.1",
|
||||
"protocol": "socks",
|
||||
"settings": { "auth": "noauth", "udp": true }
|
||||
}
|
||||
],
|
||||
"outbounds": [
|
||||
{ "tag": "direct", "protocol": "freedom", "settings": {} },
|
||||
{ "tag": "blocked", "protocol": "blackhole", "settings": {} }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
chmod 600 "$INSTALL_DIR/xray_config.json"
|
||||
info " VLESS UUID: ${UUID}"
|
||||
|
||||
# ── 9. Systemd service ───────────────────────────────────────────────────────
|
||||
info "[9/9] Creating systemd service '${SERVICE_NAME}'…"
|
||||
cat > "/etc/systemd/system/${SERVICE_NAME}.service" <<EOF
|
||||
[Unit]
|
||||
Description=SSH Panel + Xray-core Server
|
||||
After=network.target postgresql.service
|
||||
Wants=postgresql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=${INSTALL_DIR}
|
||||
EnvironmentFile=${INSTALL_DIR}/.env
|
||||
ExecStart=${INSTALL_DIR}/sshpanel -config ${INSTALL_DIR}/config.json
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
User=root
|
||||
LimitNOFILE=65536
|
||||
StandardOutput=append:${INSTALL_DIR}/logs/panel.log
|
||||
StandardError=append:${INSTALL_DIR}/logs/panel.log
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOF
|
||||
|
||||
systemctl daemon-reload
|
||||
systemctl enable "$SERVICE_NAME"
|
||||
systemctl restart "$SERVICE_NAME"
|
||||
|
||||
sleep 2
|
||||
echo ""
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Installation complete! ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " Server IP : ${YELLOW}${SERVER_IP}${NC}"
|
||||
echo -e " SSH ports : 80, 8080 (HTTP-injected SSH)"
|
||||
echo -e " VLESS port : 10086"
|
||||
echo -e " VLESS UUID : ${YELLOW}${UUID}${NC}"
|
||||
echo ""
|
||||
echo -e " Admin panel : ${YELLOW}http://${SERVER_IP}:9090${NC}"
|
||||
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 " Logs: journalctl -u ${SERVICE_NAME} -f"
|
||||
echo -e " tail -f ${INSTALL_DIR}/logs/panel.log"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Save your admin login/password. The admin token is for API bearer-token access only.${NC}"
|
||||
echo ""
|
||||
systemctl status "$SERVICE_NAME" --no-pager -l || true
|
||||
117
server_config_api.go
Normal file
117
server_config_api.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Note: applyFullConfigReload is defined in hotreload.go
|
||||
|
||||
// ---------- Global config holder ----------
|
||||
|
||||
var (
|
||||
globalCfgMu sync.RWMutex
|
||||
globalCfg *Config
|
||||
globalCfgPath string // set in main() from the -config flag
|
||||
|
||||
bannerMu sync.RWMutex
|
||||
currentBannerText string
|
||||
)
|
||||
|
||||
func setGlobalCfg(c *Config) {
|
||||
globalCfgMu.Lock()
|
||||
globalCfg = c
|
||||
globalCfgMu.Unlock()
|
||||
}
|
||||
|
||||
func getGlobalCfg() *Config {
|
||||
globalCfgMu.RLock()
|
||||
defer globalCfgMu.RUnlock()
|
||||
return globalCfg
|
||||
}
|
||||
|
||||
func setBannerText(s string) {
|
||||
bannerMu.Lock()
|
||||
currentBannerText = s
|
||||
bannerMu.Unlock()
|
||||
}
|
||||
|
||||
func getBannerText() string {
|
||||
bannerMu.RLock()
|
||||
defer bannerMu.RUnlock()
|
||||
return currentBannerText
|
||||
}
|
||||
|
||||
// ---------- HTTP handler ----------
|
||||
|
||||
// handleServerConfig dispatches GET (read) and POST (write) for /api/server/config.
|
||||
func handleServerConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
serverConfigGet(w, r)
|
||||
case http.MethodPost:
|
||||
serverConfigPost(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func serverConfigGet(w http.ResponseWriter, _ *http.Request) {
|
||||
if globalCfgPath == "" {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(globalCfgPath)
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read config: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
}
|
||||
|
||||
func serverConfigPost(w http.ResponseWriter, r *http.Request) {
|
||||
if globalCfgPath == "" {
|
||||
http.Error(w, "config path not set", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var newCfg Config
|
||||
if err := json.Unmarshal(body, &newCfg); err != nil {
|
||||
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if newCfg.Listen == "" {
|
||||
http.Error(w, "listen address required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Preserve file-based users array (not editable through the UI).
|
||||
globalCfgMu.RLock()
|
||||
if globalCfg != nil {
|
||||
newCfg.Users = globalCfg.Users
|
||||
}
|
||||
globalCfgMu.RUnlock()
|
||||
|
||||
out, err := json.MarshalIndent(newCfg, "", " ")
|
||||
if err != nil {
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply all changes live — no restart needed.
|
||||
applyFullConfigReload(&newCfg)
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
168
tls_api.go
Normal file
168
tls_api.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
const tlsCertsDir = "/opt/sshpanel/certs"
|
||||
|
||||
// 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) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" {
|
||||
http.Error(w, "domain required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
certDir := filepath.Join(tlsCertsDir, req.Domain)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
certFile := filepath.Join(certDir, "cert.pem")
|
||||
keyFile := filepath.Join(certDir, "key.pem")
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
http.Error(w, "keygen: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: req.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},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
http.Error(w, "certgen: "+err.Error(), http.StatusInternalServerError)
|
||||
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)
|
||||
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)
|
||||
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{
|
||||
"cert_file": certFile,
|
||||
"key_file": keyFile,
|
||||
})
|
||||
}
|
||||
|
||||
// handleTLSLetsEncrypt runs certbot to obtain a certificate via Let's Encrypt.
|
||||
// Requires certbot installed on the server and port 80 available.
|
||||
func handleTLSLetsEncrypt(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Domain string `json:"domain"`
|
||||
Email string `json:"email"`
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
|
||||
certFile := "/etc/letsencrypt/live/" + req.Domain + "/fullchain.pem"
|
||||
keyFile := "/etc/letsencrypt/live/" + req.Domain + "/privkey.pem"
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"cert_file": certFile,
|
||||
"key_file": keyFile,
|
||||
"output": string(out),
|
||||
})
|
||||
}
|
||||
|
||||
// handleTLSUploadPEM accepts PEM text for cert and key, saves them to disk under
|
||||
// /opt/sshpanel/certs/<name>/, and returns the file paths.
|
||||
func handleTLSUploadPEM(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Cert string `json:"cert"`
|
||||
Key string `json:"key"`
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
certDir := filepath.Join(tlsCertsDir, name)
|
||||
if err := os.MkdirAll(certDir, 0o700); err != nil {
|
||||
http.Error(w, "mkdir: "+err.Error(), http.StatusInternalServerError)
|
||||
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)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(keyFile, []byte(req.Key), 0o600); err != nil {
|
||||
http.Error(w, "write key: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"cert_file": certFile,
|
||||
"key_file": keyFile,
|
||||
})
|
||||
}
|
||||
490
udpgw_integration.go
Normal file
490
udpgw_integration.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package main
|
||||
|
||||
// This file embeds the UDP gateway (udpgw) service into the main
|
||||
// application. The original udpgw program (public domain) accepts a
|
||||
// TCP connection, then forwards framed UDP datagrams to arbitrary
|
||||
// destinations and demultiplexes replies back to the originating
|
||||
// connection. Here we expose the same functionality via a
|
||||
// configuration key in config.json. When enabled, the server binds
|
||||
// to the provided Listen address (or the default 0.0.0.0:7400 if
|
||||
// unspecified) and handles each client in its own goroutine. The
|
||||
// gateway runs entirely in‑process and behaves like the standalone
|
||||
// badvpn-udpgw daemon.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
udpgwMu sync.Mutex
|
||||
udpgwLn net.Listener
|
||||
)
|
||||
|
||||
// stopUDPGW closes the active UDPGW listener, causing the accept loop to exit.
|
||||
// It is a no-op if UDPGW is not running.
|
||||
func stopUDPGW() {
|
||||
udpgwMu.Lock()
|
||||
defer udpgwMu.Unlock()
|
||||
if udpgwLn != nil {
|
||||
_ = udpgwLn.Close()
|
||||
udpgwLn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// startUDPGW starts the integrated UDP gateway if cfg is non‑nil and
|
||||
// cfg.Listen is non‑empty. It applies default values to any zero
|
||||
// configuration fields and converts duration strings to time.Duration.
|
||||
// The server runs in a goroutine; any fatal errors are logged and
|
||||
// prevent the gateway from starting, but do not terminate the main
|
||||
// process.
|
||||
func startUDPGW(cfg *UDPGWConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
// Default the listen address to the standalone default (0.0.0.0:7400) if
|
||||
// unspecified. This matches the behaviour of the original
|
||||
// badvpn-udpgw program, which listens on all interfaces by default.
|
||||
listenAddr := cfg.Listen
|
||||
if listenAddr == "" {
|
||||
listenAddr = "0.0.0.0:7400"
|
||||
}
|
||||
// Apply defaults for numeric fields if zero.
|
||||
c := &internalUDPGWConfig{}
|
||||
c.listen = listenAddr
|
||||
if cfg.MaxFrame > 0 {
|
||||
c.maxFrame = cfg.MaxFrame
|
||||
} else {
|
||||
c.maxFrame = 64 * 1024
|
||||
}
|
||||
c.debug = cfg.Debug
|
||||
if cfg.HexdumpN > 0 {
|
||||
c.hexdumpN = cfg.HexdumpN
|
||||
} else {
|
||||
c.hexdumpN = 64
|
||||
}
|
||||
if cfg.WriteChan > 0 {
|
||||
c.writeChan = cfg.WriteChan
|
||||
} else {
|
||||
c.writeChan = 4096
|
||||
}
|
||||
c.udpBindIP = cfg.UDPBindIP
|
||||
if cfg.UDPRBuf > 0 {
|
||||
c.udpRBuf = cfg.UDPRBuf
|
||||
} else {
|
||||
c.udpRBuf = 8 * 1024 * 1024
|
||||
}
|
||||
if cfg.UDPWBuf > 0 {
|
||||
c.udpWBuf = cfg.UDPWBuf
|
||||
} else {
|
||||
c.udpWBuf = 8 * 1024 * 1024
|
||||
}
|
||||
// Parse durations with fallback defaults.
|
||||
if cfg.MapTTL != "" {
|
||||
if d, err := time.ParseDuration(cfg.MapTTL); err == nil {
|
||||
c.mapTTL = d
|
||||
} else {
|
||||
log.Printf("udpgw: invalid map_ttl %q: %v; using default 90s", cfg.MapTTL, err)
|
||||
c.mapTTL = 90 * time.Second
|
||||
}
|
||||
} else {
|
||||
c.mapTTL = 90 * time.Second
|
||||
}
|
||||
if cfg.ReapEvery != "" {
|
||||
if d, err := time.ParseDuration(cfg.ReapEvery); err == nil {
|
||||
c.reapEvery = d
|
||||
} else {
|
||||
log.Printf("udpgw: invalid reap_every %q: %v; using default 10s", cfg.ReapEvery, err)
|
||||
c.reapEvery = 10 * time.Second
|
||||
}
|
||||
} else {
|
||||
c.reapEvery = 10 * time.Second
|
||||
}
|
||||
// Idle timeout for TCP clients.
|
||||
if cfg.IdleTimeout != "" {
|
||||
if d, err := time.ParseDuration(cfg.IdleTimeout); err == nil {
|
||||
c.idleTimeout = d
|
||||
} else {
|
||||
log.Printf("udpgw: invalid idle_timeout %q: %v; using default 2m", cfg.IdleTimeout, err)
|
||||
c.idleTimeout = 2 * time.Minute
|
||||
}
|
||||
} else {
|
||||
c.idleTimeout = 2 * time.Minute
|
||||
}
|
||||
// Per-client logical connID cap.
|
||||
if cfg.MaxClientConns > 0 {
|
||||
c.maxClientConns = cfg.MaxClientConns
|
||||
} else {
|
||||
c.maxClientConns = 10
|
||||
}
|
||||
// Per-client destination mapping cap.
|
||||
if cfg.MaxMapEntries > 0 {
|
||||
c.maxMapEntries = cfg.MaxMapEntries
|
||||
} else {
|
||||
c.maxMapEntries = 32768
|
||||
}
|
||||
// Start listening.
|
||||
ln, err := net.Listen("tcp", c.listen)
|
||||
if err != nil {
|
||||
log.Printf("udpgw: listen failed on %s: %v", c.listen, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Register as the active listener so stopUDPGW can close it.
|
||||
udpgwMu.Lock()
|
||||
if udpgwLn != nil {
|
||||
_ = udpgwLn.Close()
|
||||
}
|
||||
udpgwLn = ln
|
||||
udpgwMu.Unlock()
|
||||
|
||||
if c.debug {
|
||||
log.Printf("udpgw: listening on %s", c.listen)
|
||||
}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
log.Printf("udpgw: accept error: %v", err)
|
||||
continue
|
||||
}
|
||||
go handleUDPGWClient(conn, c)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// internalUDPGWConfig mirrors the exported UDPGWConfig but with
|
||||
// time.Duration fields for TTL and reaper intervals. It does not
|
||||
// embed JSON tags because it is not exposed to the user.
|
||||
type internalUDPGWConfig struct {
|
||||
listen string
|
||||
maxFrame int
|
||||
debug bool
|
||||
hexdumpN int
|
||||
writeChan int
|
||||
udpBindIP string
|
||||
udpRBuf int
|
||||
udpWBuf int
|
||||
mapTTL time.Duration
|
||||
reapEvery time.Duration
|
||||
idleTimeout time.Duration
|
||||
maxClientConns int
|
||||
maxMapEntries int
|
||||
}
|
||||
|
||||
// udpDestKey identifies a destination IPv4:port for the UDP gateway. A
|
||||
// mapping of this key to a connID+x byte is kept per client so
|
||||
// replies can be routed correctly. Each client maintains its own map
|
||||
// of udpDestKey->udpMapVal entries.
|
||||
type udpDestKey struct {
|
||||
ip [4]byte
|
||||
port uint16
|
||||
}
|
||||
|
||||
// udpMapVal stores the mapping from udpDestKey to connID, the x byte and the
|
||||
// expiration time.
|
||||
type udpMapVal struct {
|
||||
connID uint16
|
||||
x byte
|
||||
exp time.Time
|
||||
}
|
||||
|
||||
// handleUDPGWClient manages a single TCP client connection to the UDP
|
||||
// gateway. It creates a per‑client UDP socket, reads frames from the
|
||||
// TCP connection, sends UDP datagrams to the requested destination,
|
||||
// maintains a mapping of udpDestKey->udpMapVal for routing replies, and
|
||||
// writes reply frames back over the TCP connection. When the client
|
||||
// disconnects or an error occurs, all goroutines are terminated and the
|
||||
// UDP socket is closed.
|
||||
func handleUDPGWClient(conn net.Conn, c *internalUDPGWConfig) {
|
||||
defer conn.Close()
|
||||
remote := conn.RemoteAddr().String()
|
||||
if c.debug {
|
||||
log.Printf("udpgw: client connected: %s", remote)
|
||||
}
|
||||
// Lower latency for interactive applications by disabling Nagle.
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
br := bufio.NewReaderSize(conn, 256*1024)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// Bind a UDP socket for this client. Use cfg.udpBindIP if provided.
|
||||
var laddr *net.UDPAddr
|
||||
if c.udpBindIP != "" {
|
||||
ip := net.ParseIP(c.udpBindIP)
|
||||
if ip != nil {
|
||||
laddr = &net.UDPAddr{IP: ip, Port: 0}
|
||||
} else {
|
||||
log.Printf("udpgw[%s]: invalid udp_bind IP %q", remote, c.udpBindIP)
|
||||
return
|
||||
}
|
||||
}
|
||||
udpConn, err := net.ListenUDP("udp", laddr)
|
||||
if err != nil {
|
||||
log.Printf("udpgw[%s]: UDP listen failed: %v", remote, err)
|
||||
return
|
||||
}
|
||||
defer udpConn.Close()
|
||||
_ = udpConn.SetReadBuffer(c.udpRBuf)
|
||||
_ = udpConn.SetWriteBuffer(c.udpWBuf)
|
||||
// Channel to queue outgoing frames back to the client.
|
||||
writeCh := make(chan []byte, c.writeChan)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case b := <-writeCh:
|
||||
if len(b) == 0 {
|
||||
continue
|
||||
}
|
||||
// If the client stops reading, don't block forever.
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
|
||||
_, err := conn.Write(b)
|
||||
if err != nil {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Destination -> connID+x mapping and active connID tracking per TCP client.
|
||||
var mu sync.Mutex
|
||||
destToConn := make(map[udpDestKey]udpMapVal)
|
||||
connIDLastSeen := make(map[uint16]time.Time)
|
||||
// Start a reaper goroutine to purge expired mappings.
|
||||
// Uses ctx instead of a separate stopReaper channel so that cancellation
|
||||
// is always uniform: any exit path that calls cancel() (error, disconnect,
|
||||
// idle timeout, panic-deferred cancel) also stops the reaper.
|
||||
go func() {
|
||||
t := time.NewTicker(c.reapEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
for k, v := range destToConn {
|
||||
if now.After(v.exp) {
|
||||
delete(destToConn, k)
|
||||
}
|
||||
}
|
||||
for id, lastSeen := range connIDLastSeen {
|
||||
if now.Sub(lastSeen) > c.mapTTL {
|
||||
delete(connIDLastSeen, id)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Goroutine to read UDP replies and write framed responses back.
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, from, err := udpConn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if n <= 0 {
|
||||
continue
|
||||
}
|
||||
ip4 := from.IP.To4()
|
||||
if ip4 == nil {
|
||||
// IPv6 replies are dropped because framing only supports IPv4.
|
||||
continue
|
||||
}
|
||||
k := udpDestKey{port: uint16(from.Port)}
|
||||
copy(k.ip[:], ip4)
|
||||
mu.Lock()
|
||||
v, ok := destToConn[k]
|
||||
mu.Unlock()
|
||||
if !ok || time.Now().After(v.exp) {
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: dropping %dB from %s (no mapping)", remote, n, from.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Avoid unbounded memory growth: if the client is slow and the
|
||||
// write queue is full, drop replies rather than allocating more.
|
||||
if len(writeCh) == cap(writeCh) {
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: drop UDP reply %dB from %s (write queue full)", remote, n, from.String())
|
||||
}
|
||||
continue
|
||||
}
|
||||
frame := udpgwBuildFrame(v.connID, v.x, k.ip, k.port, buf[:n])
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case writeCh <- frame:
|
||||
default:
|
||||
// Race with another sender filling the channel.
|
||||
}
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: UDP<- %dB from %s -> connID=%d x=0x%02x", remote, n, from.String(), v.connID, v.x)
|
||||
}
|
||||
}
|
||||
}()
|
||||
// Main loop: read frames from TCP, update mapping and send UDP.
|
||||
for {
|
||||
// Close idle TCP clients to avoid leaking goroutines.
|
||||
if c.idleTimeout > 0 {
|
||||
_ = conn.SetReadDeadline(time.Now().Add(c.idleTimeout))
|
||||
}
|
||||
payload, err := udpgwReadPayload(br, c.maxFrame)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: idle timeout (%s); closing", remote, c.idleTimeout)
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
if c.debug {
|
||||
log.Printf("udpgw: client disconnected: %s", remote)
|
||||
}
|
||||
} else {
|
||||
log.Printf("udpgw[%s]: read error: %v", remote, err)
|
||||
}
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
<-done
|
||||
return
|
||||
}
|
||||
// payload: connID(2) + X(1) + dstIPv4(4) + dstPort(2) + data
|
||||
if len(payload) < 2+1+4+2 {
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: too short payload %dB", remote, len(payload))
|
||||
}
|
||||
continue
|
||||
}
|
||||
connID := binary.BigEndian.Uint16(payload[0:2])
|
||||
x := payload[2]
|
||||
var dstIP [4]byte
|
||||
copy(dstIP[:], payload[3:7])
|
||||
dstPort := binary.BigEndian.Uint16(payload[7:9])
|
||||
data := payload[9:]
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: RX connID=%d dst=%d.%d.%d.%d:%d x=0x%02x len=%d", remote, connID, dstIP[0], dstIP[1], dstIP[2], dstIP[3], dstPort, x, len(data))
|
||||
}
|
||||
// Enforce a per-client logical session cap using udpgw connID.
|
||||
k := udpDestKey{ip: dstIP, port: dstPort}
|
||||
now := time.Now()
|
||||
mu.Lock()
|
||||
// Step 1: evict TTL-expired connIDs first.
|
||||
for id, lastSeen := range connIDLastSeen {
|
||||
if now.Sub(lastSeen) > c.mapTTL {
|
||||
delete(connIDLastSeen, id)
|
||||
}
|
||||
}
|
||||
// Step 2: if the connID is new and we are still at or above the cap
|
||||
// after TTL eviction, evict the single oldest entry to make room.
|
||||
// This prevents a client rotating connIDs faster than mapTTL from
|
||||
// bypassing maxClientConns and growing the map indefinitely.
|
||||
if _, alreadyKnown := connIDLastSeen[connID]; !alreadyKnown && c.maxClientConns > 0 && len(connIDLastSeen) >= c.maxClientConns {
|
||||
// Find and remove the oldest connID seen.
|
||||
var oldestID uint16
|
||||
var oldestTime time.Time
|
||||
first := true
|
||||
for id, lastSeen := range connIDLastSeen {
|
||||
if first || lastSeen.Before(oldestTime) {
|
||||
oldestID = id
|
||||
oldestTime = lastSeen
|
||||
first = false
|
||||
}
|
||||
}
|
||||
delete(connIDLastSeen, oldestID)
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: connID cap reached (%d); evicted oldest connID=%d to admit connID=%d", remote, c.maxClientConns, oldestID, connID)
|
||||
}
|
||||
}
|
||||
connIDLastSeen[connID] = now
|
||||
// Update the mapping (bounded to protect memory).
|
||||
if c.maxMapEntries > 0 && len(destToConn) >= c.maxMapEntries {
|
||||
// First, purge expired entries.
|
||||
for dk, dv := range destToConn {
|
||||
if now.After(dv.exp) {
|
||||
delete(destToConn, dk)
|
||||
}
|
||||
}
|
||||
// If still too large, evict arbitrary entries until we're under the cap.
|
||||
if len(destToConn) >= c.maxMapEntries {
|
||||
evict := (len(destToConn) - c.maxMapEntries) + 1
|
||||
for dk := range destToConn {
|
||||
delete(destToConn, dk)
|
||||
evict--
|
||||
if evict <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: mapping cap reached; evicted entries, size=%d", remote, len(destToConn))
|
||||
}
|
||||
}
|
||||
}
|
||||
destToConn[k] = udpMapVal{connID: connID, x: x, exp: now.Add(c.mapTTL)}
|
||||
mu.Unlock()
|
||||
// Send the UDP datagram.
|
||||
raddr := &net.UDPAddr{IP: net.IP(dstIP[:]), Port: int(dstPort)}
|
||||
_, err = udpConn.WriteToUDP(data, raddr)
|
||||
if err != nil {
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: UDP write failed to %s: %v", remote, raddr.String(), err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c.debug {
|
||||
log.Printf("udpgw[%s]: UDP-> %dB to %s", remote, len(data), raddr.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// udpgwReadPayload reads a length‑prefixed payload from r. The length
|
||||
// prefix is a little‑endian uint16. Payloads larger than max cause an
|
||||
// error.
|
||||
func udpgwReadPayload(r *bufio.Reader, max int) ([]byte, error) {
|
||||
var lenBuf [2]byte
|
||||
if _, err := io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := int(binary.LittleEndian.Uint16(lenBuf[:]))
|
||||
if n <= 0 || n > max {
|
||||
return nil, fmt.Errorf("udpgw: invalid frame length %d", n)
|
||||
}
|
||||
b := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, b); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// udpgwBuildFrame constructs a reply frame for the client. The frame
|
||||
// consists of a little‑endian length prefix, then connID (big endian),
|
||||
// x byte, source IPv4, source port, and the data.
|
||||
func udpgwBuildFrame(connID uint16, x byte, ip [4]byte, port uint16, data []byte) []byte {
|
||||
payloadLen := 2 + 1 + 4 + 2 + len(data)
|
||||
out := make([]byte, 2+payloadLen)
|
||||
binary.LittleEndian.PutUint16(out[0:2], uint16(payloadLen))
|
||||
binary.BigEndian.PutUint16(out[2:4], connID)
|
||||
out[4] = x
|
||||
copy(out[5:9], ip[:])
|
||||
binary.BigEndian.PutUint16(out[9:11], port)
|
||||
copy(out[11:], data)
|
||||
return out
|
||||
}
|
||||
199
update.sh
Normal file
199
update.sh
Normal file
@@ -0,0 +1,199 @@
|
||||
#!/bin/bash
|
||||
# Update script for SSH Panel — updates the binary and admin panel in place.
|
||||
# Preserves: .env, config.json, xray_config.json, SSH keys, database, certs.
|
||||
# Usage: sudo bash update.sh
|
||||
set -euo pipefail
|
||||
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
|
||||
info() { echo -e "${GREEN}[+]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
error() { echo -e "${RED}[x]${NC} $*"; exit 1; }
|
||||
|
||||
# ── config ────────────────────────────────────────────────────────────────────
|
||||
INSTALL_DIR="/opt/sshpanel"
|
||||
SERVICE_NAME="sshpanel"
|
||||
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")}"
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
|
||||
|
||||
echo -e "\n${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} SSH Panel · Updater ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}\n"
|
||||
|
||||
# ── 1. Pre-flight checks ──────────────────────────────────────────────────────
|
||||
info "[1/5] Pre-flight checks…"
|
||||
|
||||
[[ -d "$INSTALL_DIR" ]] || error "Install dir $INSTALL_DIR not found — run install.sh first."
|
||||
[[ -f "$INSTALL_DIR/.env" ]] || error "$INSTALL_DIR/.env not found — run install.sh first."
|
||||
[[ -f "$SCRIPT_DIR/go.mod" ]] || error "go.mod not found — run this script from the source directory."
|
||||
|
||||
info " Install dir : $INSTALL_DIR"
|
||||
info " Source dir : $SCRIPT_DIR"
|
||||
info " Go version : $GO_VERSION"
|
||||
|
||||
# ── 2. Go toolchain ───────────────────────────────────────────────────────────
|
||||
info "[2/5] Checking Go toolchain…"
|
||||
|
||||
NEED_GO=true
|
||||
if command -v go &>/dev/null; then
|
||||
CURRENT_GO=$(go version 2>/dev/null | awk '{print $3}' | sed 's/go//')
|
||||
if [[ "$(printf '%s\n' "$GO_VERSION" "$CURRENT_GO" | sort -V | head -1)" == "$GO_VERSION" ]]; then
|
||||
info " Go $CURRENT_GO already installed — skipping"
|
||||
NEED_GO=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if $NEED_GO; then
|
||||
MACHINE=$(uname -m)
|
||||
case "$MACHINE" in
|
||||
x86_64) GOARCH="amd64" ;;
|
||||
aarch64) GOARCH="arm64" ;;
|
||||
armv7l) GOARCH="armv6l" ;;
|
||||
*) GOARCH="amd64" ;;
|
||||
esac
|
||||
GO_URL="https://go.dev/dl/go${GO_VERSION}.linux-${GOARCH}.tar.gz"
|
||||
info " Downloading Go ${GO_VERSION} (${GOARCH})…"
|
||||
wget -q --show-progress -O /tmp/go.tar.gz "$GO_URL"
|
||||
rm -rf /usr/local/go
|
||||
tar -C /usr/local -xzf /tmp/go.tar.gz
|
||||
rm -f /tmp/go.tar.gz
|
||||
echo 'export PATH=$PATH:/usr/local/go/bin' > /etc/profile.d/go.sh
|
||||
chmod +x /etc/profile.d/go.sh
|
||||
fi
|
||||
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
go version
|
||||
|
||||
# ── 3. Build new binary ───────────────────────────────────────────────────────
|
||||
info "[3/5] Building new sshpanel binary…"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
export GOPATH=/tmp/gopath_sshpanel
|
||||
export GOCACHE=/tmp/gocache_sshpanel
|
||||
go mod download
|
||||
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
|
||||
info " Build complete."
|
||||
|
||||
# ── 4. Apply update ───────────────────────────────────────────────────────────
|
||||
info "[4/5] Applying update…"
|
||||
|
||||
# Stop the service
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
info " Stopping $SERVICE_NAME…"
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
RESTART_NEEDED=true
|
||||
else
|
||||
RESTART_NEEDED=false
|
||||
fi
|
||||
|
||||
# Backup old binary
|
||||
if [[ -f "$INSTALL_DIR/sshpanel" ]]; then
|
||||
cp "$INSTALL_DIR/sshpanel" "$INSTALL_DIR/sshpanel.bak"
|
||||
info " Old binary backed up to sshpanel.bak"
|
||||
fi
|
||||
|
||||
# Replace binary
|
||||
mv /tmp/sshpanel_new "$INSTALL_DIR/sshpanel"
|
||||
chmod +x "$INSTALL_DIR/sshpanel"
|
||||
info " Binary updated."
|
||||
|
||||
# Update admin panel files
|
||||
mkdir -p "$INSTALL_DIR/admin"
|
||||
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
|
||||
info " Admin panel updated."
|
||||
|
||||
# Ensure banner file exists (new in this version)
|
||||
if [[ ! -f "$INSTALL_DIR/banner.txt" ]]; then
|
||||
touch "$INSTALL_DIR/banner.txt"
|
||||
info " Created banner.txt"
|
||||
fi
|
||||
|
||||
# Ensure certs directory exists (new in this version)
|
||||
mkdir -p "$INSTALL_DIR/certs"
|
||||
|
||||
# Patch config.json to add missing fields introduced in this version
|
||||
# without overwriting user-configured values.
|
||||
CFG="$INSTALL_DIR/config.json"
|
||||
if [[ -f "$CFG" ]]; then
|
||||
# Add banner_file if not present
|
||||
if ! python3 -c "import json,sys; d=json.load(open('$CFG')); sys.exit(0 if 'banner_file' in d else 1)" 2>/dev/null; then
|
||||
python3 - "$CFG" << 'PYEOF'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
if 'banner_file' not in d:
|
||||
d['banner_file'] = '/opt/sshpanel/banner.txt'
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f, indent=2)
|
||||
PYEOF
|
||||
info " Added banner_file to config.json"
|
||||
fi
|
||||
|
||||
# Fix routing: remove geoip:private rules that require geoip.dat from xray_config.json
|
||||
XCFG="$INSTALL_DIR/xray_config.json"
|
||||
if [[ -f "$XCFG" ]]; then
|
||||
if grep -q '"geoip:private"' "$XCFG" 2>/dev/null; then
|
||||
python3 - "$XCFG" << 'PYEOF'
|
||||
import json, sys
|
||||
path = sys.argv[1]
|
||||
with open(path) as f:
|
||||
d = json.load(f)
|
||||
routing = d.get('routing', {})
|
||||
rules = routing.get('rules', [])
|
||||
# Remove rules that reference geoip:private
|
||||
new_rules = [r for r in rules if 'geoip:private' not in r.get('ip', [])]
|
||||
if new_rules != rules:
|
||||
if new_rules:
|
||||
d['routing']['rules'] = new_rules
|
||||
else:
|
||||
d.pop('routing', None)
|
||||
with open(path, 'w') as f:
|
||||
json.dump(d, f, indent=2)
|
||||
PYEOF
|
||||
info " Removed geoip:private routing rule from xray_config.json"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5. Restart service ────────────────────────────────────────────────────────
|
||||
info "[5/5] Restarting service…"
|
||||
|
||||
if $RESTART_NEEDED; then
|
||||
systemctl start "$SERVICE_NAME"
|
||||
sleep 2
|
||||
if systemctl is-active --quiet "$SERVICE_NAME"; then
|
||||
info " $SERVICE_NAME is running."
|
||||
else
|
||||
warn " $SERVICE_NAME failed to start — check logs:"
|
||||
warn " journalctl -u $SERVICE_NAME -n 30 --no-pager"
|
||||
warn " You can restore the old binary:"
|
||||
warn " mv $INSTALL_DIR/sshpanel.bak $INSTALL_DIR/sshpanel && systemctl start $SERVICE_NAME"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
warn " Service was not running; start it with: systemctl start $SERVICE_NAME"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo -e "${GREEN} Update complete! ${NC}"
|
||||
echo -e "${GREEN}══════════════════════════════════════════${NC}"
|
||||
echo ""
|
||||
echo -e " Logs: ${YELLOW}journalctl -u ${SERVICE_NAME} -f${NC}"
|
||||
echo -e " ${YELLOW}tail -f ${INSTALL_DIR}/logs/panel.log${NC}"
|
||||
echo ""
|
||||
echo -e " Backup: ${YELLOW}${INSTALL_DIR}/sshpanel.bak${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}What was updated:${NC}"
|
||||
echo -e " • sshpanel binary"
|
||||
echo -e " • Admin panel (admin/index.html)"
|
||||
echo -e "${YELLOW}What was preserved:${NC}"
|
||||
echo -e " • .env (DB credentials, tokens)"
|
||||
echo -e " • config.json (your server settings)"
|
||||
echo -e " • xray_config.json (your Xray settings)"
|
||||
echo -e " • SSH host keys"
|
||||
echo -e " • All user data in PostgreSQL"
|
||||
echo ""
|
||||
164
xray_clients.go
Normal file
164
xray_clients.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// XrayClientMeta holds metadata stored in PostgreSQL for an Xray client.
|
||||
// Xray's own config only stores uuid/email/level; expiry and display name live here.
|
||||
type XrayClientMeta struct {
|
||||
UUID string
|
||||
Name string
|
||||
Email string
|
||||
InboundTag string
|
||||
ExpiresAt *time.Time
|
||||
MaxConns int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Store) EnsureXrayClientsSchema(ctx context.Context) error {
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS xray_clients (
|
||||
uuid TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
email TEXT NOT NULL DEFAULT '',
|
||||
inbound_tag TEXT NOT NULL DEFAULT '',
|
||||
expires_at TIMESTAMPTZ,
|
||||
max_conns INT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) UpsertXrayClientMeta(ctx context.Context, m XrayClientMeta) error {
|
||||
var expiresAt interface{}
|
||||
if m.ExpiresAt != nil {
|
||||
expiresAt = *m.ExpiresAt
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT INTO xray_clients (uuid, name, email, inbound_tag, expires_at, max_conns)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (uuid) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
email = EXCLUDED.email,
|
||||
inbound_tag = EXCLUDED.inbound_tag,
|
||||
expires_at = EXCLUDED.expires_at,
|
||||
max_conns = EXCLUDED.max_conns`,
|
||||
m.UUID, m.Name, m.Email, m.InboundTag, expiresAt, m.MaxConns)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetXrayClientMeta(ctx context.Context, uuid string) (*XrayClientMeta, error) {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
FROM xray_clients WHERE uuid = $1`, uuid).
|
||||
Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeleteXrayClientMeta(ctx context.Context, uuid string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM xray_clients WHERE uuid = $1`, uuid)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) ListAllXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
FROM xray_clients ORDER BY created_at DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*XrayClientMeta
|
||||
for rows.Next() {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListExpiredXrayClients(ctx context.Context) ([]*XrayClientMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT uuid, name, email, inbound_tag, expires_at, max_conns, created_at
|
||||
FROM xray_clients WHERE expires_at IS NOT NULL AND expires_at <= NOW()`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*XrayClientMeta
|
||||
for rows.Next() {
|
||||
m := &XrayClientMeta{}
|
||||
var expiresAt sql.NullTime
|
||||
if err := rows.Scan(&m.UUID, &m.Name, &m.Email, &m.InboundTag, &expiresAt, &m.MaxConns, &m.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
m.ExpiresAt = &expiresAt.Time
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// startXrayClientExpiryChecker runs a background goroutine that removes expired
|
||||
// Xray clients from both the config file and the database every 5 minutes.
|
||||
func startXrayClientExpiryChecker(store *Store) {
|
||||
if store == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
ctx := context.Background()
|
||||
expired, err := store.ListExpiredXrayClients(ctx)
|
||||
if err != nil {
|
||||
log.Printf("xray expiry checker: list error: %v", err)
|
||||
continue
|
||||
}
|
||||
if len(expired) == 0 {
|
||||
continue
|
||||
}
|
||||
needRestart := false
|
||||
for _, m := range expired {
|
||||
tag := m.InboundTag
|
||||
if tag == "" {
|
||||
_ = 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 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)
|
||||
}
|
||||
if needRestart {
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
log.Printf("xray expiry: restart error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
692
xray_integration.go
Normal file
692
xray_integration.go
Normal file
@@ -0,0 +1,692 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// XrayConfig holds Xray process management settings embedded in the main Config.
|
||||
type XrayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BinPath string `json:"bin_path"` // e.g. /opt/sshpanel/xray
|
||||
ConfigFile string `json:"config_file"` // e.g. /opt/sshpanel/xray_config.json
|
||||
}
|
||||
|
||||
// xrayLogRing is a fixed-capacity circular buffer for captured log lines.
|
||||
type xrayLogRing struct {
|
||||
mu sync.Mutex
|
||||
lines []string
|
||||
pos int
|
||||
}
|
||||
|
||||
const xrayLogCap = 200
|
||||
|
||||
func (r *xrayLogRing) add(line string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.lines) < xrayLogCap {
|
||||
r.lines = append(r.lines, line)
|
||||
} else {
|
||||
r.lines[r.pos] = line
|
||||
r.pos = (r.pos + 1) % xrayLogCap
|
||||
}
|
||||
}
|
||||
|
||||
func (r *xrayLogRing) snapshot() []string {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if len(r.lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(r.lines))
|
||||
if len(r.lines) < xrayLogCap {
|
||||
copy(out, r.lines)
|
||||
} else {
|
||||
n := copy(out, r.lines[r.pos:])
|
||||
copy(out[n:], r.lines[:r.pos])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var xrayLogBuf = &xrayLogRing{}
|
||||
|
||||
// xrayWriter captures writes from the xray subprocess into the log ring buffer
|
||||
// and forwards them to stderr so they appear in the process log.
|
||||
type xrayWriter struct{}
|
||||
|
||||
func (w xrayWriter) Write(p []byte) (int, error) {
|
||||
text := strings.TrimRight(string(p), "\n")
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
if line != "" {
|
||||
xrayLogBuf.add(line)
|
||||
}
|
||||
}
|
||||
return os.Stderr.Write(p)
|
||||
}
|
||||
|
||||
// XrayManager manages the lifecycle of the external xray subprocess.
|
||||
type XrayManager struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
doneCh chan struct{}
|
||||
cfg *XrayConfig
|
||||
startTime time.Time
|
||||
lastErr string
|
||||
}
|
||||
|
||||
var xrayMgr = &XrayManager{}
|
||||
|
||||
// initXrayManager stores the config and auto-starts Xray if Enabled is true.
|
||||
func initXrayManager(cfg *XrayConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
xrayMgr.mu.Lock()
|
||||
xrayMgr.cfg = cfg
|
||||
xrayMgr.mu.Unlock()
|
||||
|
||||
if cfg.Enabled {
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
log.Printf("xray: auto-start failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isRunning returns true if the subprocess is currently alive.
|
||||
// Must be called with m.mu held.
|
||||
func (m *XrayManager) isRunning() bool {
|
||||
if m.doneCh == nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case <-m.doneCh:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Start launches the xray subprocess. Returns an error if already running or misconfigured.
|
||||
func (m *XrayManager) Start() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.isRunning() {
|
||||
return fmt.Errorf("xray already running (pid %d)", m.cmd.Process.Pid)
|
||||
}
|
||||
if m.cfg == nil {
|
||||
return fmt.Errorf("xray not configured")
|
||||
}
|
||||
if _, err := os.Stat(m.cfg.BinPath); err != nil {
|
||||
return fmt.Errorf("xray binary not found at %s", m.cfg.BinPath)
|
||||
}
|
||||
|
||||
args := []string{"run"}
|
||||
if m.cfg.ConfigFile != "" {
|
||||
args = append(args, "-c", m.cfg.ConfigFile)
|
||||
}
|
||||
|
||||
cmd := exec.Command(m.cfg.BinPath, args...)
|
||||
cmd.Stdout = xrayWriter{}
|
||||
cmd.Stderr = xrayWriter{}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
m.lastErr = err.Error()
|
||||
return fmt.Errorf("xray start: %w", err)
|
||||
}
|
||||
|
||||
doneCh := make(chan struct{})
|
||||
m.cmd = cmd
|
||||
m.doneCh = doneCh
|
||||
m.startTime = time.Now()
|
||||
m.lastErr = ""
|
||||
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
close(doneCh)
|
||||
m.mu.Lock()
|
||||
if err != nil {
|
||||
m.lastErr = err.Error()
|
||||
}
|
||||
m.mu.Unlock()
|
||||
log.Printf("xray: process exited: %v", err)
|
||||
}()
|
||||
|
||||
log.Printf("xray: started (pid %d)", cmd.Process.Pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop sends SIGTERM and waits up to 5 s before forcing SIGKILL.
|
||||
func (m *XrayManager) Stop() error {
|
||||
m.mu.Lock()
|
||||
if !m.isRunning() {
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
doneCh := m.doneCh
|
||||
cmd := m.cmd
|
||||
m.mu.Unlock()
|
||||
|
||||
_ = cmd.Process.Signal(syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(5 * time.Second):
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-doneCh:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
log.Printf("xray: stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart stops then starts the xray subprocess.
|
||||
func (m *XrayManager) Restart() error {
|
||||
if err := m.Stop(); err != nil {
|
||||
return err
|
||||
}
|
||||
return m.Start()
|
||||
}
|
||||
|
||||
// XrayStatusDTO is returned by /api/xray/status.
|
||||
type XrayStatusDTO struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Running bool `json:"running"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Status returns a snapshot of the current xray process state.
|
||||
func (m *XrayManager) Status() XrayStatusDTO {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
s := XrayStatusDTO{}
|
||||
if m.cfg != nil {
|
||||
s.Enabled = m.cfg.Enabled
|
||||
}
|
||||
if m.isRunning() && m.cmd != nil && m.cmd.Process != nil {
|
||||
s.Running = true
|
||||
s.PID = m.cmd.Process.Pid
|
||||
s.Uptime = time.Since(m.startTime).Round(time.Second).String()
|
||||
}
|
||||
if m.lastErr != "" {
|
||||
s.Error = m.lastErr
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// GetConfig reads the current xray JSON config file.
|
||||
func (m *XrayManager) GetConfig() ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg == nil || m.cfg.ConfigFile == "" {
|
||||
return nil, fmt.Errorf("xray config file not configured")
|
||||
}
|
||||
return os.ReadFile(m.cfg.ConfigFile)
|
||||
}
|
||||
|
||||
// SetConfig validates and atomically writes a new xray JSON config file.
|
||||
func (m *XrayManager) SetConfig(data []byte) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg == nil || m.cfg.ConfigFile == "" {
|
||||
return fmt.Errorf("xray config file not configured")
|
||||
}
|
||||
if !json.Valid(data) {
|
||||
return fmt.Errorf("invalid JSON")
|
||||
}
|
||||
return os.WriteFile(m.cfg.ConfigFile, data, 0o600)
|
||||
}
|
||||
|
||||
// ---- Admin HTTP handlers ----
|
||||
|
||||
func handleXrayStatus(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(xrayMgr.Status())
|
||||
}
|
||||
|
||||
func handleXrayStart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Start(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleXrayStop(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Stop(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleXrayRestart(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.Restart(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleXrayConfig(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
data, err := xrayMgr.GetConfig()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(data)
|
||||
|
||||
case http.MethodPost:
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 512*1024))
|
||||
if err != nil {
|
||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.SetConfig(body); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func handleXrayLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
lines := xrayLogBuf.snapshot()
|
||||
if lines == nil {
|
||||
lines = []string{}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{"lines": lines})
|
||||
}
|
||||
|
||||
// ---- Inbound / client management ----
|
||||
|
||||
// XrayClientInfo is a single client entry inside an Xray inbound.
|
||||
type XrayClientInfo struct {
|
||||
UUID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Level int `json:"level,omitempty"`
|
||||
// Metadata from PostgreSQL (enriched by handleXrayInbounds)
|
||||
Name string `json:"name,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
ExpirationDays int `json:"expiration_days"`
|
||||
MaxConns int `json:"max_conns"`
|
||||
Expired bool `json:"expired,omitempty"`
|
||||
}
|
||||
|
||||
// XrayInboundInfo is returned by /api/xray/inbounds.
|
||||
type XrayInboundInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port json.RawMessage `json:"port,omitempty"`
|
||||
Listen string `json:"listen,omitempty"`
|
||||
Clients []XrayClientInfo `json:"clients"`
|
||||
}
|
||||
|
||||
// protocols that carry a "clients" array in their settings
|
||||
var xrayClientProtos = map[string]bool{
|
||||
"vless": true, "vmess": true, "trojan": true,
|
||||
}
|
||||
|
||||
// ListInbounds parses the config and returns only inbounds that support client lists.
|
||||
func (m *XrayManager) ListInbounds() ([]XrayInboundInfo, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.cfg == nil || m.cfg.ConfigFile == "" {
|
||||
return nil, fmt.Errorf("xray config file not configured")
|
||||
}
|
||||
data, err := os.ReadFile(m.cfg.ConfigFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg struct {
|
||||
Inbounds []json.RawMessage `json:"inbounds"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse xray config: %w", err)
|
||||
}
|
||||
var result []XrayInboundInfo
|
||||
for _, raw := range cfg.Inbounds {
|
||||
var ib struct {
|
||||
Tag string `json:"tag"`
|
||||
Protocol string `json:"protocol"`
|
||||
Port json.RawMessage `json:"port"`
|
||||
Listen string `json:"listen"`
|
||||
Settings struct {
|
||||
Clients []XrayClientInfo `json:"clients"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &ib); err != nil {
|
||||
continue
|
||||
}
|
||||
if !xrayClientProtos[strings.ToLower(ib.Protocol)] {
|
||||
continue
|
||||
}
|
||||
clients := ib.Settings.Clients
|
||||
if clients == nil {
|
||||
clients = []XrayClientInfo{}
|
||||
}
|
||||
result = append(result, XrayInboundInfo{
|
||||
Tag: ib.Tag,
|
||||
Protocol: strings.ToLower(ib.Protocol),
|
||||
Port: ib.Port,
|
||||
Listen: ib.Listen,
|
||||
Clients: clients,
|
||||
})
|
||||
}
|
||||
if result == nil {
|
||||
result = []XrayInboundInfo{}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// modifyRawConfig reads the config as a generic map, calls fn to mutate it, then writes it back.
|
||||
// Caller must hold m.mu.
|
||||
func (m *XrayManager) modifyRawConfig(fn func(cfg map[string]interface{}) error) error {
|
||||
if m.cfg == nil || m.cfg.ConfigFile == "" {
|
||||
return fmt.Errorf("xray config file not configured")
|
||||
}
|
||||
data, err := os.ReadFile(m.cfg.ConfigFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]interface{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return fmt.Errorf("parse xray config: %w", err)
|
||||
}
|
||||
if err := fn(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(m.cfg.ConfigFile, out, 0o600)
|
||||
}
|
||||
|
||||
// AddXrayClient adds a client to the named inbound and saves the config.
|
||||
func (m *XrayManager) AddXrayClient(inboundTag, uuid, email string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.modifyRawConfig(func(raw map[string]interface{}) error {
|
||||
inbounds, _ := raw["inbounds"].([]interface{})
|
||||
for _, ib := range inbounds {
|
||||
ibMap, ok := ib.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tag, _ := ibMap["tag"].(string); tag != inboundTag {
|
||||
continue
|
||||
}
|
||||
settings, _ := ibMap["settings"].(map[string]interface{})
|
||||
if settings == nil {
|
||||
settings = make(map[string]interface{})
|
||||
ibMap["settings"] = settings
|
||||
}
|
||||
clients, _ := settings["clients"].([]interface{})
|
||||
for _, c := range clients {
|
||||
if cm, ok := c.(map[string]interface{}); ok {
|
||||
if id, _ := cm["id"].(string); id == uuid {
|
||||
return fmt.Errorf("UUID %s already exists in inbound %s", uuid, inboundTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
settings["clients"] = append(clients, map[string]interface{}{
|
||||
"id": uuid, "email": email, "level": 0,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %q not found", inboundTag)
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveXrayClient removes a client by UUID from the named inbound and saves the config.
|
||||
func (m *XrayManager) RemoveXrayClient(inboundTag, uuid string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.modifyRawConfig(func(raw map[string]interface{}) error {
|
||||
inbounds, _ := raw["inbounds"].([]interface{})
|
||||
for _, ib := range inbounds {
|
||||
ibMap, ok := ib.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if tag, _ := ibMap["tag"].(string); tag != inboundTag {
|
||||
continue
|
||||
}
|
||||
settings, _ := ibMap["settings"].(map[string]interface{})
|
||||
if settings == nil {
|
||||
return fmt.Errorf("inbound %s has no settings", inboundTag)
|
||||
}
|
||||
clients, _ := settings["clients"].([]interface{})
|
||||
var kept []interface{}
|
||||
removed := false
|
||||
for _, c := range clients {
|
||||
if cm, ok := c.(map[string]interface{}); ok {
|
||||
if id, _ := cm["id"].(string); id == uuid {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
kept = append(kept, c)
|
||||
}
|
||||
if !removed {
|
||||
return fmt.Errorf("UUID %s not found in inbound %s", uuid, inboundTag)
|
||||
}
|
||||
settings["clients"] = kept
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("inbound %q not found", inboundTag)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- HTTP handlers for inbound/client management ----
|
||||
|
||||
func handleXrayInbounds(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
inbounds, err := xrayMgr.ListInbounds()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Enrich clients with metadata from PostgreSQL when available.
|
||||
if statsStore != nil {
|
||||
metas, err := statsStore.ListAllXrayClients(r.Context())
|
||||
if err == nil {
|
||||
metaMap := make(map[string]*XrayClientMeta, len(metas))
|
||||
for _, m := range metas {
|
||||
metaMap[m.UUID] = m
|
||||
}
|
||||
now := time.Now()
|
||||
for i := range inbounds {
|
||||
for j := range inbounds[i].Clients {
|
||||
c := &inbounds[i].Clients[j]
|
||||
m, ok := metaMap[c.UUID]
|
||||
if !ok {
|
||||
c.ExpirationDays = -1
|
||||
continue
|
||||
}
|
||||
c.Name = m.Name
|
||||
c.ExpiresAt = m.ExpiresAt
|
||||
c.MaxConns = m.MaxConns
|
||||
if m.ExpiresAt == nil {
|
||||
c.ExpirationDays = -1
|
||||
} else if m.ExpiresAt.Before(now) {
|
||||
c.Expired = true
|
||||
c.ExpirationDays = 0
|
||||
} else {
|
||||
c.ExpirationDays = int(m.ExpiresAt.Sub(now).Hours() / 24)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(inbounds)
|
||||
}
|
||||
|
||||
func handleXrayClientAdd(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
InboundTag string `json:"inbound_tag"`
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339 or YYYY-MM-DD or empty
|
||||
MaxConnections int `json:"max_connections"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).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)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.AddXrayClient(req.InboundTag, req.UUID, req.Email); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
InboundTag: req.InboundTag,
|
||||
MaxConns: req.MaxConnections,
|
||||
}
|
||||
if req.ExpiresAt != "" {
|
||||
var t time.Time
|
||||
var err error
|
||||
for _, layout := range []string{time.RFC3339, "2006-01-02T15:04", "2006-01-02"} {
|
||||
t, err = time.Parse(layout, req.ExpiresAt)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
meta.ExpiresAt = &t
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
log.Printf("xray: save meta for %s: %v", req.UUID, err)
|
||||
}
|
||||
}
|
||||
_ = xrayMgr.Restart()
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// handleXrayClientUpdate updates the metadata (name, email, expiry, max_conns)
|
||||
// of an existing Xray client in PostgreSQL without touching the config file.
|
||||
func handleXrayClientUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
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"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.UUID == "" {
|
||||
http.Error(w, "uuid required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if statsStore == nil {
|
||||
http.Error(w, "storage not available", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
meta := XrayClientMeta{
|
||||
UUID: req.UUID,
|
||||
Name: req.Name,
|
||||
Email: req.Email,
|
||||
MaxConns: req.MaxConnections,
|
||||
}
|
||||
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 {
|
||||
meta.ExpiresAt = &t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := statsStore.UpsertXrayClientMeta(r.Context(), meta); err != nil {
|
||||
http.Error(w, "update failed: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func handleXrayClientRemove(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
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)
|
||||
return
|
||||
}
|
||||
if err := xrayMgr.RemoveXrayClient(inboundTag, uuid); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if statsStore != nil {
|
||||
_ = statsStore.DeleteXrayClientMeta(r.Context(), uuid)
|
||||
}
|
||||
_ = xrayMgr.Restart()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
Reference in New Issue
Block a user