// Пацанский Гит — Telegram-бот. Репозитории под неймспейсом юзера (/.git), // управление SSH-ключами (генерит authorized_keys для sshd), вайтлист по чату. // Stdlib-only. package main import ( "encoding/json" "fmt" "io" "log" "net/http" "net/url" "os" "os/exec" "path/filepath" "regexp" "sort" "strconv" "strings" "sync" "time" ) var ( token = os.Getenv("BOT_TOKEN") reposDir = envDefault("REPOS_DIR", "/repos") keysDir = envDefault("KEYS_DIR", "/keys") publicURL = strings.TrimRight(envDefault("PUBLIC_URL", "http://localhost"), "/") sshHost = os.Getenv("SSH_HOST") // напр. git@git.ancap.in.ua (для ssh clone-url; пусто = не показывать) nsCmd = envDefault("GIT_NS", "git-ns") // форс-команда в authorized_keys (в докере "git-ns" на PATH, живьём — абс.путь) admins = parseAdmins(os.Getenv("ADMINS")) whitelistChat = os.Getenv("WHITELIST_CHAT") api = "https://api.telegram.org/bot" + token nameRe = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`) userRe = regexp.MustCompile(`[^a-z0-9._-]+`) sshKeyRe = regexp.MustCompile(`^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]+(\s+\S+)?$`) mu sync.Mutex // защищает файловые операции с ключами/authorized_keys ) func envDefault(k, d string) string { if v := os.Getenv(k); v != "" { return v } return d } func parseAdmins(s string) map[int64]bool { m := map[int64]bool{} for _, p := range strings.Split(s, ",") { if id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil { m[id] = true } } return m } type Update struct { UpdateID int64 `json:"update_id"` Message *struct { From struct { ID int64 `json:"id"` Username string `json:"username"` } `json:"from"` Chat struct { ID int64 `json:"id"` } `json:"chat"` Text string `json:"text"` } `json:"message"` } func tg(method string, params url.Values) ([]byte, error) { resp, err := http.PostForm(api+"/"+method, params) if err != nil { return nil, err } defer resp.Body.Close() return io.ReadAll(resp.Body) } func send(chat int64, text string) { tg("sendMessage", url.Values{"chat_id": {strconv.FormatInt(chat, 10)}, "text": {text}, "parse_mode": {"HTML"}, "disable_web_page_preview": {"true"}}) } // имя-неймспейс юзера: @username в нижнем регистре, иначе u func nsOf(username string, id int64) string { u := strings.ToLower(strings.TrimSpace(username)) if u == "" { return "u" + strconv.FormatInt(id, 10) } u = userRe.ReplaceAllString(u, "-") if u == "" { return "u" + strconv.FormatInt(id, 10) } return u } func repoPath(ns, name string) string { return filepath.Join(reposDir, ns, name+".git") } // ---- вайтлист по членству в чате ---- type memEntry struct { ok bool exp time.Time } var ( memCache = map[int64]memEntry{} memCacheMu sync.Mutex ) func authorized(id int64) bool { if admins[id] { return true } if whitelistChat != "" { return isChatMember(id) } return len(admins) == 0 } func isChatMember(id int64) bool { memCacheMu.Lock() if e, ok := memCache[id]; ok && time.Now().Before(e.exp) { memCacheMu.Unlock() return e.ok } memCacheMu.Unlock() ok := false if b, err := tg("getChatMember", url.Values{"chat_id": {whitelistChat}, "user_id": {strconv.FormatInt(id, 10)}}); err == nil { var r struct { OK bool `json:"ok"` Result struct { Status string `json:"status"` } `json:"result"` } if json.Unmarshal(b, &r) == nil && r.OK { switch r.Result.Status { case "creator", "administrator", "member", "restricted": ok = true } } } memCacheMu.Lock() memCache[id] = memEntry{ok: ok, exp: time.Now().Add(2 * time.Minute)} memCacheMu.Unlock() return ok } func main() { if token == "" { log.Fatal("BOT_TOKEN не задан") } os.MkdirAll(reposDir, 0o755) os.MkdirAll(filepath.Join(keysDir, "users"), 0o700) rebuildAuthorizedKeys() log.Printf("gitbot up · repos=%s · keys=%s · public=%s · whitelist=%s", reposDir, keysDir, publicURL, whitelistChat) var offset int64 for { b, err := tg("getUpdates", url.Values{"offset": {strconv.FormatInt(offset+1, 10)}, "timeout": {"30"}}) if err != nil { time.Sleep(3 * time.Second) continue } var r struct { OK bool `json:"ok"` Result []Update `json:"result"` } if json.Unmarshal(b, &r) != nil || !r.OK { time.Sleep(2 * time.Second) continue } for _, u := range r.Result { offset = u.UpdateID if u.Message != nil && u.Message.Text != "" { handle(u) } } } } func handle(u Update) { chat, uid, uname := u.Message.Chat.ID, u.Message.From.ID, u.Message.From.Username ns := nsOf(uname, uid) fields := strings.Fields(strings.TrimSpace(u.Message.Text)) if len(fields) == 0 { return } cmd := fields[0] if i := strings.Index(cmd, "@"); i >= 0 { cmd = cmd[:i] } // для команд с многострочным аргументом (ключи) берём полный текст после команды rest := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(u.Message.Text), fields[0])) args := fields[1:] switch cmd { case "/start", "/help": send(chat, helpText()) case "/new": if !authorized(uid) { send(chat, "⛔ Только участники чата.") return } if len(args) < 1 || !nameRe.MatchString(args[0]) { send(chat, "Использование: /new имя [описание]") return } if err := createRepo(ns, args[0], strings.Join(args[1:], " ")); err != nil { send(chat, "❌ "+err.Error()) return } send(chat, repoInfo(ns, args[0], "✅ Репо создан.\n")) case "/list": send(chat, listAll()) case "/mine": send(chat, listMine(ns)) case "/info": if len(args) < 1 { send(chat, "Использование: /info имя или /info юзер/имя") return } o, n := ns, args[0] if strings.Contains(args[0], "/") { p := strings.SplitN(args[0], "/", 2) o, n = p[0], strings.TrimSuffix(p[1], ".git") } send(chat, repoInfo(o, n, "")) case "/del", "/rm": if !authorized(uid) { send(chat, "⛔ Только участники чата.") return } if len(args) < 1 || !nameRe.MatchString(args[0]) { send(chat, "Использование: /del имя confirm (только своё)") return } if len(args) < 2 || args[1] != "confirm" { send(chat, fmt.Sprintf("⚠ Удалить %s/%s? Подтверди: /del %s confirm", ns, args[0], args[0])) return } if err := delRepo(ns, args[0]); err != nil { send(chat, "❌ "+err.Error()) return } send(chat, "🗑 Удалён "+ns+"/"+args[0]+".") case "/addkey": if !authorized(uid) { send(chat, "⛔ Только участники чата.") return } if err := addKey(ns, rest); err != nil { send(chat, "❌ "+err.Error()) return } s := "" if sshHost != "" { s = fmt.Sprintf("\nSSH clone: %s:%s/имя.git", sshHost, ns) } send(chat, "🔑 Ключ добавлен."+s) case "/keys": send(chat, listKeys(ns)) case "/delkey": if len(args) < 1 { send(chat, "Использование: /delkey N (номер из /keys)") return } n, _ := strconv.Atoi(args[0]) if err := delKey(ns, n); err != nil { send(chat, "❌ "+err.Error()) return } send(chat, "🔑 Ключ #"+args[0]+" удалён.") } } func createRepo(ns, name, desc string) error { p := repoPath(ns, name) if _, err := os.Stat(p); err == nil { return fmt.Errorf("репо уже есть") } os.MkdirAll(filepath.Dir(p), 0o755) if out, err := exec.Command("git", "init", "--bare", "--initial-branch=main", p).CombinedOutput(); err != nil { return fmt.Errorf("git init: %s", strings.TrimSpace(string(out))) } if desc == "" { desc = name } os.WriteFile(filepath.Join(p, "description"), []byte(desc+"\n"), 0o644) os.WriteFile(filepath.Join(p, "git-daemon-export-ok"), []byte(""), 0o644) exec.Command("git", "--git-dir", p, "config", "http.receivepack", "true").Run() return nil } func delRepo(ns, name string) error { p := repoPath(ns, name) if _, err := os.Stat(p); err != nil { return fmt.Errorf("нет такого репо в твоём неймспейсе") } return os.RemoveAll(p) } func reposOf(ns string) []string { ents, _ := os.ReadDir(filepath.Join(reposDir, ns)) var out []string for _, e := range ents { if e.IsDir() && strings.HasSuffix(e.Name(), ".git") { out = append(out, strings.TrimSuffix(e.Name(), ".git")) } } sort.Strings(out) return out } func listMine(ns string) string { rs := reposOf(ns) if len(rs) == 0 { return "У тебя (" + ns + ") нет репо. Создай: /new имя" } var sb strings.Builder fmt.Fprintf(&sb, "📦 Твои репо (%s): %d\n", ns, len(rs)) for _, n := range rs { sb.WriteString("• " + n + "\n") } return sb.String() } func listAll() string { ents, _ := os.ReadDir(reposDir) var owners []string for _, e := range ents { if e.IsDir() { owners = append(owners, e.Name()) } } sort.Strings(owners) var sb strings.Builder total := 0 for _, o := range owners { rs := reposOf(o) if len(rs) == 0 { continue } total += len(rs) sb.WriteString("" + o + "/\n") for _, n := range rs { sb.WriteString(" • " + n + "\n") } } if total == 0 { return "Репозиториев пока нет." } return fmt.Sprintf("📦 Всего репо: %d\n", total) + sb.String() } func repoInfo(ns, name, prefix string) string { p := repoPath(ns, name) if _, err := os.Stat(p); err != nil { return "нет такого репо" } desc, _ := os.ReadFile(filepath.Join(p, "description")) s := fmt.Sprintf("%s%s/%s\n%s\nHTTP: %s/%s/%s.git\nбраузить: %s/%s/%s/", prefix, ns, name, strings.TrimSpace(string(desc)), publicURL, ns, name, publicURL, ns, name) if sshHost != "" { s += fmt.Sprintf("\nSSH: %s:%s/%s.git", sshHost, ns, name) } return s } // ---- SSH-ключи ---- func userKeysFile(ns string) string { return filepath.Join(keysDir, "users", ns) } func readKeys(ns string) []string { b, _ := os.ReadFile(userKeysFile(ns)) var out []string for _, l := range strings.Split(string(b), "\n") { if strings.TrimSpace(l) != "" { out = append(out, strings.TrimSpace(l)) } } return out } func addKey(ns, key string) error { key = strings.TrimSpace(strings.ReplaceAll(key, "\n", " ")) key = strings.Join(strings.Fields(key), " ") if !sshKeyRe.MatchString(key) { return fmt.Errorf("не похоже на ssh-pubkey (ssh-ed25519/ssh-rsa/ecdsa...)") } mu.Lock() defer mu.Unlock() keys := readKeys(ns) for _, k := range keys { if strings.Fields(k)[1] == strings.Fields(key)[1] { return fmt.Errorf("такой ключ уже добавлен") } } keys = append(keys, key) os.WriteFile(userKeysFile(ns), []byte(strings.Join(keys, "\n")+"\n"), 0o600) rebuildAuthorizedKeys() return nil } func delKey(ns string, n int) error { mu.Lock() defer mu.Unlock() keys := readKeys(ns) if n < 1 || n > len(keys) { return fmt.Errorf("нет ключа #%d (см. /keys)", n) } keys = append(keys[:n-1], keys[n:]...) os.WriteFile(userKeysFile(ns), []byte(strings.Join(keys, "\n")+"\n"), 0o600) rebuildAuthorizedKeys() return nil } func listKeys(ns string) string { keys := readKeys(ns) if len(keys) == 0 { return "У тебя нет ключей. Добавь: /addkey ssh-ed25519 AAAA... comment" } var sb strings.Builder sb.WriteString("🔑 Твои ключи:\n") for i, k := range keys { f := strings.Fields(k) typ, blob := f[0], f[1] short := blob if len(blob) > 20 { short = blob[:10] + "…" + blob[len(blob)-8:] } cmt := "" if len(f) > 2 { cmt = " " + strings.Join(f[2:], " ") } fmt.Fprintf(&sb, "%d. %s %s%s\n", i+1, typ, short, cmt) } sb.WriteString("Удалить: /delkey N") return sb.String() } // authorized_keys для sshd: каждый ключ -> git-shell-обёртка, ограниченная неймспейсом юзера func rebuildAuthorizedKeys() { mu2 := &mu _ = mu2 ents, _ := os.ReadDir(filepath.Join(keysDir, "users")) var sb strings.Builder for _, e := range ents { ns := e.Name() for _, k := range readKeys(ns) { fmt.Fprintf(&sb, `command="%s %s",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding %s`+"\n", nsCmd, ns, k) } } os.WriteFile(filepath.Join(keysDir, "authorized_keys"), []byte(sb.String()), 0o600) } func helpText() string { s := "🤖 Пацанский Гит — бот\nРепо под твоим неймспейсом.\n\n" + "Репо:\n/new имя [описание] · /mine · /list · /info имя · /del имя confirm\n\n" + "SSH-ключи:\n/addkey ssh-ed25519 AAAA... · /keys · /delkey N\n\n" + "HTTP: " + publicURL + "/юзер/имя.git" if sshHost != "" { s += "\nSSH: " + sshHost + ":юзер/имя.git" } return s }