summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorprospanclaudebot <[email protected]>2026-07-10 22:10:31 +0000
committerprospanclaudebot <[email protected]>2026-07-10 22:10:31 +0000
commit966b62d9bd7e483f1e9484a5a99d8c2db475171c (patch)
treedf3a27730963d68d515272b4246dc79d96945744
downloadpacan-git-966b62d9bd7e483f1e9484a5a99d8c2db475171c.tar.gz
pacan-git-966b62d9bd7e483f1e9484a5a99d8c2db475171c.zip
initial import: pacan-gitHEADmain
-rw-r--r--.env.example24
-rw-r--r--README.md45
-rw-r--r--bot/Dockerfile13
-rw-r--r--bot/go.mod3
-rw-r--r--bot/main.go464
-rw-r--r--docker-compose.yml44
-rw-r--r--ssh/Dockerfile13
-rwxr-xr-xssh/entrypoint.sh20
-rwxr-xr-xssh/git-ns25
-rw-r--r--web/Dockerfile16
-rw-r--r--web/MonaspaceKrypton.woff2bin0 -> 445408 bytes
-rw-r--r--web/cgitrc14
-rw-r--r--web/dark.css983
-rwxr-xr-xweb/entrypoint.sh7
-rw-r--r--web/nginx.conf39
-rw-r--r--web/pg-favicon.pngbin0 -> 6851 bytes
-rw-r--r--web/pg-logo.pngbin0 -> 29948 bytes
17 files changed, 1710 insertions, 0 deletions
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..ed5ac2f
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,24 @@
+# --- скопируй в .env и заполни ---
+
+# токен бота от @BotFather
+BOT_TOKEN=123456789:AA...
+
+# супер-админы: TG user-id через запятую (всегда можно). Пусто = нет.
+ADMINS=
+
+# ВАЙТЛИСТ: id чата, чьи участники допущены к боту. Бот должен быть добавлен в этот чат.
+# Пусто = без вайтлиста (если и ADMINS пуст — открыто всем).
+WHITELIST_CHAT=
+
+# публичный адрес для clone/browse-ссылок (HTTP), что шлёт бот
+PUBLIC_URL=https://git.ancap.in.ua
+
+# ssh-адрес для ssh-clone-ссылок (git@host). Пусто = ssh-ссылки не показывать.
+# порт хоста для sshd (SSH через HTTP-туннель не ходит — либо этот порт наружу, либо свой TCP-туннель)
+SSH_PORT=2222
+
+# cloudflared tunnel token:
+# Cloudflare Zero Trust -> Networks -> Tunnels -> Create tunnel ->
+# public hostname: git.ancap.in.ua -> service: http://web:80 -> скопировать token
+TUNNEL_TOKEN=eyJhIjoi...
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..078f2a8
--- /dev/null
+++ b/README.md
@@ -0,0 +1,45 @@
+# Пацанский Гит 🏴‍☠️
+
+Минимальный self-hosted git: **телеграм-бот** рулит репозиториями, **cgit** их показывает,
+**git-http-backend** отдаёт clone/push, **cloudflared** пробрасывает наружу. Всё в докере.
+
+```
+tg-бот ──создаёт/удаляет──▶ /repos (bare) ◀──показывает── cgit ──┐
+ ├─ nginx ─ cloudflared ─▶ git.ancap.in.ua
+git push/clone ──HTTP──▶ git-http-backend ────────────────────────┘
+```
+
+## Запуск
+
+1. `cp .env.example .env` и заполни: `BOT_TOKEN`, `ADMINS`, `PUBLIC_URL`, `TUNNEL_TOKEN`.
+2. В Cloudflare Zero Trust создай туннель, public hostname `git.ancap.in.ua` → service `http://web:80`, вставь token в `.env`.
+3. `docker compose up -d --build`
+
+## Бот
+
+Репозитории живут под **неймспейсом юзера** (`<юзер>/<репо>.git`, юзер = @username).
+- `/new имя [описание]` — создать репо у себя
+- `/mine` — свои репо · `/list` — все (по владельцам) · `/info имя`
+- `/del имя confirm` — удалить своё
+- `/addkey ssh-ed25519 AAAA... коммент` — добавить ssh-ключ
+- `/keys` · `/delkey N`
+
+Вайтлист: `WHITELIST_CHAT` = id чата, бот должен быть в него добавлен.
+
+## Работа с репо
+
+По HTTP (через cloudflared-туннель):
+```
+git clone https://git.ancap.in.ua/юзер/имя.git
+```
+
+По SSH (ключи добавляются ботом, пуш только в свой неймспейс):
+```
+git clone ssh://[email protected]:2222/юзер/имя.git
+# или: git clone [email protected]:юзер/имя.git (для порта 22)
+```
+Браузить (cgit, тёмная тема): `https://git.ancap.in.ua/юзер/имя/`
+
+⚠️ **SSH через cloudflared HTTP-туннель не ходит** — для ssh пробрось порт `SSH_PORT`
+наружу напрямую или отдельным TCP-туннелем. HTTP-пуш открыт (`GIT_HTTP_EXPORT_ALL`);
+хочешь авторизацию и по HTTP — навесь `auth_basic` в `web/nginx.conf`.
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
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bf0e5fb
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,44 @@
+name: pacan-git
+
+services:
+ web: # cgit (браузинг) + git-http-backend (clone/push)
+ build: ./web
+ volumes:
+ - repos:/repos
+ expose:
+ - "80"
+ restart: unless-stopped
+
+ bot: # телеграм-бот: /new /list /info /del
+ build: ./bot
+ environment:
+ BOT_TOKEN: ${BOT_TOKEN:?нужен токен от @BotFather}
+ ADMINS: ${ADMINS:-}
+ WHITELIST_CHAT: ${WHITELIST_CHAT:-}
+ PUBLIC_URL: ${PUBLIC_URL:-https://git.ancap.in.ua}
+ SSH_HOST: ${SSH_HOST:-}
+ REPOS_DIR: /repos
+ KEYS_DIR: /keys
+ GIT_NS: /usr/local/bin/git-ns # форс-команда в authorized_keys (её читает ssh-сервис)
+ volumes:
+ - repos:/repos
+ - keys:/keys
+ restart: unless-stopped
+
+ tunnel: # cloudflared: git.ancap.in.ua -> http://web:80 (HTTP)
+ image: cloudflare/cloudflared:latest
+ command: tunnel --no-autoupdate run --token ${TUNNEL_TOKEN:?нужен cloudflared tunnel token}
+ restart: unless-stopped
+
+ ssh: # git по ssh-ключам: пуш только в свой неймспейс
+ build: ./ssh
+ volumes:
+ - repos:/repos
+ - keys:/keys
+ ports:
+ - "${SSH_PORT:-2222}:22" # SSH через HTTP-туннель не ходит — пробрось порт или свой TCP-туннель
+ restart: unless-stopped
+
+volumes:
+ repos:
+ keys:
diff --git a/ssh/Dockerfile b/ssh/Dockerfile
new file mode 100644
index 0000000..13367b6
--- /dev/null
+++ b/ssh/Dockerfile
@@ -0,0 +1,13 @@
+# sshd для git-доступа по ключам. Ключи и authorized_keys приезжают из volume /keys (пишет бот).
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends openssh-server git \
+ && rm -rf /var/lib/apt/lists/* \
+ && useradd -m -d /home/git -s /bin/sh git \
+ && mkdir -p /home/git/.ssh /run/sshd
+COPY git-ns /usr/local/bin/git-ns
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /usr/local/bin/git-ns /entrypoint.sh
+EXPOSE 22
+VOLUME /repos
+VOLUME /keys
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/ssh/entrypoint.sh b/ssh/entrypoint.sh
new file mode 100755
index 0000000..c255adf
--- /dev/null
+++ b/ssh/entrypoint.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+set -e
+mkdir -p /repos /keys /home/git/.ssh /run/sshd
+touch /keys/authorized_keys
+chown -R git:git /home/git/.ssh 2>/dev/null || true
+chmod 700 /home/git/.ssh
+ssh-keygen -A
+cat > /etc/ssh/sshd_config <<EOF
+Port 22
+PermitRootLogin no
+PasswordAuthentication no
+PubkeyAuthentication yes
+AllowUsers git
+AuthorizedKeysFile /keys/authorized_keys
+X11Forwarding no
+AllowTcpForwarding no
+AllowAgentForwarding no
+EOF
+echo "sshd up · authorized_keys=/keys/authorized_keys (пишет бот)"
+exec /usr/sbin/sshd -D -e
diff --git a/ssh/git-ns b/ssh/git-ns
new file mode 100755
index 0000000..f8b6b0b
--- /dev/null
+++ b/ssh/git-ns
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Форс-команда из authorized_keys: command="git-ns <namespace>".
+# Пускает пользователя только в git-операции над репозиториями ЕГО неймспейса.
+NS="$1"
+REPOS="${REPOS_DIR:-/repos}"
+set -- $SSH_ORIGINAL_COMMAND
+cmd="$1"
+repo=$(printf '%s' "$2" | tr -d "'\"")
+repo="${repo#/}"
+case "$cmd" in
+ git-receive-pack|git-upload-pack|git-upload-archive) ;;
+ *) echo "разрешены только git-команды" >&2; exit 1 ;;
+esac
+# без слеша -> свой неймспейс
+case "$repo" in
+ */*) ;;
+ *) repo="$NS/$repo" ;;
+esac
+# запрет выхода в чужой неймспейс
+case "$repo" in
+ "$NS/"*) ;;
+ *) echo "чужой неймспейс запрещён" >&2; exit 1 ;;
+esac
+repo="${repo%/}"
+exec git-shell -c "$cmd '$REPOS/$repo'"
diff --git a/web/Dockerfile b/web/Dockerfile
new file mode 100644
index 0000000..c99c212
--- /dev/null
+++ b/web/Dockerfile
@@ -0,0 +1,16 @@
+# cgit + nginx + git-http-backend (браузинг + clone/push по HTTP)
+FROM debian:bookworm-slim
+RUN apt-get update && apt-get install -y --no-install-recommends \
+ nginx cgit fcgiwrap spawn-fcgi git ca-certificates \
+ && rm -rf /var/lib/apt/lists/*
+COPY nginx.conf /etc/nginx/nginx.conf
+COPY cgitrc /etc/cgitrc
+COPY dark.css /usr/share/cgit/dark.css
+COPY MonaspaceKrypton.woff2 /usr/share/cgit/MonaspaceKrypton.woff2
+COPY pg-logo.png /usr/share/cgit/pg-logo.png
+COPY pg-favicon.png /usr/share/cgit/pg-favicon.png
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+EXPOSE 80
+VOLUME /repos
+ENTRYPOINT ["/entrypoint.sh"]
diff --git a/web/MonaspaceKrypton.woff2 b/web/MonaspaceKrypton.woff2
new file mode 100644
index 0000000..1ad7f01
--- /dev/null
+++ b/web/MonaspaceKrypton.woff2
Binary files differ
diff --git a/web/cgitrc b/web/cgitrc
new file mode 100644
index 0000000..18764a1
--- /dev/null
+++ b/web/cgitrc
@@ -0,0 +1,14 @@
+css=/cgit-static/dark.css?v=krypton5
+logo=/cgit-static/pg-logo.png
+favicon=/cgit-static/pg-favicon.png
+root-title=Пацанский Гит · ancap.in.ua
+root-desc=репозитории, которыми рулит телеграм-бот
+enable-http-clone=1
+clone-url=https://$HTTP_HOST/$CGIT_REPO_URL
+snapshots=tar.gz zip
+enable-index-links=1
+enable-commit-graph=1
+enable-index-owner=0
+readme=:README.md
+remove-suffix=1
+scan-path=/repos
diff --git a/web/dark.css b/web/dark.css
new file mode 100644
index 0000000..3781187
--- /dev/null
+++ b/web/dark.css
@@ -0,0 +1,983 @@
+div#cgit {
+ padding: 0em;
+ margin: 0em;
+ font-family: sans-serif;
+ font-size: 10pt;
+ color: #333;
+ background: white;
+ padding: 4px;
+}
+
+div#cgit a {
+ color: blue;
+ text-decoration: none;
+}
+
+div#cgit a:hover {
+ text-decoration: underline;
+}
+
+div#cgit table {
+ border-collapse: collapse;
+}
+
+div#cgit table#header {
+ width: 100%;
+ margin-bottom: 1em;
+}
+
+div#cgit table#header td.logo {
+ width: 96px;
+ vertical-align: top;
+}
+
+div#cgit table#header td.main {
+ font-size: 250%;
+ padding-left: 10px;
+ white-space: nowrap;
+}
+
+div#cgit table#header td.main a {
+ color: #000;
+}
+
+div#cgit table#header td.form {
+ text-align: right;
+ vertical-align: bottom;
+ padding-right: 1em;
+ padding-bottom: 2px;
+ white-space: nowrap;
+}
+
+div#cgit table#header td.form form,
+div#cgit table#header td.form input,
+div#cgit table#header td.form select {
+ font-size: 90%;
+}
+
+div#cgit table#header td.sub {
+ color: #777;
+ border-top: solid 1px #ccc;
+ padding-left: 10px;
+}
+
+div#cgit table.tabs {
+ border-bottom: solid 3px #ccc;
+ border-collapse: collapse;
+ margin-top: 2em;
+ margin-bottom: 0px;
+ width: 100%;
+}
+
+div#cgit table.tabs td {
+ padding: 0px 1em;
+ vertical-align: bottom;
+}
+
+div#cgit table.tabs td a {
+ padding: 2px 0.75em;
+ color: #777;
+ font-size: 110%;
+}
+
+div#cgit table.tabs td a.active {
+ color: #000;
+ background-color: #ccc;
+}
+
+div#cgit table.tabs a[href^="http://"]:after, div#cgit table.tabs a[href^="https://"]:after {
+ content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAQAAAAnOwc2AAAAAmJLR0QA/4ePzL8AAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfgAhcJDQY+gm2TAAAAHWlUWHRDb21tZW50AAAAAABDcmVhdGVkIHdpdGggR0lNUGQuZQcAAABbSURBVAhbY2BABs4MU4CwhYHBh2Erww4wrGFQZHjI8B8IgUIscJWyDHcggltQhI4zGDCcRwhChPggHIggP1QoAVmQkSETrGoHsiAEsACtBYN0oDAMbgU6EBcAAL2eHUt4XUU4AAAAAElFTkSuQmCC);
+ opacity: 0.5;
+ margin: 0 0 0 5px;
+}
+
+div#cgit table.tabs td.form {
+ text-align: right;
+}
+
+div#cgit table.tabs td.form form {
+ padding-bottom: 2px;
+ font-size: 90%;
+ white-space: nowrap;
+}
+
+div#cgit table.tabs td.form input,
+div#cgit table.tabs td.form select {
+ font-size: 90%;
+}
+
+div#cgit div.path {
+ margin: 0px;
+ padding: 5px 2em 2px 2em;
+ color: #000;
+ background-color: #eee;
+}
+
+div#cgit div.content {
+ margin: 0px;
+ padding: 2em;
+ border-bottom: solid 3px #ccc;
+}
+
+
+div#cgit table.list {
+ width: 100%;
+ border: none;
+ border-collapse: collapse;
+}
+
+div#cgit table.list tr {
+ background: white;
+}
+
+div#cgit table.list tr.logheader {
+ background: #eee;
+}
+
+div#cgit table.list tr:nth-child(even) {
+ background: #f7f7f7;
+}
+
+div#cgit table.list tr:nth-child(odd) {
+ background: white;
+}
+
+div#cgit table.list tr:hover {
+ background: #eee;
+}
+
+div#cgit table.list tr.nohover {
+ background: white;
+}
+
+div#cgit table.list tr.nohover:hover {
+ background: white;
+}
+
+div#cgit table.list tr.nohover-highlight:hover:nth-child(even) {
+ background: #f7f7f7;
+}
+
+div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) {
+ background: white;
+}
+
+div#cgit table.list th {
+ font-weight: bold;
+ /* color: #888;
+ border-top: dashed 1px #888;
+ border-bottom: dashed 1px #888;
+ */
+ padding: 0.1em 0.5em 0.05em 0.5em;
+ vertical-align: baseline;
+}
+
+div#cgit table.list td {
+ border: none;
+ padding: 0.1em 0.5em 0.1em 0.5em;
+}
+
+div#cgit table.list td.commitgraph {
+ font-family: monospace;
+ white-space: pre;
+}
+
+div#cgit table.list td.commitgraph .column1 {
+ color: #a00;
+}
+
+div#cgit table.list td.commitgraph .column2 {
+ color: #0a0;
+}
+
+div#cgit table.list td.commitgraph .column3 {
+ color: #aa0;
+}
+
+div#cgit table.list td.commitgraph .column4 {
+ color: #00a;
+}
+
+div#cgit table.list td.commitgraph .column5 {
+ color: #a0a;
+}
+
+div#cgit table.list td.commitgraph .column6 {
+ color: #0aa;
+}
+
+div#cgit table.list td.logsubject {
+ font-family: monospace;
+ font-weight: bold;
+}
+
+div#cgit table.list td.logmsg {
+ font-family: monospace;
+ white-space: pre;
+ padding: 0 0.5em;
+}
+
+div#cgit table.list td a {
+ color: black;
+}
+
+div#cgit table.list td a.ls-dir {
+ font-weight: bold;
+ color: #00f;
+}
+
+div#cgit table.list td a:hover {
+ color: #00f;
+}
+
+div#cgit img {
+ border: none;
+}
+
+div#cgit input#switch-btn {
+ margin: 2px 0px 0px 0px;
+}
+
+div#cgit td#sidebar input.txt {
+ width: 100%;
+ margin: 2px 0px 0px 0px;
+}
+
+div#cgit table#grid {
+ margin: 0px;
+}
+
+div#cgit td#content {
+ vertical-align: top;
+ padding: 1em 2em 1em 1em;
+ border: none;
+}
+
+div#cgit div#summary {
+ vertical-align: top;
+ margin-bottom: 1em;
+}
+
+div#cgit table#downloads {
+ float: right;
+ border-collapse: collapse;
+ border: solid 1px #777;
+ margin-left: 0.5em;
+ margin-bottom: 0.5em;
+}
+
+div#cgit table#downloads th {
+ background-color: #ccc;
+}
+
+div#cgit div#blob {
+ border: solid 1px black;
+}
+
+div#cgit div.error {
+ color: red;
+ font-weight: bold;
+ margin: 1em 2em;
+}
+
+div#cgit a.ls-blob, div#cgit a.ls-dir, div#cgit .ls-mod {
+ font-family: monospace;
+}
+
+div#cgit td.ls-size {
+ text-align: right;
+ font-family: monospace;
+ width: 10em;
+}
+
+div#cgit td.ls-mode {
+ font-family: monospace;
+ width: 10em;
+}
+
+div#cgit table.blob {
+ margin-top: 0.5em;
+ border-top: solid 1px black;
+}
+
+div#cgit table.blob td.hashes,
+div#cgit table.blob td.lines {
+ margin: 0; padding: 0 0 0 0.5em;
+ vertical-align: top;
+ color: black;
+}
+
+div#cgit table.blob td.linenumbers {
+ margin: 0; padding: 0 0.5em 0 0.5em;
+ vertical-align: top;
+ text-align: right;
+ border-right: 1px solid gray;
+}
+
+div#cgit table.blob pre {
+ padding: 0; margin: 0;
+}
+
+div#cgit table.blob td.linenumbers a,
+div#cgit table.ssdiff td.lineno a {
+ color: gray;
+ text-align: right;
+ text-decoration: none;
+}
+
+div#cgit table.blob td.linenumbers a:hover,
+div#cgit table.ssdiff td.lineno a:hover {
+ color: black;
+}
+
+div#cgit table.blame td.hashes,
+div#cgit table.blame td.lines,
+div#cgit table.blame td.linenumbers {
+ padding: 0;
+}
+
+div#cgit table.blame td.hashes div.alt,
+div#cgit table.blame td.lines div.alt {
+ padding: 0 0.5em 0 0.5em;
+}
+
+div#cgit table.blame td.linenumbers div.alt {
+ padding: 0 0.5em 0 0;
+}
+
+div#cgit table.blame div.alt:nth-child(even) {
+ background: #eee;
+}
+
+div#cgit table.blame div.alt:nth-child(odd) {
+ background: white;
+}
+
+div#cgit table.blame td.lines > div {
+ position: relative;
+}
+
+div#cgit table.blame td.lines > div > pre {
+ padding: 0 0 0 0.5em;
+ position: absolute;
+ top: 0;
+}
+
+div#cgit table.blame .oid {
+ font-size: 100%;
+}
+
+div#cgit table.bin-blob {
+ margin-top: 0.5em;
+ border: solid 1px black;
+}
+
+div#cgit table.bin-blob th {
+ font-family: monospace;
+ white-space: pre;
+ border: solid 1px #777;
+ padding: 0.5em 1em;
+}
+
+div#cgit table.bin-blob td {
+ font-family: monospace;
+ white-space: pre;
+ border-left: solid 1px #777;
+ padding: 0em 1em;
+}
+
+div#cgit table.nowrap td {
+ white-space: nowrap;
+}
+
+div#cgit table.commit-info {
+ border-collapse: collapse;
+ margin-top: 1.5em;
+}
+
+div#cgit div.cgit-panel {
+ float: right;
+ margin-top: 1.5em;
+}
+
+div#cgit div.cgit-panel table {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+ background-color: #eee;
+}
+
+div#cgit div.cgit-panel th {
+ text-align: center;
+}
+
+div#cgit div.cgit-panel td {
+ padding: 0.25em 0.5em;
+}
+
+div#cgit div.cgit-panel td.label {
+ padding-right: 0.5em;
+}
+
+div#cgit div.cgit-panel td.ctrl {
+ padding-left: 0.5em;
+}
+
+div#cgit table.commit-info th {
+ text-align: left;
+ font-weight: normal;
+ padding: 0.1em 1em 0.1em 0.1em;
+ vertical-align: top;
+}
+
+div#cgit table.commit-info td {
+ font-weight: normal;
+ padding: 0.1em 1em 0.1em 0.1em;
+}
+
+div#cgit div.commit-subject {
+ font-weight: bold;
+ font-size: 125%;
+ margin: 1.5em 0em 0.5em 0em;
+ padding: 0em;
+}
+
+div#cgit div.commit-msg {
+ white-space: pre;
+ font-family: monospace;
+}
+
+div#cgit div.notes-header {
+ font-weight: bold;
+ padding-top: 1.5em;
+}
+
+div#cgit div.notes {
+ white-space: pre;
+ font-family: monospace;
+ border: solid 1px #ee9;
+ background-color: #ffd;
+ padding: 0.3em 2em 0.3em 1em;
+ float: left;
+}
+
+div#cgit div.notes-footer {
+ clear: left;
+}
+
+div#cgit div.diffstat-header {
+ font-weight: bold;
+ padding-top: 1.5em;
+}
+
+div#cgit table.diffstat {
+ border-collapse: collapse;
+ border: solid 1px #aaa;
+ background-color: #eee;
+}
+
+div#cgit table.diffstat th {
+ font-weight: normal;
+ text-align: left;
+ text-decoration: underline;
+ padding: 0.1em 1em 0.1em 0.1em;
+ font-size: 100%;
+}
+
+div#cgit table.diffstat td {
+ padding: 0.2em 0.2em 0.1em 0.1em;
+ font-size: 100%;
+ border: none;
+}
+
+div#cgit table.diffstat td.mode {
+ white-space: nowrap;
+}
+
+div#cgit table.diffstat td span.modechange {
+ padding-left: 1em;
+ color: red;
+}
+
+div#cgit table.diffstat td.add a {
+ color: green;
+}
+
+div#cgit table.diffstat td.del a {
+ color: red;
+}
+
+div#cgit table.diffstat td.upd a {
+ color: blue;
+}
+
+div#cgit table.diffstat td.graph {
+ width: 500px;
+ vertical-align: middle;
+}
+
+div#cgit table.diffstat td.graph table {
+ border: none;
+}
+
+div#cgit table.diffstat td.graph td {
+ padding: 0px;
+ border: 0px;
+ height: 7pt;
+}
+
+div#cgit table.diffstat td.graph td.add {
+ background-color: #5c5;
+}
+
+div#cgit table.diffstat td.graph td.rem {
+ background-color: #c55;
+}
+
+div#cgit div.diffstat-summary {
+ color: #888;
+ padding-top: 0.5em;
+}
+
+div#cgit table.diff {
+ width: 100%;
+}
+
+div#cgit table.diff td {
+ font-family: monospace;
+ white-space: pre;
+}
+
+div#cgit table.diff td div.head {
+ font-weight: bold;
+ margin-top: 1em;
+ color: black;
+}
+
+div#cgit table.diff td div.hunk {
+ color: #009;
+}
+
+div#cgit table.diff td div.add {
+ color: green;
+}
+
+div#cgit table.diff td div.del {
+ color: red;
+}
+
+div#cgit .oid {
+ font-family: monospace;
+ font-size: 90%;
+}
+
+div#cgit .left {
+ text-align: left;
+}
+
+div#cgit .right {
+ text-align: right;
+}
+
+div#cgit table.list td.reposection {
+ font-style: italic;
+ color: #888;
+}
+
+div#cgit a.button {
+ font-size: 80%;
+ padding: 0em 0.5em;
+}
+
+div#cgit a.primary {
+ font-size: 100%;
+}
+
+div#cgit a.secondary {
+ font-size: 90%;
+}
+
+div#cgit td.toplevel-repo {
+
+}
+
+div#cgit table.list td.sublevel-repo {
+ padding-left: 1.5em;
+}
+
+div#cgit ul.pager {
+ list-style-type: none;
+ text-align: center;
+ margin: 1em 0em 0em 0em;
+ padding: 0;
+}
+
+div#cgit ul.pager li {
+ display: inline-block;
+ margin: 0.25em 0.5em;
+}
+
+div#cgit ul.pager a {
+ color: #777;
+}
+
+div#cgit ul.pager .current {
+ font-weight: bold;
+}
+
+div#cgit span.age-mins {
+ font-weight: bold;
+ color: #080;
+}
+
+div#cgit span.age-hours {
+ color: #080;
+}
+
+div#cgit span.age-days {
+ color: #040;
+}
+
+div#cgit span.age-weeks {
+ color: #444;
+}
+
+div#cgit span.age-months {
+ color: #888;
+}
+
+div#cgit span.age-years {
+ color: #bbb;
+}
+
+div#cgit span.insertions {
+ color: #080;
+}
+
+div#cgit span.deletions {
+ color: #800;
+}
+
+div#cgit div.footer {
+ margin-top: 0.5em;
+ text-align: center;
+ font-size: 80%;
+ color: #ccc;
+}
+
+div#cgit div.footer a {
+ color: #ccc;
+ text-decoration: none;
+}
+
+div#cgit div.footer a:hover {
+ text-decoration: underline;
+}
+
+div#cgit a.branch-deco {
+ color: #000;
+ margin: 0px 0.5em;
+ padding: 0px 0.25em;
+ background-color: #88ff88;
+ border: solid 1px #007700;
+}
+
+div#cgit a.tag-deco {
+ color: #000;
+ margin: 0px 0.5em;
+ padding: 0px 0.25em;
+ background-color: #ffff88;
+ border: solid 1px #777700;
+}
+
+div#cgit a.tag-annotated-deco {
+ color: #000;
+ margin: 0px 0.5em;
+ padding: 0px 0.25em;
+ background-color: #ffcc88;
+ border: solid 1px #777700;
+}
+
+div#cgit a.remote-deco {
+ color: #000;
+ margin: 0px 0.5em;
+ padding: 0px 0.25em;
+ background-color: #ccccff;
+ border: solid 1px #000077;
+}
+
+div#cgit a.deco {
+ color: #000;
+ margin: 0px 0.5em;
+ padding: 0px 0.25em;
+ background-color: #ff8888;
+ border: solid 1px #770000;
+}
+
+div#cgit div.commit-subject a.branch-deco,
+div#cgit div.commit-subject a.tag-deco,
+div#cgit div.commit-subject a.tag-annotated-deco,
+div#cgit div.commit-subject a.remote-deco,
+div#cgit div.commit-subject a.deco {
+ margin-left: 1em;
+ font-size: 75%;
+}
+
+div#cgit table.stats {
+ border: solid 1px black;
+ border-collapse: collapse;
+}
+
+div#cgit table.stats th {
+ text-align: left;
+ padding: 1px 0.5em;
+ background-color: #eee;
+ border: solid 1px black;
+}
+
+div#cgit table.stats td {
+ text-align: right;
+ padding: 1px 0.5em;
+ border: solid 1px black;
+}
+
+div#cgit table.stats td.total {
+ font-weight: bold;
+ text-align: left;
+}
+
+div#cgit table.stats td.sum {
+ color: #c00;
+ font-weight: bold;
+/* background-color: #eee; */
+}
+
+div#cgit table.stats td.left {
+ text-align: left;
+}
+
+div#cgit table.vgraph {
+ border-collapse: separate;
+ border: solid 1px black;
+ height: 200px;
+}
+
+div#cgit table.vgraph th {
+ background-color: #eee;
+ font-weight: bold;
+ border: solid 1px white;
+ padding: 1px 0.5em;
+}
+
+div#cgit table.vgraph td {
+ vertical-align: bottom;
+ padding: 0px 10px;
+}
+
+div#cgit table.vgraph div.bar {
+ background-color: #eee;
+}
+
+div#cgit table.hgraph {
+ border: solid 1px black;
+ width: 800px;
+}
+
+div#cgit table.hgraph th {
+ background-color: #eee;
+ font-weight: bold;
+ border: solid 1px black;
+ padding: 1px 0.5em;
+}
+
+div#cgit table.hgraph td {
+ vertical-align: middle;
+ padding: 2px 2px;
+}
+
+div#cgit table.hgraph div.bar {
+ background-color: #eee;
+ height: 1em;
+}
+
+div#cgit table.ssdiff {
+ width: 100%;
+}
+
+div#cgit table.ssdiff td {
+ font-size: 75%;
+ font-family: monospace;
+ white-space: pre;
+ padding: 1px 4px 1px 4px;
+ border-left: solid 1px #aaa;
+ border-right: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.add {
+ color: black;
+ background: #cfc;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff td.add_dark {
+ color: black;
+ background: #aca;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff span.add {
+ background: #cfc;
+ font-weight: bold;
+}
+
+div#cgit table.ssdiff td.del {
+ color: black;
+ background: #fcc;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff td.del_dark {
+ color: black;
+ background: #caa;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff span.del {
+ background: #fcc;
+ font-weight: bold;
+}
+
+div#cgit table.ssdiff td.changed {
+ color: black;
+ background: #ffc;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff td.changed_dark {
+ color: black;
+ background: #cca;
+ min-width: 50%;
+}
+
+div#cgit table.ssdiff td.lineno {
+ color: black;
+ background: #eee;
+ text-align: right;
+ width: 3em;
+ min-width: 3em;
+}
+
+div#cgit table.ssdiff td.hunk {
+ color: black;
+ background: #ccf;
+ border-top: solid 1px #aaa;
+ border-bottom: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.head {
+ border-top: solid 1px #aaa;
+ border-bottom: solid 1px #aaa;
+}
+
+div#cgit table.ssdiff td.head div.head {
+ font-weight: bold;
+ color: black;
+}
+
+div#cgit table.ssdiff td.foot {
+ border-top: solid 1px #aaa;
+ border-left: none;
+ border-right: none;
+ border-bottom: none;
+}
+
+div#cgit table.ssdiff td.space {
+ border: none;
+}
+
+div#cgit table.ssdiff td.space div {
+ min-height: 3em;
+}
+
+/* Style definition file generated by highlight 3.9, http://www.andre-simon.de/ */
+/* Highlighting theme: Kwrite Editor */
+/* adapted for cgit */
+div#cgit table.blob .num { color:#b07e00; }
+div#cgit table.blob .esc { color:#ff00ff; }
+div#cgit table.blob .str { color:#bf0303; }
+div#cgit table.blob .pps { color:#818100; }
+div#cgit table.blob .slc { color:#838183; font-style:italic; }
+div#cgit table.blob .com { color:#838183; font-style:italic; }
+div#cgit table.blob .ppc { color:#008200; }
+div#cgit table.blob .opt { color:#000000; }
+div#cgit table.blob .lin { color:#555555; }
+div#cgit table.blob .kwa { color:#000000; font-weight:bold; }
+div#cgit table.blob .kwb { color:#0057ae; }
+div#cgit table.blob .kwc { color:#000000; font-weight:bold; }
+div#cgit table.blob .kwd { color:#010181; }
+
+/* ================= тёмный слой + Monaspace Krypton (Пацанский Гит) ================= */
+/* префикс div#cgit — чтобы перебивать дефолтные правила той же специфичности (идём позже в файле) */
+@font-face { font-family:'Monaspace Krypton'; src:url('/cgit-static/MonaspaceKrypton.woff2') format('woff2'); font-weight:100 800; font-display:swap; }
+
+div#cgit { background:#0f1115; color:#c9d1d9;
+ font-family:'Monaspace Krypton',ui-monospace,SFMono-Regular,Menlo,monospace; }
+div#cgit table, div#cgit td, div#cgit th, div#cgit pre, div#cgit input, div#cgit select, div#cgit textarea, div#cgit a,
+div#cgit td.logsubject, div#cgit td.logmsg, div#cgit td.commitgraph, div#cgit a.ls-blob, div#cgit a.ls-dir,
+div#cgit td.ls-size, div#cgit td.ls-mode, div#cgit table.blob pre, div#cgit table.bin-blob th, div#cgit table.bin-blob td {
+ font-family:'Monaspace Krypton',ui-monospace,SFMono-Regular,Menlo,monospace; }
+
+div#cgit a { color:#58a6ff; }
+div#cgit a:hover { color:#79c0ff; }
+div#cgit h2 { color:#e6edf3; }
+
+div#cgit table#header td.main, div#cgit table#header td.main a { color:#e6edf3; }
+div#cgit table#header td.sub { color:#8b949e; border-top:1px solid #21262d; }
+
+div#cgit table.tabs { background:#161b22; border-bottom:2px solid #30363d; }
+div#cgit table.tabs td a { color:#c9d1d9; }
+div#cgit table.tabs td a.active { color:#fff; background:#21262d; }
+
+div#cgit div.path { color:#c9d1d9; background:#161b22; }
+div#cgit div.content { border-bottom:1px solid #21262d; }
+div#cgit td#content { color:#c9d1d9; }
+
+div#cgit table.list tr, div#cgit table.list tr:nth-child(odd) { background:#0f1115; }
+div#cgit table.list tr:nth-child(even) { background:#0d1117; }
+div#cgit table.list tr.logheader, div#cgit table.list th { background:#161b22; color:#8b949e; }
+div#cgit table.list tr:hover,
+div#cgit table.list tr.nohover-highlight:hover:nth-child(even),
+div#cgit table.list tr.nohover-highlight:hover:nth-child(odd) { background:#161b22; }
+div#cgit table.list tr.nohover, div#cgit table.list tr.nohover:hover { background:#0f1115; }
+div#cgit table.list td a { color:#c9d1d9; }
+div#cgit table.list td a.ls-dir { color:#58a6ff; }
+div#cgit table.list td a:hover { color:#79c0ff; }
+
+div#cgit .sha1, div#cgit .oid, div#cgit td.sha1 { color:#8b949e; }
+div#cgit div.commit-subject, div#cgit div.commit-msg { color:#e6edf3; }
+div#cgit table.commit-info, div#cgit div.commit-info { color:#8b949e; }
+div#cgit table.commit-info th, div#cgit table.commit-info td { color:#c9d1d9; }
+
+div#cgit div#blob { border-color:#30363d; }
+div#cgit table.blob td.lines pre, div#cgit table.blob td.hashes { color:#c9d1d9; }
+div#cgit table.blob td.linenumbers a { color:#484f58; }
+div#cgit table.blob td.linenumbers a:hover { color:#c9d1d9; }
+div#cgit table.blob td.linenumbers { border-right:1px solid #30363d; }
+
+div#cgit table.diff td div.head { color:#e6edf3; }
+div#cgit table.diff td div.hunk { color:#58a6ff; background:#161b22; }
+div#cgit table.diff td div.add { color:#3fb950; background:#0d2818; }
+div#cgit table.diff td div.del { color:#f85149; background:#2a0d0d; }
+
+div#cgit input, div#cgit select, div#cgit textarea { background:#0d1117; color:#c9d1d9; border:1px solid #30363d; }
+div#cgit div.footer { color:#6e7681; border-top:1px solid #21262d; }
+div#cgit td, div#cgit th { border-color:#21262d; }
+
+/* ref-бейджи (HEAD/ветки/теги) под тёмную тему */
+div#cgit a.branch-deco { color:#3fb950; background:#0d2818; border:1px solid #238636; padding:0 5px; border-radius:5px; }
+div#cgit a.tag-deco { color:#d29922; background:#2b2000; border:1px solid #9e6a03; padding:0 5px; border-radius:5px; }
+div#cgit a.remote-deco { color:#a371f7; background:#1d1533; border:1px solid #6e40c9; padding:0 5px; border-radius:5px; }
+div#cgit a.deco { color:#c9d1d9; background:#161b22; border:1px solid #30363d; padding:0 5px; border-radius:5px; }
+
+/* фикс «белых краёв»: cgit красит только div#cgit, а body/html остаются дефолтно-белыми */
+html, body { background:#0f1115 !important; margin:0; padding:0; }
+div#cgit { min-height:100vh; box-sizing:border-box; }
diff --git a/web/entrypoint.sh b/web/entrypoint.sh
new file mode 100755
index 0000000..f92af30
--- /dev/null
+++ b/web/entrypoint.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+set -e
+mkdir -p /repos
+# fcgiwrap на unix-сокете (его дёргает nginx для cgit.cgi и git-http-backend)
+rm -f /run/fcgiwrap.sock
+spawn-fcgi -s /run/fcgiwrap.sock -M 0666 -F 4 -- /usr/sbin/fcgiwrap
+exec nginx -g 'daemon off;'
diff --git a/web/nginx.conf b/web/nginx.conf
new file mode 100644
index 0000000..4477a8d
--- /dev/null
+++ b/web/nginx.conf
@@ -0,0 +1,39 @@
+user root;
+worker_processes 1;
+events { worker_connections 256; }
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+ sendfile on;
+ server {
+ listen 80 default_server;
+ server_name _;
+
+ # статика cgit (css/иконки)
+ location /cgit-static/ {
+ alias /usr/share/cgit/;
+ access_log off;
+ }
+
+ # git smart-HTTP: clone/push по /<repo>.git/...
+ location ~ ^/(?<gitrepo>[a-zA-Z0-9._/-]+\.git)(?<gitrest>/.*)?$ {
+ client_max_body_size 0;
+ include /etc/nginx/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/lib/git-core/git-http-backend;
+ fastcgi_param GIT_HTTP_EXPORT_ALL "";
+ fastcgi_param GIT_PROJECT_ROOT /repos;
+ fastcgi_param PATH_INFO /$gitrepo$gitrest;
+ fastcgi_pass unix:/run/fcgiwrap.sock;
+ }
+
+ # cgit — браузинг всего остального
+ location / {
+ include /etc/nginx/fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi;
+ fastcgi_param PATH_INFO $uri;
+ fastcgi_param QUERY_STRING $query_string;
+ fastcgi_param HTTP_HOST $http_host;
+ fastcgi_pass unix:/run/fcgiwrap.sock;
+ }
+ }
+}
diff --git a/web/pg-favicon.png b/web/pg-favicon.png
new file mode 100644
index 0000000..b90ce12
--- /dev/null
+++ b/web/pg-favicon.png
Binary files differ
diff --git a/web/pg-logo.png b/web/pg-logo.png
new file mode 100644
index 0000000..03be6ce
--- /dev/null
+++ b/web/pg-logo.png
Binary files differ