254 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|