Files
simplegit/cmd/daemon/main.go
T

254 lines
8.1 KiB
Go

package daemon
import (
"flag"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"simplegit/common"
"simplegit/gitrpc"
"simplegit/state"
)
func Run(args []string) {
fs := flag.NewFlagSet("daemon", flag.ExitOnError)
var cfg common.Config
fs.StringVar(&cfg.Root, "root", ".simplegit-data", "simplegit root")
fs.StringVar(&cfg.SSHAddr, "ssh", "127.0.1.1:2222", "SSH listen address (git clone/push, key auth); empty disables")
fs.StringVar(&cfg.HTTPAddr, "http", "127.0.1.1:8093", "HTTP listen address for git smart-HTTP clone/push (/:owner/:name.git/*) + index page; empty disables")
fs.Func("gitrpc", "Connect-RPC listen address(es) for content read/write/modify (GitService+ManageService) + /raw+/archive+/patch. Comma-separated and/or repeated. Each is unix:///path (e.g. a local TUI) or host:port (e.g. the console). Empty disables gitrpc entirely.",
func(s string) error {
for _, a := range strings.Split(s, ",") {
if a = strings.TrimSpace(a); a != "" {
cfg.GitRPCAddrs = append(cfg.GitRPCAddrs, a)
}
}
return nil
})
fs.StringVar(&cfg.HookScript, "hook-script", "./hook.sh", "script run by every Git hook; receives hook type and owner/name as its first two arguments")
fs.Func("hook-arg", "fixed argument passed to -hook-script before hook type and owner/name (repeatable)", func(arg string) error {
cfg.HookArgs = append(cfg.HookArgs, arg)
return nil
})
fs.StringVar(&cfg.DBUri, "db", "sqlite://./db.sqlite", "private state DB URI (sqlite://<path> or postgres://<dsn>) - backs PAT/SSH-key ACLs and repo metadata")
fs.Parse(args)
if err := cfg.Prepare(); err != nil {
log.Fatalf("simplegit: config prepare failed: %v", err)
}
// simplegit does no JWT/JWKS verification: it is the git kernel, reached
// over the internal network. smart-HTTP auths with PATs and SSH with keys
// against this process's own state DB (opened below). gitrpc Connect is
// trusted-open at the internal hop (only the console/nginx gateway reach it).
store, err := state.Open(&cfg)
if err != nil {
log.Fatalf("simplegit: open state db: %v", err)
}
app := NewApp(cfg.RepoRoot(), store)
server := gitrpc.NewServer(&cfg, store)
if cfg.HookScript != "" {
hookScript, err := resolveHookScript(cfg.HookScript)
if err != nil {
log.Fatalf("simplegit: resolve hook script: %v", err)
}
info, err := os.Stat(hookScript)
if err != nil {
log.Fatalf("simplegit: hook script: %v", err)
}
if info.Mode()&0o111 == 0 {
log.Fatalf("simplegit: hook script %s is not executable", hookScript)
}
cfg.HookScript = hookScript
log.Printf("simplegit: Git hooks execute %s %s", hookScript, strings.Join(cfg.HookArgs, " "))
}
if _, err := ensureHookTemplate(cfg.Root, cfg.HookScript, cfg.HookArgs); err != nil {
log.Printf("simplegit: hook template: %v (hooks disabled)", err)
}
// gitrpc mux: Connect-RPC (GitService + ManageService) + the binary content
// endpoints /raw, /archive, /patch. Served on EVERY -gitrpc address (a unix
// socket for a local TUI and a tcp addr for the console can both be live).
gmux := http.NewServeMux()
if path, h := server.ConnectHandler(); path != "" {
gmux.Handle(path, h)
}
if mPath, h := server.ManageConnectHandler(); mPath != "" {
gmux.Handle(mPath, h)
}
gmux.Handle("GET /raw", server)
gmux.Handle("GET /archive", server)
gmux.Handle("GET /patch", server)
// http mux: git smart-HTTP clone/push + the index page only. Content reads
// (/raw /archive /patch) and Connect-RPC live on gitrpc above, not here.
hmux := http.NewServeMux()
indexH := &indexHandler{store: store, reporoot: cfg.RepoRoot(), sshAddr: cfg.SSHAddr}
hmux.Handle("GET /{$}", indexH)
hmux.Handle("GET /index.html", indexH)
hmux.Handle("/", app)
if cfg.HTTPAddr == "" && cfg.SSHAddr == "" && len(cfg.GitRPCAddrs) == 0 {
log.Fatalf("simplegit: no listeners configured (-http/-ssh/-gitrpc all empty)")
}
var wg sync.WaitGroup
for _, addr := range cfg.GitRPCAddrs {
wg.Add(1)
go func(addr string) {
defer wg.Done()
ln, err := listenAddr(addr)
if err != nil {
log.Fatalf("gitrpc listen %s: %v", addr, err)
}
defer ln.Close()
log.Printf("gitrpc listening on %s", addr)
if err := http.Serve(ln, gmux); err != nil {
log.Fatalf("gitrpc serve %s: %v", addr, err)
}
}(addr)
}
if cfg.HTTPAddr != "" {
wg.Add(1)
go func() {
defer wg.Done()
log.Printf("http listening on %s", cfg.HTTPAddr)
if err := http.ListenAndServe(cfg.HTTPAddr, hmux); err != nil {
log.Fatalf("http: %v", err)
}
}()
}
if cfg.SSHAddr != "" {
wg.Add(1)
go func() {
defer wg.Done()
log.Printf("SSH listening on %s", cfg.SSHAddr)
if err := app.ServeSSH(cfg.SSHAddr); err != nil {
log.Fatalf("ssh: %v", err)
}
}()
}
wg.Wait()
}
var gitHookNames = []string{
"pre-receive", "update", "post-receive", "post-update", "proc-receive",
"push-to-checkout", "reference-transaction", "pre-applypatch",
"post-applypatch", "pre-commit", "pre-merge-commit", "prepare-commit-msg",
"commit-msg", "post-commit", "pre-rebase", "post-checkout", "post-merge",
"pre-push", "pre-auto-gc", "post-rewrite", "sendemail-validate",
"fsmonitor-watchman", "p4-pre-submit", "p4-post-changelist",
"p4-prepare-changelist", "p4-changelist",
}
const hookWrapperTemplate = `#!/bin/sh
# Generated by simplegit. The operator controls behavior with -hook-script.
repo_dir=$(pwd -P)
repo_name=${repo_dir##*/}
repo_name=${repo_name%%.git}
owner_dir=${repo_dir%%/*}
owner=${owner_dir##*/}
repo=$owner/$repo_name
exec %s %s "$repo" "$@"
`
func ensureHookTemplate(root, script string, args []string) (string, error) {
dir := filepath.Join(root, "template")
hooksDir := filepath.Join(dir, "hooks")
if err := os.MkdirAll(hooksDir, 0o755); err != nil {
return "", fmt.Errorf("create hook template: %w", err)
}
for _, name := range gitHookNames {
path := filepath.Join(hooksDir, name)
body := "#!/bin/sh\nexit 0\n"
if script != "" {
command := shellQuote(script)
for _, arg := range args {
command += " " + shellQuote(arg)
}
body = fmt.Sprintf(hookWrapperTemplate, command, shellQuote(name))
}
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
return "", fmt.Errorf("write hook %s: %w", name, err)
}
}
if err := refreshRepositoryHooks(filepath.Join(root, "repos"), hooksDir); err != nil {
return "", err
}
return dir, nil
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}
func refreshRepositoryHooks(reposRoot, templateHooks string) error {
if _, err := os.Stat(reposRoot); os.IsNotExist(err) {
return nil
}
return filepath.WalkDir(reposRoot, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() || !strings.HasSuffix(entry.Name(), ".git") {
return nil
}
dst := filepath.Join(path, "hooks")
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
for _, name := range gitHookNames {
body, err := os.ReadFile(filepath.Join(templateHooks, name))
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dst, name), body, 0o755); err != nil {
return err
}
}
return filepath.SkipDir
})
}
// resolveHookScript first honors paths relative to the working directory
// (convenient for `go run`), then looks beside the executable (production).
func resolveHookScript(path string) (string, error) {
if filepath.IsAbs(path) {
return path, nil
}
if abs, err := filepath.Abs(path); err == nil {
if _, statErr := os.Stat(abs); statErr == nil {
return abs, nil
}
}
bin, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Join(filepath.Dir(bin), filepath.Base(path)), nil
}
// listenAddr listens on a gitrpc address: unix:///path -> a unix socket
// (stale socket file removed first so restart doesn't fail), otherwise
// host:port (an optional tcp:// prefix is accepted) -> tcp.
func listenAddr(addr string) (net.Listener, error) {
switch {
case strings.HasPrefix(addr, "unix://"):
path := strings.TrimPrefix(addr, "unix://")
_ = os.Remove(path)
return net.Listen("unix", path)
case strings.HasPrefix(addr, "tcp://"):
return net.Listen("tcp", strings.TrimPrefix(addr, "tcp://"))
default:
return net.Listen("tcp", addr)
}
}