diff --git a/README.md b/README.md index beac7fa..1cf6283 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ - `/health` -> `GET /v1/health` - `/summary` -> `GET /v1/stats/summary` - `/users` -> `GET /v1/stats/users` (первые 20 строк) +- `/create_user ` -> `POST /v1/users` (создание пользователя + ссылки на подключение) Команды обрабатываются только от одного админ-чата (`TELEGRAM_ADMIN_CHAT_ID`). diff --git a/main.go b/main.go index 37ec157..78163d4 100644 --- a/main.go +++ b/main.go @@ -87,6 +87,22 @@ type userInfo struct { CurrentConnections uint64 `json:"current_connections"` ActiveUniqueIPs uint64 `json:"active_unique_ips"` TotalOctets uint64 `json:"total_octets"` + Links userLinks `json:"links"` +} + +type userLinks struct { + Classic []string `json:"classic"` + Secure []string `json:"secure"` + TLS []string `json:"tls"` +} + +type createUserRequest struct { + Username string `json:"username"` +} + +type createUserResponse struct { + User userInfo `json:"user"` + Secret string `json:"secret"` } func main() { @@ -253,13 +269,15 @@ func (b *bot) handleMessage(ctx context.Context, msg *tgMessage) error { } switch command { case "/start", "/help": - return b.sendMessage(ctx, chatID, "Команды:\n/health\n/summary\n/users") + return b.sendMessage(ctx, chatID, "Команды:\n/health\n/summary\n/users\n/create_user ") case "/health": return b.handleHealth(ctx, chatID) case "/summary": return b.handleSummary(ctx, chatID) case "/users": return b.handleUsers(ctx, chatID) + case "/create_user", "/createuser": + return b.handleCreateUser(ctx, chatID, text) default: return b.sendMessage(ctx, chatID, "Неизвестная команда. Используй /help") } @@ -315,14 +333,91 @@ func (b *bot) handleUsers(ctx context.Context, chatID int64) error { return b.sendMessage(ctx, chatID, strings.TrimRight(sb.String(), "\n")) } +func (b *bot) handleCreateUser(ctx context.Context, chatID int64, text string) error { + parts := strings.Fields(text) + if len(parts) < 2 { + return b.sendMessage(ctx, chatID, "Использование: /create_user ") + } + + username := strings.TrimSpace(parts[1]) + if !isValidUsername(username) { + return b.sendMessage(ctx, chatID, "Некорректный username. Разрешены [A-Za-z0-9_.-], длина 1..64.") + } + + req := createUserRequest{Username: username} + var created createUserResponse + if err := b.callTelemtJSON(ctx, http.MethodPost, "/v1/users", req, &created, http.StatusCreated); err != nil { + return b.sendMessage(ctx, chatID, "Ошибка создания пользователя: "+err.Error()) + } + + msg := fmt.Sprintf("Пользователь создан: %s\nsecret: %s", created.User.Username, created.Secret) + if linksText := formatUserLinks(created.User.Links); linksText != "" { + msg = msg + "\n\nСсылки:\n" + linksText + } + return b.sendMessage(ctx, chatID, msg) +} + +func formatUserLinks(links userLinks) string { + var sb strings.Builder + appendLinks := func(title string, items []string) { + if len(items) == 0 { + return + } + sb.WriteString(title) + sb.WriteByte('\n') + for i := range items { + sb.WriteString("- ") + sb.WriteString(items[i]) + sb.WriteByte('\n') + } + } + + appendLinks("classic:", links.Classic) + appendLinks("secure:", links.Secure) + appendLinks("tls:", links.TLS) + return strings.TrimRight(sb.String(), "\n") +} + +func isValidUsername(v string) bool { + if len(v) < 1 || len(v) > 64 { + return false + } + for i := 0; i < len(v); i++ { + ch := v[i] + if (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.' || ch == '-' { + continue + } + return false + } + return true +} + func (b *bot) callTelemt(ctx context.Context, path string, out any) error { + return b.callTelemtJSON(ctx, http.MethodGet, path, nil, out, http.StatusOK) +} + +func (b *bot) callTelemtJSON(ctx context.Context, method, path string, payload any, out any, successStatus int) error { cctx, cancel := context.WithTimeout(ctx, b.cfg.TelemtTimeout) defer cancel() - req, err := http.NewRequestWithContext(cctx, http.MethodGet, b.cfg.TelemtAPIBaseURL+path, nil) + var bodyReader *bytes.Reader + if payload != nil { + body, err := json.Marshal(payload) + if err != nil { + return err + } + bodyReader = bytes.NewReader(body) + } else { + bodyReader = bytes.NewReader(nil) + } + + req, err := http.NewRequestWithContext(cctx, method, b.cfg.TelemtAPIBaseURL+path, bodyReader) if err != nil { return err } + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } if b.cfg.TelemtAPIAuth != "" { req.Header.Set("Authorization", b.cfg.TelemtAPIAuth) } @@ -333,13 +428,19 @@ func (b *bot) callTelemt(ctx context.Context, path string, out any) error { } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("telemt status %s", resp.Status) - } - var env telemtEnvelope if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { - return err + if resp.StatusCode != successStatus { + return fmt.Errorf("telemt status %s", resp.Status) + } + return fmt.Errorf("telemt decode response: %w", err) + } + + if resp.StatusCode != successStatus { + if env.Error != nil { + return fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message) + } + return fmt.Errorf("telemt status %s", resp.Status) } if !env.OK { if env.Error != nil { @@ -347,7 +448,10 @@ func (b *bot) callTelemt(ctx context.Context, path string, out any) error { } return errors.New("telemt returned ok=false") } - if len(env.Data) == 0 { + if out == nil { + return nil + } + if len(env.Data) == 0 || string(env.Data) == "null" { return errors.New("telemt empty data") } return json.Unmarshal(env.Data, out)