git@cosmocr.at/gitweb.git @ HEAD · summary refs log
HEAD / main.go /
main.go · 5.5 KiB · raw
view: Source ☐ force
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// 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>/<path-to-repo>.
//
// 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
}