// Command gitweb serves git repositories stored in upspin over HTTPS: a browse // UI plus the git smart-HTTP protocol for clone and push. It runs as a single // upspin user (git@cosmocr.at); a repo owner publishes a path by granting that // user access in an upspin Access file. The upspin path is the URL path, so a // repo lives at https://git.cosmocr.at//. // // Public vs private is derived from the repo's governing Access file: a repo // readable by all is public (browse and clone anonymously); otherwise callers // must authenticate with HTTP Basic auth, supplying either a vault-issued SSO // token (see cmd/git-credential-cosmocr) or a personal access token. Push // requires write access in the Access file. See README.md. package main import ( "crypto/tls" "embed" "flag" "io/fs" "log" "net/http" "os" "sync" "time" "gitweb/internal/auth" "gitweb/internal/server" "gitweb/internal/store" "upspin.io/config" "upspin.io/rpc" "upspin.io/transports" "upspin.io/upspin" // Packers used by the upspin client when reading packed files. _ "upspin.io/pack/ee" _ "upspin.io/pack/eeintegrity" _ "upspin.io/pack/plain" ) //go:embed all:web var embeddedWeb embed.FS func main() { var ( addr = flag.String("addr", ":8446", "listen address") certFile = flag.String("tls_cert", "", "TLS certificate (PEM)") keyFile = flag.String("tls_key", "", "TLS key (PEM)") upspinConf = flag.String("upspin_config", "/gitweb/config", "upspin config file for the gitweb user") webDir = flag.String("web", "", "serve static assets from this dir (default: embedded)") devDir = flag.String("dev", "", "if set, read files from this local dir instead of upspin (dev only)") ssoIss = flag.String("sso_issuer", "vault@cosmocr.at", "upspin user that issues SSO tokens; its keyserver public key is trusted ('' disables SSO auth)") ssoAud = flag.String("sso_audience", "gitweb", "expected audience of accepted SSO tokens") ssoAuthURL = flag.String("sso_authorize_url", "https://vault.cosmocr.at/authorize", "issuer's browser login endpoint; the browser SSO 'Sign in' flow is enabled when set") baseURL = flag.String("base_url", "https://git.cosmocr.at", "this service's public base URL, used to build the SSO redirect_uri") cacheDir = flag.String("cache_dir", "", "repo cache directory — ALL its contents are DELETED at startup (default: fresh dir under the system temp dir)") cacheMB = flag.Int64("cache_budget_mb", 512, "target total size of the on-disk repo cache, in MiB") maxRepoMB = flag.Int64("max_repo_mb", 256, "reject repos larger than this when materializing, in MiB") ) flag.Parse() var st store.WriteStore var vault *auth.VaultVerifier if *devDir != "" { st = store.NewLocalStore(*devDir) log.Printf("gitweb: DEV MODE — local file store at %s", *devDir) } else { cfg, err := config.FromFile(*upspinConf) if err != nil { log.Fatalf("gitweb: load upspin config: %v", err) } transports.Init(cfg) st = store.NewUpspinStore(cfg) if *ssoIss != "" { // Verify SSO tokens by looking up the issuer's public key through // the upspin keyserver — no runtime call to the issuer service. lookup := rpc.PublicUserKeyService(cfg) vault = auth.NewVaultVerifier(upspin.UserName(*ssoIss), *ssoAud, auth.KeyLookup(lookup)) log.Printf("gitweb: accepting SSO tokens from %s (aud=%s)", *ssoIss, *ssoAud) } } var webFS http.FileSystem if *webDir != "" { webFS = http.Dir(*webDir) } else { sub, err := fs.Sub(embeddedWeb, "web") if err != nil { log.Fatalf("gitweb: embed web: %v", err) } webFS = http.FS(sub) } srv, err := server.New(server.Config{ Store: st, Web: webFS, Vault: vault, SSOAuthorizeURL: *ssoAuthURL, SSOAudience: *ssoAud, BaseURL: *baseURL, CacheDir: *cacheDir, CacheBudgetBytes: *cacheMB << 20, MaxRepoBytes: *maxRepoMB << 20, }) if err != nil { log.Fatalf("gitweb: init server: %v", err) } httpSrv := &http.Server{ Addr: *addr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, } log.Printf("gitweb: serving on %s", *addr) if *certFile == "" || *keyFile == "" { log.Fatal(httpSrv.ListenAndServe()) // dev only (e.g. localhost) } httpSrv.TLSConfig = &tls.Config{ MinVersion: tls.VersionTLS12, GetCertificate: (&certReloader{certFile: *certFile, keyFile: *keyFile}).get, } log.Fatal(httpSrv.ListenAndServeTLS("", "")) } // certReloader serves a file-based TLS certificate, reloading it when the files // change so certbot renewals are picked up without a restart. (Copied from the // vault/markdown services, which share this deployment model.) type certReloader struct { certFile, keyFile string mu sync.RWMutex cert *tls.Certificate modTime time.Time } func (r *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) { mt := newest(r.certFile, r.keyFile) r.mu.RLock() cert, loaded := r.cert, r.modTime r.mu.RUnlock() if cert == nil || mt.After(loaded) { c, err := tls.LoadX509KeyPair(r.certFile, r.keyFile) if err != nil { if cert != nil { log.Printf("gitweb: cert reload failed, keeping previous: %v", err) return cert, nil } return nil, err } r.mu.Lock() r.cert, r.modTime = &c, mt r.mu.Unlock() cert = &c } return cert, nil } func newest(files ...string) time.Time { var t time.Time for _, f := range files { if fi, err := os.Stat(f); err == nil && fi.ModTime().After(t) { t = fi.ModTime() } } return t }