This commit is contained in:
2026-05-02 18:42:58 -03:00
commit 6f677d272a
17 changed files with 9658 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

682
auth.go Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

20
go.mod Normal file
View 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
View 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
View 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
View 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

2532
main.go Normal file

File diff suppressed because it is too large Load Diff

117
server_config_api.go Normal file
View 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
View 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
View 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 inprocess 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 nonnil and
// cfg.Listen is nonempty. 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 perclient 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 lengthprefixed payload from r. The length
// prefix is a littleendian 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 littleendian 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
View 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
View 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
View 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)
}