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:// or postgres://) - 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) } }