// 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/user@domain/. // // 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 }