1719 lines
47 KiB
Go
1719 lines
47 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/pion/dtls/v3"
|
|
"github.com/pion/dtls/v3/pkg/crypto/selfsign"
|
|
"golang.org/x/crypto/chacha20poly1305"
|
|
"golang.org/x/crypto/curve25519"
|
|
"golang.org/x/crypto/hkdf"
|
|
|
|
"golang.zx2c4.com/wireguard/conn"
|
|
"golang.zx2c4.com/wireguard/device"
|
|
"golang.zx2c4.com/wireguard/ipc"
|
|
"golang.zx2c4.com/wireguard/tun"
|
|
|
|
dtlsnet "github.com/pion/dtls/v3/pkg/net"
|
|
pionudp "github.com/pion/transport/v4/udp"
|
|
)
|
|
|
|
const (
|
|
wgIfaceName = "wdtt0"
|
|
wgServerAddr = "10.66.66.1"
|
|
wgClientAddr = "10.66.66.2"
|
|
wgClientCIDR = wgClientAddr + "/32"
|
|
wgServerCIDR = wgServerAddr + "/24"
|
|
defaultInternalWGPort = 56001
|
|
dns = "1.1.1.1"
|
|
wgMTU = 1280
|
|
keepalive = 25
|
|
)
|
|
|
|
// ==================== База данных и Бот ====================
|
|
|
|
type ClientDevice struct {
|
|
DeviceID string `json:"device_id"`
|
|
IP string `json:"ip"`
|
|
PrivKey string `json:"priv_key"`
|
|
PubKey string `json:"pub_key"`
|
|
}
|
|
|
|
type PasswordEntry struct {
|
|
DeviceID string `json:"device_id"` // пусто = ещё не привязан
|
|
ExpiresAt int64 `json:"expires_at"` // unix timestamp
|
|
DownBytes int64 `json:"down_bytes"` // скачано клиентом
|
|
UpBytes int64 `json:"up_bytes"` // отдано клиентом
|
|
}
|
|
|
|
// Трафик главного пароля (владельца)
|
|
var (
|
|
mainPassDown int64
|
|
mainPassUp int64
|
|
)
|
|
|
|
// Онлайн-статус устройств
|
|
var (
|
|
activeDevices = make(map[string]int32) // deviceID -> кол-во активных коннектов
|
|
activeDevicesMu sync.Mutex
|
|
)
|
|
|
|
type Database struct {
|
|
MainPassword string `json:"main_password"`
|
|
AdminID string `json:"admin_id"`
|
|
BotToken string `json:"bot_token"`
|
|
Passwords map[string]*PasswordEntry `json:"passwords"`
|
|
Devices map[string]*ClientDevice `json:"devices"`
|
|
}
|
|
|
|
var (
|
|
db *Database
|
|
dbMutex sync.Mutex
|
|
dbFile string
|
|
)
|
|
|
|
var serverWrapKeys = newWrapKeyStore()
|
|
|
|
const (
|
|
passChars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"
|
|
generatedPasswordLen = 16
|
|
maxGeneratedPasswords = 10
|
|
)
|
|
|
|
func generatePassword() string {
|
|
b := make([]byte, generatedPasswordLen)
|
|
randomBytes := make([]byte, len(b))
|
|
if _, err := rand.Read(randomBytes); err != nil {
|
|
now := time.Now().UnixNano()
|
|
for i := range b {
|
|
b[i] = passChars[int(now+int64(i))%len(passChars)]
|
|
}
|
|
return string(b)
|
|
}
|
|
for i, raw := range randomBytes {
|
|
b[i] = passChars[int(raw)%len(passChars)]
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
type wrapKeyEntry struct {
|
|
id string
|
|
key []byte
|
|
}
|
|
|
|
type wrapKeyStore struct {
|
|
mu sync.RWMutex
|
|
entries []wrapKeyEntry
|
|
}
|
|
|
|
func newWrapKeyStore() *wrapKeyStore {
|
|
return &wrapKeyStore{}
|
|
}
|
|
|
|
func deriveWrapKey(password string) ([]byte, error) {
|
|
if password == "" {
|
|
return nil, errors.New("empty password")
|
|
}
|
|
key := make([]byte, wrapKeyLen)
|
|
reader := hkdf.New(
|
|
sha256.New,
|
|
[]byte(password),
|
|
[]byte("WDTT-WRAP-v1"),
|
|
[]byte("rtp-obfs/chacha20poly1305"),
|
|
)
|
|
if _, err := io.ReadFull(reader, key); err != nil {
|
|
return nil, fmt.Errorf("derive wrap key: %w", err)
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func wrapKeyID(password string) string {
|
|
sum := sha256.Sum256([]byte("WDTT-WRAP-ID-v1\x00" + password))
|
|
return hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
func zeroBytes(b []byte) {
|
|
for i := range b {
|
|
b[i] = 0
|
|
}
|
|
}
|
|
|
|
func (s *wrapKeyStore) SetPasswords(mainPassword string, generated []string) error {
|
|
next := make([]wrapKeyEntry, 0, len(generated)+1)
|
|
seen := make(map[string]struct{}, len(generated)+1)
|
|
|
|
if mainPassword != "" {
|
|
key, err := deriveWrapKey(mainPassword)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
next = append(next, wrapKeyEntry{id: "main", key: key})
|
|
seen["main"] = struct{}{}
|
|
}
|
|
|
|
for _, password := range generated {
|
|
if password == "" {
|
|
continue
|
|
}
|
|
id := "pass:" + wrapKeyID(password)
|
|
if _, exists := seen[id]; exists {
|
|
continue
|
|
}
|
|
key, err := deriveWrapKey(password)
|
|
if err != nil {
|
|
for _, entry := range next {
|
|
zeroBytes(entry.key)
|
|
}
|
|
return err
|
|
}
|
|
next = append(next, wrapKeyEntry{id: id, key: key})
|
|
seen[id] = struct{}{}
|
|
}
|
|
|
|
s.mu.Lock()
|
|
old := s.entries
|
|
s.entries = next
|
|
s.mu.Unlock()
|
|
for _, entry := range old {
|
|
zeroBytes(entry.key)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *wrapKeyStore) AddPassword(password string) error {
|
|
key, err := deriveWrapKey(password)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
id := "pass:" + wrapKeyID(password)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for _, entry := range s.entries {
|
|
if entry.id == id {
|
|
zeroBytes(key)
|
|
return nil
|
|
}
|
|
}
|
|
s.entries = append(s.entries, wrapKeyEntry{id: id, key: key})
|
|
return nil
|
|
}
|
|
|
|
func (s *wrapKeyStore) RemovePassword(password string) {
|
|
id := "pass:" + wrapKeyID(password)
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i, entry := range s.entries {
|
|
if entry.id != id {
|
|
continue
|
|
}
|
|
zeroBytes(entry.key)
|
|
copy(s.entries[i:], s.entries[i+1:])
|
|
s.entries[len(s.entries)-1] = wrapKeyEntry{}
|
|
s.entries = s.entries[:len(s.entries)-1]
|
|
return
|
|
}
|
|
}
|
|
|
|
func (s *wrapKeyStore) Count() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.entries)
|
|
}
|
|
|
|
func (s *wrapKeyStore) Unwrap(raw, dst []byte) ([]byte, int, error) {
|
|
if !obfsIsRTPPacket(raw) {
|
|
return nil, 0, errors.New("wrap: non-obfs packet")
|
|
}
|
|
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if len(s.entries) == 0 {
|
|
return nil, 0, errors.New("wrap: no active keys")
|
|
}
|
|
for _, entry := range s.entries {
|
|
m, err := obfsUnwrapPacket(entry.key, raw, dst)
|
|
if err == nil {
|
|
return append([]byte(nil), entry.key...), m, nil
|
|
}
|
|
}
|
|
return nil, 0, errors.New("wrap: auth failed")
|
|
}
|
|
|
|
func refreshWrapKeysFromDBLocked() error {
|
|
passwords := make([]string, 0, len(db.Passwords))
|
|
for password, entry := range db.Passwords {
|
|
if !isPasswordExpired(entry) {
|
|
passwords = append(passwords, password)
|
|
}
|
|
}
|
|
return serverWrapKeys.SetPasswords(db.MainPassword, passwords)
|
|
}
|
|
|
|
func initDB(dir, mainPass, adminID, botToken string) {
|
|
dbFile = filepath.Join(dir, "passwords.json")
|
|
db = &Database{
|
|
Passwords: make(map[string]*PasswordEntry),
|
|
Devices: make(map[string]*ClientDevice),
|
|
}
|
|
data, err := os.ReadFile(dbFile)
|
|
if err == nil {
|
|
json.Unmarshal(data, db)
|
|
}
|
|
if db.Passwords == nil {
|
|
db.Passwords = make(map[string]*PasswordEntry)
|
|
}
|
|
if db.Devices == nil {
|
|
db.Devices = make(map[string]*ClientDevice)
|
|
}
|
|
db.MainPassword = mainPass
|
|
db.AdminID = adminID
|
|
db.BotToken = botToken
|
|
saveDB()
|
|
if err := refreshWrapKeysFromDBLocked(); err != nil {
|
|
log.Fatalf("[WRAP] init keys: %v", err)
|
|
}
|
|
}
|
|
|
|
func saveDB() {
|
|
data, _ := json.MarshalIndent(db, "", " ")
|
|
os.WriteFile(dbFile, data, 0600)
|
|
}
|
|
|
|
func isPasswordExpired(entry *PasswordEntry) bool {
|
|
if entry == nil {
|
|
return true
|
|
}
|
|
if entry.ExpiresAt == 0 {
|
|
return false // бессрочный
|
|
}
|
|
return time.Now().Unix() > entry.ExpiresAt
|
|
}
|
|
|
|
func getNextIP() string {
|
|
used := make(map[string]bool)
|
|
for _, dev := range db.Devices {
|
|
used[dev.IP] = true
|
|
}
|
|
for i := 2; i <= 250; i++ {
|
|
ip := fmt.Sprintf("10.66.66.%d", i)
|
|
if !used[ip] {
|
|
return ip
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func botLoop(token string, adminIDstr string, wgDev *device.Device) {
|
|
if token == "" || adminIDstr == "" {
|
|
return
|
|
}
|
|
adminID, _ := strconv.ParseInt(adminIDstr, 10, 64)
|
|
if adminID == 0 {
|
|
return
|
|
}
|
|
|
|
// Устанавливаем команды для синей кнопки Menu
|
|
go func() {
|
|
cmds := `{"commands":[{"command":"new","description":"Создать временный пароль"},{"command":"list","description":"Управление доступами"}]}`
|
|
resp, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/setMyCommands", token), "application/json", strings.NewReader(cmds))
|
|
if err == nil {
|
|
resp.Body.Close()
|
|
}
|
|
}()
|
|
|
|
offset := 0
|
|
client := &http.Client{Timeout: 65 * time.Second}
|
|
|
|
// Состояние ожидания ввода дней
|
|
var waitingForDays bool
|
|
|
|
for {
|
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?timeout=60&offset=%d", token, offset)
|
|
resp, err := client.Get(url)
|
|
if err != nil {
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
var res struct {
|
|
Ok bool `json:"ok"`
|
|
Result []struct {
|
|
UpdateID int `json:"update_id"`
|
|
Message *struct {
|
|
Chat struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"chat"`
|
|
Text string `json:"text"`
|
|
} `json:"message"`
|
|
CallbackQuery *struct {
|
|
ID string `json:"id"`
|
|
Data string `json:"data"`
|
|
Message struct {
|
|
MessageID int `json:"message_id"`
|
|
Chat struct {
|
|
ID int64 `json:"id"`
|
|
} `json:"chat"`
|
|
} `json:"message"`
|
|
} `json:"callback_query"`
|
|
} `json:"result"`
|
|
}
|
|
|
|
err = json.NewDecoder(resp.Body).Decode(&res)
|
|
resp.Body.Close()
|
|
if err != nil {
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
for _, u := range res.Result {
|
|
offset = u.UpdateID + 1
|
|
|
|
// ═══ Callback кнопки ═══
|
|
if u.CallbackQuery != nil && u.CallbackQuery.Message.Chat.ID == adminID {
|
|
data := u.CallbackQuery.Data
|
|
answerCallback(token, u.CallbackQuery.ID)
|
|
|
|
if strings.HasPrefix(data, "viewpass_") {
|
|
// Просмотр деталей пароля
|
|
pass := strings.TrimPrefix(data, "viewpass_")
|
|
dbMutex.Lock()
|
|
entry, exists := db.Passwords[pass]
|
|
if !exists || entry == nil {
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, "❌ Пароль не найден", nil)
|
|
continue
|
|
}
|
|
txt := fmt.Sprintf("🔑 *Пароль:* `%s`\n", pass)
|
|
if entry.ExpiresAt > 0 {
|
|
expireTime := time.Unix(entry.ExpiresAt, 0)
|
|
remaining := time.Until(expireTime)
|
|
if remaining > 0 {
|
|
txt += fmt.Sprintf("⏰ Истекает: %s (через %dd)\n", expireTime.Format("02.01.2006"), int(remaining.Hours()/24))
|
|
} else {
|
|
txt += "⏰ *ИСТЁК* ❌\n"
|
|
}
|
|
} else {
|
|
txt += "⏰ Бессрочный ♾\n"
|
|
}
|
|
txt += "\n📱 *Привязанное устройство:*\n"
|
|
var kb []map[string]interface{}
|
|
if entry.DeviceID == "" {
|
|
txt += "_Ожидает первого подключения..._\n"
|
|
} else {
|
|
dev, devExists := db.Devices[entry.DeviceID]
|
|
if devExists {
|
|
txt += fmt.Sprintf("• ID: `%s`\n• IP: `%s`\n", entry.DeviceID, dev.IP)
|
|
} else {
|
|
txt += fmt.Sprintf("• ID: `%s` (устройство удалено)\n", entry.DeviceID)
|
|
}
|
|
kb = append(kb, map[string]interface{}{
|
|
"text": "🗑 Отвязать устройство",
|
|
"callback_data": "unbind_" + pass,
|
|
})
|
|
}
|
|
dbMutex.Unlock()
|
|
kb = append(kb, map[string]interface{}{
|
|
"text": "❌ Удалить пароль",
|
|
"callback_data": "delpass_" + pass,
|
|
})
|
|
kb = append(kb, map[string]interface{}{
|
|
"text": "◀️ Назад к списку",
|
|
"callback_data": "backlist",
|
|
})
|
|
var keyboard [][]map[string]interface{}
|
|
for _, btn := range kb {
|
|
keyboard = append(keyboard, []map[string]interface{}{btn})
|
|
}
|
|
sendTelegram(token, adminID, txt, map[string]interface{}{"inline_keyboard": keyboard})
|
|
|
|
} else if strings.HasPrefix(data, "unbind_") {
|
|
pass := strings.TrimPrefix(data, "unbind_")
|
|
dbMutex.Lock()
|
|
entry, exists := db.Passwords[pass]
|
|
if exists && entry != nil && entry.DeviceID != "" {
|
|
// Удаляем устройство из WG и из хранилища
|
|
dev, devExists := db.Devices[entry.DeviceID]
|
|
if devExists {
|
|
pubHex, _ := b64ToHex(dev.PubKey)
|
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
|
|
delete(db.Devices, entry.DeviceID)
|
|
}
|
|
entry.DeviceID = ""
|
|
saveDB()
|
|
}
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, fmt.Sprintf("✅ Устройство отвязано от пароля `%s`", pass), nil)
|
|
|
|
} else if strings.HasPrefix(data, "delpass_") {
|
|
pass := strings.TrimPrefix(data, "delpass_")
|
|
dbMutex.Lock()
|
|
entry, exists := db.Passwords[pass]
|
|
if exists && entry != nil && entry.DeviceID != "" {
|
|
dev, devExists := db.Devices[entry.DeviceID]
|
|
if devExists {
|
|
pubHex, _ := b64ToHex(dev.PubKey)
|
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
|
|
delete(db.Devices, entry.DeviceID)
|
|
}
|
|
}
|
|
delete(db.Passwords, pass)
|
|
serverWrapKeys.RemovePassword(pass)
|
|
saveDB()
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, fmt.Sprintf("✅ Пароль `%s` и его устройство удалены", pass), nil)
|
|
|
|
} else if strings.HasPrefix(data, "deldev_") {
|
|
devID := strings.TrimPrefix(data, "deldev_")
|
|
dbMutex.Lock()
|
|
dev, exists := db.Devices[devID]
|
|
if exists {
|
|
delete(db.Devices, devID)
|
|
pubHex, _ := b64ToHex(dev.PubKey)
|
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
|
|
// Очищаем привязку из пароля
|
|
for _, entry := range db.Passwords {
|
|
if entry != nil && entry.DeviceID == devID {
|
|
entry.DeviceID = ""
|
|
}
|
|
}
|
|
saveDB()
|
|
}
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, fmt.Sprintf("✅ Устройство `%s` удалено", devID), nil)
|
|
|
|
} else if data == "backlist" {
|
|
sendPasswordList(token, adminID, wgDev)
|
|
}
|
|
}
|
|
|
|
// ═══ Текстовые команды ═══
|
|
msg := u.Message
|
|
if msg == nil || msg.Chat.ID != adminID {
|
|
continue
|
|
}
|
|
|
|
cmd := strings.TrimSpace(msg.Text)
|
|
|
|
// Обработка ввода количества дней
|
|
if waitingForDays {
|
|
waitingForDays = false
|
|
days, parseErr := strconv.Atoi(cmd)
|
|
if parseErr != nil || days < 1 || days > 365 {
|
|
sendTelegram(token, adminID, "❌ Неверное значение. Укажите число от 1 до 365, или отправьте /new заново.", nil)
|
|
continue
|
|
}
|
|
expiresAt := time.Now().Add(time.Duration(days) * 24 * time.Hour).Unix()
|
|
dbMutex.Lock()
|
|
if cleanupExpiredPasswordsLocked(wgDev) > 0 {
|
|
saveDB()
|
|
}
|
|
if len(db.Passwords) >= maxGeneratedPasswords {
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, fmt.Sprintf("❌ Лимит паролей: максимум %d активных. Удалите ненужный пароль через /list.", maxGeneratedPasswords), nil)
|
|
continue
|
|
}
|
|
newPass := ""
|
|
for i := 0; i < 10; i++ {
|
|
candidate := generatePassword()
|
|
if _, exists := db.Passwords[candidate]; !exists {
|
|
newPass = candidate
|
|
break
|
|
}
|
|
}
|
|
if newPass == "" {
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, "❌ Не удалось создать уникальный пароль. Повторите /new.", nil)
|
|
continue
|
|
}
|
|
if err := serverWrapKeys.AddPassword(newPass); err != nil {
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, "❌ Не удалось создать WRAP-ключ для пароля. Повторите /new.", nil)
|
|
continue
|
|
}
|
|
db.Passwords[newPass] = &PasswordEntry{ExpiresAt: expiresAt}
|
|
saveDB()
|
|
dbMutex.Unlock()
|
|
expDate := time.Unix(expiresAt, 0).Format("02.01.2006")
|
|
sendTelegram(token, adminID, fmt.Sprintf("🔑 Новый пароль:\n`%s`\n\n⏰ Действует %d дн. (до %s)\n📱 Ожидает первого подключения", newPass, days, expDate), nil)
|
|
continue
|
|
}
|
|
|
|
if cmd == "/start" || cmd == "/help" {
|
|
sendTelegram(token, adminID, "🤖 *WDTT VPN Manager*\n\n/new — Создать пароль\n/list — Список паролей", nil)
|
|
|
|
} else if cmd == "/new" {
|
|
dbMutex.Lock()
|
|
if cleanupExpiredPasswordsLocked(wgDev) > 0 {
|
|
saveDB()
|
|
}
|
|
if len(db.Passwords) >= maxGeneratedPasswords {
|
|
dbMutex.Unlock()
|
|
sendTelegram(token, adminID, fmt.Sprintf("❌ Лимит паролей: максимум %d активных. Удалите ненужный пароль через /list.", maxGeneratedPasswords), nil)
|
|
continue
|
|
}
|
|
dbMutex.Unlock()
|
|
waitingForDays = true
|
|
sendTelegram(token, adminID, "📅 Введите срок действия пароля в днях (1–365):\n\n_Примеры: 30 = месяц, 365 = год_", nil)
|
|
|
|
} else if cmd == "/list" {
|
|
sendPasswordList(token, adminID, wgDev)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func removePeerFromWG(wgDev *device.Device, dev *ClientDevice) {
|
|
if wgDev == nil || dev == nil || dev.PubKey == "" {
|
|
return
|
|
}
|
|
pubHex, err := b64ToHex(dev.PubKey)
|
|
if err != nil {
|
|
return
|
|
}
|
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nremove=true\n", pubHex))
|
|
}
|
|
|
|
func upsertPeerInWG(wgDev *device.Device, dev *ClientDevice) {
|
|
if wgDev == nil || dev == nil || dev.PubKey == "" || dev.IP == "" {
|
|
return
|
|
}
|
|
pubHex, err := b64ToHex(dev.PubKey)
|
|
if err != nil {
|
|
return
|
|
}
|
|
wgDev.IpcSet(fmt.Sprintf("public_key=%s\nallowed_ip=%s/32\n", pubHex, dev.IP))
|
|
}
|
|
|
|
func cleanupExpiredPasswordsLocked(wgDev *device.Device) int {
|
|
removed := 0
|
|
for p, entry := range db.Passwords {
|
|
if isPasswordExpired(entry) {
|
|
if entry != nil && entry.DeviceID != "" {
|
|
removePeerFromWG(wgDev, db.Devices[entry.DeviceID])
|
|
delete(db.Devices, entry.DeviceID)
|
|
}
|
|
delete(db.Passwords, p)
|
|
serverWrapKeys.RemovePassword(p)
|
|
removed++
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
func cleanupExpiredPasswords(wgDev *device.Device) int {
|
|
dbMutex.Lock()
|
|
defer dbMutex.Unlock()
|
|
removed := cleanupExpiredPasswordsLocked(wgDev)
|
|
if removed > 0 {
|
|
saveDB()
|
|
}
|
|
return removed
|
|
}
|
|
|
|
func expiredPasswordJanitor(ctx context.Context, wgDev *device.Device) {
|
|
ticker := time.NewTicker(1 * time.Hour)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if removed := cleanupExpiredPasswords(wgDev); removed > 0 {
|
|
log.Printf("[DB] Удалено истёкших паролей: %d", removed)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func syncPersistedPeersToWG(wgDev *device.Device) {
|
|
dbMutex.Lock()
|
|
defer dbMutex.Unlock()
|
|
count := 0
|
|
for _, dev := range db.Devices {
|
|
upsertPeerInWG(wgDev, dev)
|
|
count++
|
|
}
|
|
if count > 0 {
|
|
log.Printf("[WG] Восстановлено сохранённых устройств: %d", count)
|
|
}
|
|
}
|
|
|
|
func sendPasswordList(token string, adminID int64, wgDev *device.Device) {
|
|
dbMutex.Lock()
|
|
defer dbMutex.Unlock()
|
|
|
|
// Очистка истёкших
|
|
if cleanupExpiredPasswordsLocked(wgDev) > 0 {
|
|
saveDB()
|
|
}
|
|
|
|
txt := "🔐 *Пароли:*\n\n"
|
|
txt += fmt.Sprintf("🔒 Главный: `%s` (владелец)\n\n", db.MainPassword)
|
|
|
|
var inlineKb []map[string]interface{}
|
|
|
|
if len(db.Passwords) == 0 {
|
|
txt += "_Нет сгенерированных паролей._\n"
|
|
} else {
|
|
txt += fmt.Sprintf("_Активно: %d/%d_\n\n", len(db.Passwords), maxGeneratedPasswords)
|
|
for p, entry := range db.Passwords {
|
|
status := "🟢"
|
|
if entry.DeviceID != "" {
|
|
status = "🔗"
|
|
}
|
|
expiry := "♾"
|
|
if entry.ExpiresAt > 0 {
|
|
remaining := time.Until(time.Unix(entry.ExpiresAt, 0))
|
|
if remaining > 0 {
|
|
expiry = fmt.Sprintf("%dd", int(remaining.Hours()/24)+1)
|
|
} else {
|
|
expiry = "❌"
|
|
}
|
|
}
|
|
txt += fmt.Sprintf("%s `%s` (%s)\n", status, p, expiry)
|
|
inlineKb = append(inlineKb, map[string]interface{}{
|
|
"text": "🔍 " + p,
|
|
"callback_data": "viewpass_" + p,
|
|
})
|
|
}
|
|
}
|
|
|
|
txt += "\n🟢 = свободен | 🔗 = привязан"
|
|
|
|
var replyMarkup interface{}
|
|
if len(inlineKb) > 0 {
|
|
var keyboard [][]map[string]interface{}
|
|
for _, btn := range inlineKb {
|
|
keyboard = append(keyboard, []map[string]interface{}{btn})
|
|
}
|
|
replyMarkup = map[string]interface{}{"inline_keyboard": keyboard}
|
|
}
|
|
sendTelegram(token, adminID, txt, replyMarkup)
|
|
}
|
|
|
|
func answerCallback(token, callbackID string) {
|
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/answerCallbackQuery", token)
|
|
payload := map[string]interface{}{"callback_query_id": callbackID}
|
|
body, _ := json.Marshal(payload)
|
|
http.Post(url, "application/json", bytes.NewBuffer(body))
|
|
}
|
|
|
|
func maskPassword(pass string) string {
|
|
if len(pass) <= 3 {
|
|
return pass
|
|
}
|
|
return pass[:3] + "****"
|
|
}
|
|
|
|
func sendTelegram(token string, chatID int64, text string, replyMarkup interface{}) {
|
|
url := fmt.Sprintf("https://api.telegram.org/bot%s/sendMessage", token)
|
|
payload := map[string]interface{}{
|
|
"chat_id": chatID,
|
|
"text": text,
|
|
"parse_mode": "Markdown",
|
|
}
|
|
if replyMarkup != nil {
|
|
payload["reply_markup"] = replyMarkup
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
http.Post(url, "application/json", bytes.NewBuffer(body))
|
|
}
|
|
|
|
// ==================== Пул буферов ====================
|
|
|
|
var bufPool = sync.Pool{
|
|
New: func() interface{} {
|
|
b := make([]byte, 1600)
|
|
return &b
|
|
},
|
|
}
|
|
|
|
func getBuf() *[]byte { return bufPool.Get().(*[]byte) }
|
|
func putBuf(b *[]byte) { bufPool.Put(b) }
|
|
|
|
// ==================== Оптимизация ====================
|
|
|
|
func enableBBR() {
|
|
log.Println("[SYS] Оптимизация TCP...")
|
|
out, _ := runCmd("bash", "-c", "sysctl net.ipv4.tcp_congestion_control")
|
|
if strings.Contains(out, "bbr") {
|
|
log.Println("[SYS] BBR уже активен ✓")
|
|
return
|
|
}
|
|
cmds := [][]string{
|
|
{"sysctl", "-w", "net.core.default_qdisc=fq"},
|
|
{"sysctl", "-w", "net.ipv4.tcp_congestion_control=bbr"},
|
|
{"sysctl", "-w", "net.core.rmem_max=25165824"},
|
|
{"sysctl", "-w", "net.core.wmem_max=25165824"},
|
|
{"sysctl", "-w", "net.ipv4.tcp_rmem=4096 87380 25165824"},
|
|
{"sysctl", "-w", "net.ipv4.tcp_wmem=4096 65536 25165824"},
|
|
}
|
|
for _, cmd := range cmds {
|
|
runCmd(cmd[0], cmd[1:]...)
|
|
}
|
|
log.Println("[SYS] BBR включен ✓")
|
|
}
|
|
|
|
// ==================== Статистика ====================
|
|
|
|
var (
|
|
totalBytesFromClient int64
|
|
totalBytesToClient int64
|
|
activeConns int32
|
|
totalConns int64
|
|
natType string = "Инициализация..."
|
|
serverStartTime time.Time
|
|
)
|
|
|
|
func statsLoop(ctx context.Context, configDir string) {
|
|
serverStartTime = time.Now()
|
|
statsFile := filepath.Join(configDir, "server.log")
|
|
ticker := time.NewTicker(10 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
fromC := atomic.LoadInt64(&totalBytesFromClient)
|
|
toC := atomic.LoadInt64(&totalBytesToClient)
|
|
active := atomic.LoadInt32(&activeConns)
|
|
total := atomic.LoadInt64(&totalConns)
|
|
uptime := time.Since(serverStartTime)
|
|
|
|
log.Printf("[СТАТ] Активных: %d | Всего: %d | NAT: %s | ↑%.2f МБ | ↓%.2f МБ",
|
|
active, total, natType,
|
|
float64(fromC)/1024/1024,
|
|
float64(toC)/1024/1024,
|
|
)
|
|
|
|
// Пишем server.log
|
|
dbMutex.Lock()
|
|
numPasswords := len(db.Passwords)
|
|
numDevices := len(db.Devices)
|
|
dbMutex.Unlock()
|
|
|
|
uptimeStr := formatUptime(uptime)
|
|
downGB := float64(toC) / (1024 * 1024 * 1024)
|
|
upGB := float64(fromC) / (1024 * 1024 * 1024)
|
|
|
|
statsJSON, _ := json.Marshal(map[string]interface{}{
|
|
"active": active,
|
|
"total": total,
|
|
"nat": natType,
|
|
"uptime": uptimeStr,
|
|
"down_gb": fmt.Sprintf("%.2f", downGB),
|
|
"up_gb": fmt.Sprintf("%.2f", upGB),
|
|
"passwords": numPasswords,
|
|
"devices": numDevices,
|
|
"timestamp": time.Now().Unix(),
|
|
})
|
|
os.WriteFile(statsFile, statsJSON, 0644)
|
|
}
|
|
}
|
|
}
|
|
|
|
func formatUptime(d time.Duration) string {
|
|
days := int(d.Hours()) / 24
|
|
hours := int(d.Hours()) % 24
|
|
mins := int(d.Minutes()) % 60
|
|
if days > 0 {
|
|
return fmt.Sprintf("%dд %dч %dм", days, hours, mins)
|
|
}
|
|
if hours > 0 {
|
|
return fmt.Sprintf("%dч %dм", hours, mins)
|
|
}
|
|
return fmt.Sprintf("%dм", mins)
|
|
}
|
|
|
|
// ==================== Утилиты ====================
|
|
|
|
func runCmd(name string, args ...string) (string, error) {
|
|
out, err := exec.Command(name, args...).CombinedOutput()
|
|
return strings.TrimSpace(string(out)), err
|
|
}
|
|
|
|
func runCmdSilent(name string, args ...string) string {
|
|
out, _ := exec.Command(name, args...).CombinedOutput()
|
|
return strings.TrimSpace(string(out))
|
|
}
|
|
|
|
func commandExists(name string) bool {
|
|
_, err := exec.LookPath(name)
|
|
return err == nil
|
|
}
|
|
|
|
func isNetTimeout(err error) bool {
|
|
ne, ok := err.(net.Error)
|
|
return ok && ne.Timeout()
|
|
}
|
|
|
|
func getDefaultInterface() string {
|
|
out := runCmdSilent("bash", "-c", "ip route show default | awk '/default/ {print $5}' | head -1")
|
|
if out != "" {
|
|
return strings.TrimSpace(out)
|
|
}
|
|
out = runCmdSilent("bash", "-c", "ip -o link show | awk -F': ' '{print $2}' | grep -v -E 'lo|wg|tun|wdtt' | head -1")
|
|
if out != "" {
|
|
return strings.TrimSpace(out)
|
|
}
|
|
return "eth0"
|
|
}
|
|
|
|
// ==================== Ключи ====================
|
|
|
|
type wgKeys struct {
|
|
serverPrivate, serverPublic, clientPrivate, clientPublic string
|
|
}
|
|
|
|
func b64ToHex(s string) (string, error) {
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(b) != 32 {
|
|
return "", fmt.Errorf("key length %d != 32", len(b))
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
func generateKeyPair() (privB64, pubB64 string, err error) {
|
|
var priv [32]byte
|
|
if _, err := rand.Read(priv[:]); err != nil {
|
|
return "", "", err
|
|
}
|
|
priv[0] &= 248
|
|
priv[31] = (priv[31] & 127) | 64
|
|
pub, err := curve25519.X25519(priv[:], curve25519.Basepoint)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(priv[:]),
|
|
base64.StdEncoding.EncodeToString(pub), nil
|
|
}
|
|
|
|
func loadOrGenerateKeys(dir string) (*wgKeys, error) {
|
|
f := filepath.Join(dir, "wg-keys.dat")
|
|
if data, err := os.ReadFile(f); err == nil {
|
|
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
|
if len(lines) >= 4 {
|
|
keys := &wgKeys{
|
|
serverPrivate: strings.TrimSpace(lines[0]),
|
|
serverPublic: strings.TrimSpace(lines[1]),
|
|
clientPrivate: strings.TrimSpace(lines[2]),
|
|
clientPublic: strings.TrimSpace(lines[3]),
|
|
}
|
|
for _, k := range []string{keys.serverPrivate, keys.serverPublic,
|
|
keys.clientPrivate, keys.clientPublic} {
|
|
if _, err := b64ToHex(k); err != nil {
|
|
goto generate
|
|
}
|
|
}
|
|
log.Printf("[WG] Ключи загружены из %s", f)
|
|
return keys, nil
|
|
}
|
|
}
|
|
generate:
|
|
log.Println("[WG] Генерирую новые ключи...")
|
|
sPriv, sPub, err := generateKeyPair()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cPriv, cPub, err := generateKeyPair()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keys := &wgKeys{sPriv, sPub, cPriv, cPub}
|
|
os.MkdirAll(dir, 0700)
|
|
os.WriteFile(f, []byte(fmt.Sprintf("%s\n%s\n%s\n%s\n",
|
|
keys.serverPrivate, keys.serverPublic,
|
|
keys.clientPrivate, keys.clientPublic)), 0600)
|
|
log.Printf("[WG] Ключи сохранены в %s", f)
|
|
return keys, nil
|
|
}
|
|
|
|
// ==================== NAT ====================
|
|
|
|
func setupFullConeNAT(wgIface string) error {
|
|
log.Println("[NAT] ══════════════════════════════════════")
|
|
|
|
os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1"), 0644)
|
|
|
|
extIface := getDefaultInterface()
|
|
log.Printf("[NAT] Внешний: %s", extIface)
|
|
|
|
switch {
|
|
case commandExists("iptables"):
|
|
for i := 0; i < 5; i++ {
|
|
exec.Command("iptables", "-t", "nat", "-D", "POSTROUTING", "-s", wgServerCIDR, "-o", extIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "MASQUERADE").Run()
|
|
}
|
|
exec.Command("iptables", "-t", "nat", "-I", "POSTROUTING", "1", "-s", wgServerCIDR, "-o", extIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "MASQUERADE").Run()
|
|
natType = "MASQUERADE iptables ✅"
|
|
setupForwardRules(wgIface)
|
|
case commandExists("nft"):
|
|
setupNftNAT(extIface)
|
|
natType = "MASQUERADE nft ✅"
|
|
setupForwardRules(wgIface)
|
|
default:
|
|
natType = "NAT не настроен: нет iptables/nft"
|
|
log.Printf("[NAT] WARNING: %s", natType)
|
|
}
|
|
|
|
log.Printf("[NAT] Режим: %s", natType)
|
|
log.Println("[NAT] ══════════════════════════════════════")
|
|
return nil
|
|
}
|
|
|
|
func setupNftNAT(extIface string) {
|
|
exec.Command("nft", "add", "table", "ip", "wdtt").Run()
|
|
exec.Command("nft", "add", "chain", "ip", "wdtt", "postrouting", "{ type nat hook postrouting priority 100; }").Run()
|
|
exec.Command("nft", "add", "rule", "ip", "wdtt", "postrouting", "ip", "saddr", wgServerCIDR, "oifname", extIface, "masquerade").Run()
|
|
}
|
|
|
|
func setupForwardRules(wgIface string) {
|
|
if commandExists("iptables") {
|
|
for i := 0; i < 5; i++ {
|
|
exec.Command("iptables", "-D", "FORWARD", "-i", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run()
|
|
exec.Command("iptables", "-D", "FORWARD", "-o", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run()
|
|
}
|
|
exec.Command("iptables", "-A", "FORWARD", "-i", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run()
|
|
exec.Command("iptables", "-A", "FORWARD", "-o", wgIface, "-m", "comment", "--comment", "WDTT_MANAGED", "-j", "ACCEPT").Run()
|
|
return
|
|
}
|
|
if commandExists("nft") {
|
|
exec.Command("nft", "add", "table", "inet", "wdtt").Run()
|
|
exec.Command("nft", "add", "chain", "inet", "wdtt", "forward", "{ type filter hook forward priority 0; policy accept; }").Run()
|
|
exec.Command("nft", "add", "rule", "inet", "wdtt", "forward", "iifname", wgIface, "accept").Run()
|
|
exec.Command("nft", "add", "rule", "inet", "wdtt", "forward", "oifname", wgIface, "accept").Run()
|
|
}
|
|
}
|
|
|
|
// ==================== WireGuard ====================
|
|
|
|
func startUserspaceWG(keys *wgKeys, wgPort int) (*device.Device, error) {
|
|
runCmdSilent("ip", "link", "del", wgIfaceName)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
tunDev, err := tun.CreateTUN(wgIfaceName, wgMTU)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("CreateTUN: %w", err)
|
|
}
|
|
|
|
ifaceName, err := tunDev.Name()
|
|
if err != nil {
|
|
tunDev.Close()
|
|
return nil, fmt.Errorf("TUN name: %w", err)
|
|
}
|
|
|
|
logger := device.NewLogger(device.LogLevelError, "[WG] ")
|
|
bind := conn.NewDefaultBind()
|
|
dev := device.NewDevice(tunDev, bind, logger)
|
|
|
|
serverPrivHex, _ := b64ToHex(keys.serverPrivate)
|
|
|
|
if err := dev.IpcSet(fmt.Sprintf(
|
|
"private_key=%s\nlisten_port=%d\n",
|
|
serverPrivHex, wgPort,
|
|
)); err != nil {
|
|
dev.Close()
|
|
return nil, fmt.Errorf("IpcSet: %w", err)
|
|
}
|
|
|
|
for _, d := range db.Devices {
|
|
pubHex, _ := b64ToHex(d.PubKey)
|
|
if pubHex != "" {
|
|
dev.IpcSet(fmt.Sprintf("public_key=%s\nallowed_ip=%s/32\n", pubHex, d.IP))
|
|
}
|
|
}
|
|
|
|
if err := dev.Up(); err != nil {
|
|
dev.Close()
|
|
return nil, fmt.Errorf("device.Up: %w", err)
|
|
}
|
|
|
|
if err := configureInterface(ifaceName); err != nil {
|
|
dev.Close()
|
|
return nil, err
|
|
}
|
|
|
|
if err := setupFullConeNAT(ifaceName); err != nil {
|
|
dev.Close()
|
|
return nil, err
|
|
}
|
|
|
|
go func() {
|
|
uapiFile, err := ipc.UAPIOpen(ifaceName)
|
|
if err != nil {
|
|
return
|
|
}
|
|
uapi, err := ipc.UAPIListen(ifaceName, uapiFile)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer uapi.Close()
|
|
for {
|
|
c, err := uapi.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
go dev.IpcHandle(c)
|
|
}
|
|
}()
|
|
|
|
log.Printf("[WG] Запущен на порту %d", wgPort)
|
|
return dev, nil
|
|
}
|
|
|
|
func configureInterface(ifaceName string) error {
|
|
for _, cmd := range [][]string{
|
|
{"ip", "addr", "add", wgServerCIDR, "dev", ifaceName},
|
|
{"ip", "link", "set", "mtu", fmt.Sprintf("%d", wgMTU), "dev", ifaceName},
|
|
{"ip", "link", "set", ifaceName, "up"},
|
|
} {
|
|
out, err := runCmd(cmd[0], cmd[1:]...)
|
|
if err != nil && !strings.Contains(out, "File exists") {
|
|
return fmt.Errorf("%s: %s", strings.Join(cmd, " "), out)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildClientConfig(serverPublic, clientPrivate, clientIP, clientPort string) string {
|
|
return fmt.Sprintf(`[Interface]
|
|
PrivateKey = %s
|
|
Address = %s/32
|
|
DNS = %s
|
|
MTU = %d
|
|
|
|
[Peer]
|
|
PublicKey = %s
|
|
AllowedIPs = 0.0.0.0/0
|
|
Endpoint = 127.0.0.1:%s
|
|
PersistentKeepalive = %d`,
|
|
clientPrivate, clientIP, dns, wgMTU,
|
|
serverPublic, clientPort, keepalive,
|
|
)
|
|
}
|
|
|
|
// ==================== Main ====================
|
|
|
|
func main() {
|
|
listen := flag.String("listen", "0.0.0.0:56000", "DTLS адрес")
|
|
wgPort := flag.Int("wg-port", defaultInternalWGPort, "WireGuard UDP порт")
|
|
configDir := flag.String("config-dir", "/etc/wdtt", "директория конфигурации")
|
|
mainPass := flag.String("password", "", "пароль владельца")
|
|
adminID := flag.String("admin", "", "Telegram Admin ID")
|
|
botToken := flag.String("bot-token", "", "Telegram Bot Token")
|
|
flag.Parse()
|
|
|
|
log.SetFlags(log.Ldate | log.Ltime | log.Lmicroseconds)
|
|
log.Println("══════════════════════════════════════════")
|
|
log.Println(" WDTT Server v2 (Multi-User)")
|
|
log.Println("══════════════════════════════════════════")
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT)
|
|
go func() {
|
|
<-sig
|
|
cancel()
|
|
time.Sleep(2 * time.Second)
|
|
os.Exit(0)
|
|
}()
|
|
|
|
initDB(*configDir, *mainPass, *adminID, *botToken)
|
|
|
|
keys, err := loadOrGenerateKeys(*configDir)
|
|
if err != nil {
|
|
log.Fatalf("[WG] Ключи: %v", err)
|
|
}
|
|
|
|
enableBBR()
|
|
|
|
wgDev, err := startUserspaceWG(keys, *wgPort)
|
|
if err != nil {
|
|
log.Fatalf("[WG] Запуск: %v", err)
|
|
}
|
|
if removed := cleanupExpiredPasswords(wgDev); removed > 0 {
|
|
log.Printf("[DB] Удалено истёкших паролей при старте: %d", removed)
|
|
}
|
|
syncPersistedPeersToWG(wgDev)
|
|
defer func() {
|
|
wgDev.Close()
|
|
runCmdSilent("ip", "link", "del", wgIfaceName)
|
|
}()
|
|
|
|
go statsLoop(ctx, *configDir)
|
|
go expiredPasswordJanitor(ctx, wgDev)
|
|
go botLoop(*botToken, *adminID, wgDev)
|
|
|
|
addr, _ := net.ResolveUDPAddr("udp", *listen)
|
|
cert, _ := selfsign.GenerateSelfSigned()
|
|
if serverWrapKeys.Count() == 0 {
|
|
log.Fatalf("[WRAP] нет активных паролей для WRAP")
|
|
}
|
|
|
|
wrapListener, err := listenWrapped(addr, serverWrapKeys)
|
|
if err != nil {
|
|
log.Fatalf("[WRAP] %v", err)
|
|
}
|
|
|
|
listener, err := dtls.NewListenerWithOptions(wrapListener, dtls.WithCertificates(cert), dtls.WithExtendedMasterSecret(dtls.RequireExtendedMasterSecret), dtls.WithCipherSuites(dtls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256), dtls.WithConnectionIDGenerator(dtls.RandomCIDGenerator(8)))
|
|
if err != nil {
|
|
log.Fatalf("[DTLS] %v", err)
|
|
}
|
|
context.AfterFunc(ctx, func() { listener.Close() })
|
|
|
|
wgEndpoint := fmt.Sprintf("127.0.0.1:%d", *wgPort)
|
|
|
|
log.Printf(" DTLS: %s | WG: %s | NAT: %s", *listen, wgEndpoint, natType)
|
|
log.Printf(" WRAP: password HKDF + RTP AEAD | keys: %d", serverWrapKeys.Count())
|
|
log.Println("[SERVER] Готов")
|
|
|
|
var wg sync.WaitGroup
|
|
for {
|
|
dtlsConn, err := listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-ctx.Done():
|
|
wg.Wait()
|
|
return
|
|
default:
|
|
}
|
|
continue
|
|
}
|
|
wg.Add(1)
|
|
go func(c net.Conn) {
|
|
defer wg.Done()
|
|
defer c.Close()
|
|
handleConn(ctx, c, wgEndpoint, wgDev, keys)
|
|
}(dtlsConn)
|
|
}
|
|
}
|
|
|
|
// ==================== Обработка соединений ====================
|
|
|
|
func handleConn(ctx context.Context, clientConn net.Conn, wgEndpoint string, wgDev *device.Device, keys *wgKeys) {
|
|
atomic.AddInt64(&totalConns, 1)
|
|
|
|
var connDeviceID string
|
|
var connPassword string
|
|
var connIsMainPass bool
|
|
|
|
dtlsConn, ok := clientConn.(*dtls.Conn)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
hctx, hcancel := context.WithTimeout(ctx, 30*time.Second)
|
|
if err := dtlsConn.HandshakeContext(hctx); err != nil {
|
|
hcancel()
|
|
return
|
|
}
|
|
hcancel()
|
|
|
|
atomic.AddInt32(&activeConns, 1)
|
|
defer atomic.AddInt32(&activeConns, -1)
|
|
|
|
buf := make([]byte, 1600)
|
|
clientConn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
|
n, err := clientConn.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
clientConn.SetReadDeadline(time.Time{})
|
|
|
|
firstPacket := buf[:n]
|
|
firstStr := string(firstPacket)
|
|
|
|
if strings.HasPrefix(firstStr, "GETCONF:") {
|
|
parts := strings.Split(strings.TrimSpace(strings.TrimPrefix(firstStr, "GETCONF:")), "|")
|
|
clientPort := "9000"
|
|
deviceID := "unknown"
|
|
password := ""
|
|
if len(parts) > 0 {
|
|
clientPort = parts[0]
|
|
}
|
|
if len(parts) > 1 {
|
|
deviceID = parts[1]
|
|
}
|
|
if len(parts) > 2 {
|
|
password = parts[2]
|
|
}
|
|
|
|
dbMutex.Lock()
|
|
|
|
// Проверяем пароль
|
|
isMainPass := password != "" && password == db.MainPassword
|
|
entry, isGenPass := db.Passwords[password]
|
|
valid := isMainPass || (isGenPass && !isPasswordExpired(entry))
|
|
|
|
// Для сгенерированных паролей — проверяем привязку к устройству
|
|
if valid && isGenPass && entry.DeviceID != "" && entry.DeviceID != deviceID {
|
|
// Пароль уже привязан к другому устройству
|
|
clientConn.Write([]byte("DENIED:device_mismatch"))
|
|
log.Printf("[WG] Отказ: пароль %s привязан к %s, запрос от %s", maskPassword(password), entry.DeviceID, deviceID)
|
|
dbMutex.Unlock()
|
|
} else if valid {
|
|
connDeviceID = deviceID
|
|
connPassword = password
|
|
connIsMainPass = isMainPass
|
|
|
|
// Привязываем пароль к устройству при первом использовании
|
|
if isGenPass && entry.DeviceID == "" {
|
|
entry.DeviceID = deviceID
|
|
saveDB()
|
|
log.Printf("[WG] Пароль %s привязан к устройству %s", maskPassword(password), deviceID)
|
|
}
|
|
|
|
dev, exists := db.Devices[deviceID]
|
|
if !exists {
|
|
dev = &ClientDevice{DeviceID: deviceID, IP: getNextIP()}
|
|
privB64, pubB64, keyErr := generateKeyPair()
|
|
if keyErr == nil && dev.IP != "" {
|
|
dev.PrivKey = privB64
|
|
dev.PubKey = pubB64
|
|
db.Devices[deviceID] = dev
|
|
saveDB()
|
|
log.Printf("[WG] Новое устройство %s (IP: %s)", deviceID, dev.IP)
|
|
} else {
|
|
dev = nil
|
|
}
|
|
}
|
|
if dev != nil {
|
|
upsertPeerInWG(wgDev, dev)
|
|
clientConn.Write([]byte(buildClientConfig(keys.serverPublic, dev.PrivKey, dev.IP, clientPort)))
|
|
} else {
|
|
clientConn.Write([]byte("NOCONF"))
|
|
}
|
|
dbMutex.Unlock()
|
|
} else {
|
|
if isGenPass && isPasswordExpired(entry) {
|
|
clientConn.Write([]byte("DENIED:expired"))
|
|
log.Printf("[WG] Отказ: пароль %s истёк, от %s", maskPassword(password), deviceID)
|
|
} else {
|
|
clientConn.Write([]byte("DENIED:wrong_password"))
|
|
log.Printf("[WG] Отказ (неверный пароль) от %s", deviceID)
|
|
}
|
|
dbMutex.Unlock()
|
|
}
|
|
|
|
clientConn.SetReadDeadline(time.Now().Add(5 * time.Minute))
|
|
n, err = clientConn.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
clientConn.SetReadDeadline(time.Time{})
|
|
firstPacket = buf[:n]
|
|
firstStr = string(firstPacket)
|
|
}
|
|
|
|
if firstStr == "READY" {
|
|
clientConn.Write([]byte("READY_OK"))
|
|
clientConn.SetReadDeadline(time.Now().Add(10 * time.Minute))
|
|
n, err = clientConn.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
clientConn.SetReadDeadline(time.Time{})
|
|
firstPacket = buf[:n]
|
|
}
|
|
|
|
// WG прокси
|
|
wgConn, err := net.Dial("udp", wgEndpoint)
|
|
if err != nil {
|
|
return
|
|
}
|
|
defer wgConn.Close()
|
|
|
|
if uc, ok := wgConn.(*net.UDPConn); ok {
|
|
uc.SetReadBuffer(2 * 1024 * 1024)
|
|
uc.SetWriteBuffer(2 * 1024 * 1024)
|
|
}
|
|
|
|
if _, err := wgConn.Write(firstPacket); err != nil {
|
|
return
|
|
}
|
|
atomic.AddInt64(&totalBytesFromClient, int64(len(firstPacket)))
|
|
|
|
// Трекинг онлайн-статуса
|
|
if connDeviceID != "" {
|
|
activeDevicesMu.Lock()
|
|
activeDevices[connDeviceID]++
|
|
activeDevicesMu.Unlock()
|
|
defer func() {
|
|
activeDevicesMu.Lock()
|
|
activeDevices[connDeviceID]--
|
|
if activeDevices[connDeviceID] <= 0 {
|
|
delete(activeDevices, connDeviceID)
|
|
}
|
|
activeDevicesMu.Unlock()
|
|
}()
|
|
}
|
|
|
|
pctx, pcancel := context.WithCancel(ctx)
|
|
defer pcancel()
|
|
|
|
context.AfterFunc(pctx, func() {
|
|
clientConn.SetDeadline(time.Now())
|
|
wgConn.SetDeadline(time.Now())
|
|
})
|
|
|
|
var proxyWg sync.WaitGroup
|
|
proxyWg.Add(2)
|
|
|
|
// Клиент → WG
|
|
go func() {
|
|
defer proxyWg.Done()
|
|
defer pcancel()
|
|
b := getBuf()
|
|
defer putBuf(b)
|
|
for {
|
|
select {
|
|
case <-pctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
clientConn.SetReadDeadline(time.Now().Add(30 * time.Minute))
|
|
nn, err := clientConn.Read(*b)
|
|
if err != nil {
|
|
return
|
|
}
|
|
// Skip DTLS keepalive packets (1-byte 0xFF ping from client)
|
|
if nn == 1 && (*b)[0] == 0xFF {
|
|
continue
|
|
}
|
|
atomic.AddInt64(&totalBytesFromClient, int64(nn))
|
|
// Per-password upload tracking
|
|
if connIsMainPass {
|
|
atomic.AddInt64(&mainPassUp, int64(nn))
|
|
} else if connPassword != "" {
|
|
dbMutex.Lock()
|
|
e, ok := db.Passwords[connPassword]
|
|
if !ok || e == nil || isPasswordExpired(e) {
|
|
dbMutex.Unlock()
|
|
return
|
|
}
|
|
e.UpBytes += int64(nn)
|
|
dbMutex.Unlock()
|
|
}
|
|
if _, err := wgConn.Write((*b)[:nn]); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
// WG → Клиент
|
|
go func() {
|
|
defer proxyWg.Done()
|
|
defer pcancel()
|
|
b := getBuf()
|
|
defer putBuf(b)
|
|
for {
|
|
select {
|
|
case <-pctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
wgConn.SetReadDeadline(time.Now().Add(30 * time.Minute))
|
|
nn, err := wgConn.Read(*b)
|
|
if err != nil {
|
|
if isNetTimeout(err) {
|
|
if pctx.Err() != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
return
|
|
}
|
|
atomic.AddInt64(&totalBytesToClient, int64(nn))
|
|
// Per-password download tracking
|
|
if connIsMainPass {
|
|
atomic.AddInt64(&mainPassDown, int64(nn))
|
|
} else if connPassword != "" {
|
|
dbMutex.Lock()
|
|
e, ok := db.Passwords[connPassword]
|
|
if !ok || e == nil || isPasswordExpired(e) {
|
|
dbMutex.Unlock()
|
|
return
|
|
}
|
|
e.DownBytes += int64(nn)
|
|
dbMutex.Unlock()
|
|
}
|
|
if _, err := clientConn.Write((*b)[:nn]); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
|
|
proxyWg.Wait()
|
|
}
|
|
|
|
const (
|
|
wrapNonceLen = 12
|
|
wrapKeyLen = 32
|
|
)
|
|
|
|
// ==================== RTP Обфускация ====================
|
|
|
|
type ObfsConfig struct {
|
|
SSRC uint32
|
|
PayloadType uint8
|
|
PaddingMax int
|
|
}
|
|
|
|
type ObfsState struct {
|
|
mu sync.Mutex
|
|
seq uint16
|
|
ts uint32
|
|
}
|
|
|
|
func NewObfsConfig() *ObfsConfig {
|
|
var buf [4]byte
|
|
rand.Read(buf[:])
|
|
return &ObfsConfig{
|
|
SSRC: binary.BigEndian.Uint32(buf[:]),
|
|
PayloadType: 111,
|
|
PaddingMax: 24,
|
|
}
|
|
}
|
|
|
|
func NewObfsState() *ObfsState {
|
|
var buf [6]byte
|
|
rand.Read(buf[:])
|
|
return &ObfsState{
|
|
seq: binary.BigEndian.Uint16(buf[0:2]),
|
|
ts: binary.BigEndian.Uint32(buf[2:6]),
|
|
}
|
|
}
|
|
|
|
func obfsBuildNonce(ssrc uint32, seq uint16, ts uint32) []byte {
|
|
n := make([]byte, 12)
|
|
binary.BigEndian.PutUint32(n[0:4], ssrc)
|
|
binary.BigEndian.PutUint16(n[4:6], seq)
|
|
binary.BigEndian.PutUint32(n[8:12], ts)
|
|
return n
|
|
}
|
|
|
|
func obfsWrapPacket(key, payload []byte, cfg *ObfsConfig, state *ObfsState) ([]byte, error) {
|
|
if len(key) != wrapKeyLen {
|
|
return nil, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
|
}
|
|
if len(payload) == 0 {
|
|
return nil, errors.New("obfs: empty payload")
|
|
}
|
|
state.mu.Lock()
|
|
seq := state.seq
|
|
ts := state.ts
|
|
state.seq++
|
|
state.ts += 960
|
|
state.mu.Unlock()
|
|
|
|
nonce := obfsBuildNonce(cfg.SSRC, seq, ts)
|
|
padRand := 0
|
|
if cfg.PaddingMax > 0 {
|
|
var rndBuf [1]byte
|
|
rand.Read(rndBuf[:])
|
|
padRand = int(rndBuf[0]) % cfg.PaddingMax
|
|
}
|
|
padTotal := padRand + 1
|
|
outLen := 12 + len(payload) + chacha20poly1305.Overhead + padTotal
|
|
out := make([]byte, outLen)
|
|
|
|
out[0] = 0x80 | 0x20
|
|
out[1] = cfg.PayloadType & 0x7F
|
|
binary.BigEndian.PutUint16(out[2:4], seq)
|
|
binary.BigEndian.PutUint32(out[4:8], ts)
|
|
binary.BigEndian.PutUint32(out[8:12], cfg.SSRC)
|
|
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("obfs: cipher init: %w", err)
|
|
}
|
|
sealed := aead.Seal(out[12:12], nonce, payload, out[:12])
|
|
padStart := 12 + len(sealed)
|
|
if padRand > 0 {
|
|
rand.Read(out[padStart : padStart+padRand])
|
|
}
|
|
out[outLen-1] = byte(padTotal)
|
|
return out, nil
|
|
}
|
|
|
|
func obfsUnwrapPacket(key, wire, dst []byte) (int, error) {
|
|
if len(key) != wrapKeyLen {
|
|
return 0, fmt.Errorf("obfs: key must be %d bytes (got %d)", wrapKeyLen, len(key))
|
|
}
|
|
if len(wire) < 13 {
|
|
return 0, errors.New("obfs: packet too short")
|
|
}
|
|
if (wire[0] >> 6) != 2 {
|
|
return 0, errors.New("obfs: not RTP v2")
|
|
}
|
|
seq := binary.BigEndian.Uint16(wire[2:4])
|
|
ts := binary.BigEndian.Uint32(wire[4:8])
|
|
ssrc := binary.BigEndian.Uint32(wire[8:12])
|
|
|
|
payloadEnd := len(wire)
|
|
if wire[0]&0x20 != 0 {
|
|
padLen := int(wire[len(wire)-1])
|
|
if padLen == 0 || padLen > payloadEnd-12 {
|
|
return 0, fmt.Errorf("obfs: invalid padding length %d", padLen)
|
|
}
|
|
payloadEnd -= padLen
|
|
}
|
|
ciphertextLen := payloadEnd - 12
|
|
if ciphertextLen <= chacha20poly1305.Overhead {
|
|
return 0, errors.New("obfs: no payload")
|
|
}
|
|
if ciphertextLen-chacha20poly1305.Overhead > len(dst) {
|
|
return 0, errors.New("obfs: dst buffer too small")
|
|
}
|
|
nonce := obfsBuildNonce(ssrc, seq, ts)
|
|
aead, err := chacha20poly1305.New(key)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("obfs: cipher init: %w", err)
|
|
}
|
|
plain, err := aead.Open(dst[:0], nonce, wire[12:payloadEnd], wire[:12])
|
|
if err != nil {
|
|
return 0, fmt.Errorf("obfs: auth: %w", err)
|
|
}
|
|
return len(plain), nil
|
|
}
|
|
|
|
func obfsIsRTPPacket(wire []byte) bool {
|
|
if len(wire) < 13 {
|
|
return false
|
|
}
|
|
if (wire[0] >> 6) != 2 {
|
|
return false
|
|
}
|
|
pt := wire[1] & 0x7F
|
|
return pt == 111
|
|
}
|
|
|
|
func listenWrapped(addr *net.UDPAddr, keys *wrapKeyStore) (dtlsnet.PacketListener, error) {
|
|
if keys == nil || keys.Count() == 0 {
|
|
return nil, errors.New("wrap: no active keys")
|
|
}
|
|
inner, err := pionudp.Listen("udp", addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wrap: udp listen: %w", err)
|
|
}
|
|
return &wrapPacketListener{
|
|
inner: dtlsnet.PacketListenerFromListener(inner),
|
|
keys: keys,
|
|
}, nil
|
|
}
|
|
|
|
type wrapPacketListener struct {
|
|
inner dtlsnet.PacketListener
|
|
keys *wrapKeyStore
|
|
}
|
|
|
|
func (l *wrapPacketListener) Accept() (net.PacketConn, net.Addr, error) {
|
|
pc, addr, err := l.inner.Accept()
|
|
if err != nil {
|
|
return pc, addr, err
|
|
}
|
|
return &wrapPacketConn{inner: pc, keys: l.keys}, addr, nil
|
|
}
|
|
|
|
func (l *wrapPacketListener) Close() error { return l.inner.Close() }
|
|
func (l *wrapPacketListener) Addr() net.Addr { return l.inner.Addr() }
|
|
|
|
type wrapPacketConn struct {
|
|
inner net.PacketConn
|
|
keys *wrapKeyStore
|
|
key []byte
|
|
selected int32
|
|
authLog int32
|
|
obfsCfg *ObfsConfig
|
|
obfsWrite *ObfsState
|
|
}
|
|
|
|
func (c *wrapPacketConn) ReadFrom(p []byte) (int, net.Addr, error) {
|
|
// Extra space for RTP header (12) + AEAD tag (16) + padding.
|
|
buf := make([]byte, len(p)+80)
|
|
n, addr, err := c.inner.ReadFrom(buf)
|
|
if err != nil {
|
|
return 0, addr, err
|
|
}
|
|
raw := buf[:n]
|
|
|
|
if atomic.LoadInt32(&c.selected) == 0 {
|
|
key, m, uErr := c.keys.Unwrap(raw, p)
|
|
if uErr != nil {
|
|
if atomic.CompareAndSwapInt32(&c.authLog, 0, 1) {
|
|
log.Printf("[WRAP] Отказ: RTP AEAD auth failed from %s (keys=%d)", addr.String(), c.keys.Count())
|
|
}
|
|
return 0, addr, uErr
|
|
}
|
|
c.key = key
|
|
c.obfsCfg = NewObfsConfig()
|
|
c.obfsWrite = NewObfsState()
|
|
atomic.StoreInt32(&c.selected, 1)
|
|
if atomic.CompareAndSwapInt32(&c.authLog, 0, 1) {
|
|
log.Printf("[WRAP] OK: ключ выбран для %s (keys=%d)", addr.String(), c.keys.Count())
|
|
}
|
|
return m, addr, nil
|
|
}
|
|
|
|
m, uErr := obfsUnwrapPacket(c.key, raw, p)
|
|
if uErr != nil {
|
|
return 0, addr, fmt.Errorf("obfs unwrap: %w", uErr)
|
|
}
|
|
return m, addr, nil
|
|
}
|
|
|
|
func (c *wrapPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) {
|
|
if atomic.LoadInt32(&c.selected) == 0 || len(c.key) != wrapKeyLen {
|
|
return 0, errors.New("wrap: key not selected")
|
|
}
|
|
if c.obfsCfg == nil || c.obfsWrite == nil {
|
|
c.obfsCfg = NewObfsConfig()
|
|
c.obfsWrite = NewObfsState()
|
|
}
|
|
wrapped, wErr := obfsWrapPacket(c.key, p, c.obfsCfg, c.obfsWrite)
|
|
if wErr != nil {
|
|
return 0, fmt.Errorf("obfs wrap: %w", wErr)
|
|
}
|
|
if _, err := c.inner.WriteTo(wrapped, addr); err != nil {
|
|
return 0, err
|
|
}
|
|
return len(p), nil
|
|
}
|
|
|
|
func (c *wrapPacketConn) Close() error { return c.inner.Close() }
|
|
func (c *wrapPacketConn) LocalAddr() net.Addr { return c.inner.LocalAddr() }
|
|
func (c *wrapPacketConn) SetDeadline(t time.Time) error { return c.inner.SetDeadline(t) }
|
|
func (c *wrapPacketConn) SetReadDeadline(t time.Time) error { return c.inner.SetReadDeadline(t) }
|
|
func (c *wrapPacketConn) SetWriteDeadline(t time.Time) error { return c.inner.SetWriteDeadline(t) }
|