diff options
Diffstat (limited to 'bot')
| -rw-r--r-- | bot/Dockerfile | 13 | ||||
| -rw-r--r-- | bot/go.mod | 3 | ||||
| -rw-r--r-- | bot/main.go | 464 |
3 files changed, 480 insertions, 0 deletions
diff --git a/bot/Dockerfile b/bot/Dockerfile new file mode 100644 index 0000000..fb89d33 --- /dev/null +++ b/bot/Dockerfile @@ -0,0 +1,13 @@ +# сборка Go-бота в статик-бинарь +FROM golang:1.22-alpine AS build +WORKDIR /src +COPY go.mod . +COPY main.go . +RUN CGO_ENABLED=0 go build -o /gitbot -ldflags="-s -w" . + +FROM alpine:3.20 +RUN apk add --no-cache git ca-certificates tzdata +COPY --from=build /gitbot /usr/local/bin/gitbot +ENV REPOS_DIR=/repos +VOLUME /repos +ENTRYPOINT ["/usr/local/bin/gitbot"] diff --git a/bot/go.mod b/bot/go.mod new file mode 100644 index 0000000..7a4f81e --- /dev/null +++ b/bot/go.mod @@ -0,0 +1,3 @@ +module gitbot + +go 1.21 diff --git a/bot/main.go b/bot/main.go new file mode 100644 index 0000000..61e1621 --- /dev/null +++ b/bot/main.go @@ -0,0 +1,464 @@ +// Пацанский Гит — Telegram-бот. Репозитории под неймспейсом юзера (<user>/<repo>.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") // напр. [email protected] (для 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<id> +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, "Использование: <code>/new имя [описание]</code>") + 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, "Использование: <code>/info имя</code> или <code>/info юзер/имя</code>") + 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, "Использование: <code>/del имя confirm</code> (только своё)") + return + } + if len(args) < 2 || args[1] != "confirm" { + send(chat, fmt.Sprintf("⚠ Удалить <b>%s/%s</b>? Подтверди: <code>/del %s confirm</code>", ns, args[0], args[0])) + return + } + if err := delRepo(ns, args[0]); err != nil { + send(chat, "❌ "+err.Error()) + return + } + send(chat, "🗑 Удалён <b>"+ns+"/"+args[0]+"</b>.") + 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: <code>%s:%s/имя.git</code>", sshHost, ns) + } + send(chat, "🔑 Ключ добавлен."+s) + case "/keys": + send(chat, listKeys(ns)) + case "/delkey": + if len(args) < 1 { + send(chat, "Использование: <code>/delkey N</code> (номер из /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 "У тебя (<b>" + ns + "</b>) нет репо. Создай: <code>/new имя</code>" + } + 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("<b>" + o + "/</b>\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<b>%s/%s</b>\n%s\nHTTP: <code>%s/%s/%s.git</code>\nбраузить: %s/%s/%s/", + prefix, ns, name, strings.TrimSpace(string(desc)), publicURL, ns, name, publicURL, ns, name) + if sshHost != "" { + s += fmt.Sprintf("\nSSH: <code>%s:%s/%s.git</code>", 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 "У тебя нет ключей. Добавь: <code>/addkey ssh-ed25519 AAAA... comment</code>" + } + 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("Удалить: <code>/delkey N</code>") + 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 := "<b>🤖 Пацанский Гит — бот</b>\nРепо под твоим неймспейсом.\n\n" + + "<b>Репо:</b>\n/new имя [описание] · /mine · /list · /info имя · /del имя confirm\n\n" + + "<b>SSH-ключи:</b>\n/addkey ssh-ed25519 AAAA... · /keys · /delkey N\n\n" + + "HTTP: <code>" + publicURL + "/юзер/имя.git</code>" + if sshHost != "" { + s += "\nSSH: <code>" + sshHost + ":юзер/имя.git</code>" + } + return s +} |
