fork of whitequark.org/git-pages with mods for tangled
at main 13 kB view raw
1package git_pages 2 3import ( 4 "context" 5 "crypto/tls" 6 "errors" 7 "flag" 8 "fmt" 9 "io" 10 "log" 11 "log/slog" 12 "net" 13 "net/http" 14 "net/http/httputil" 15 "net/url" 16 "os" 17 "runtime/debug" 18 "strings" 19 20 automemlimit "github.com/KimMachineGun/automemlimit/memlimit" 21 "github.com/c2h5oh/datasize" 22 "github.com/prometheus/client_golang/prometheus/promhttp" 23) 24 25var config *Config 26var wildcards []*WildcardPattern 27var fallback http.Handler 28var backend Backend 29 30func configureFeatures(ctx context.Context) (err error) { 31 if len(config.Features) > 0 { 32 logc.Println(ctx, "features:", strings.Join(config.Features, ", ")) 33 } 34 return 35} 36 37func configureMemLimit(ctx context.Context) (err error) { 38 // Avoid being OOM killed by not garbage collecting early enough. 39 memlimitBefore := datasize.ByteSize(debug.SetMemoryLimit(-1)) 40 automemlimit.SetGoMemLimitWithOpts( 41 automemlimit.WithLogger(slog.New(slog.DiscardHandler)), 42 automemlimit.WithProvider( 43 automemlimit.ApplyFallback( 44 automemlimit.FromCgroup, 45 automemlimit.FromSystem, 46 ), 47 ), 48 automemlimit.WithRatio(float64(config.Limits.MaxHeapSizeRatio)), 49 ) 50 memlimitAfter := datasize.ByteSize(debug.SetMemoryLimit(-1)) 51 if memlimitBefore == memlimitAfter { 52 logc.Println(ctx, "memlimit: now", memlimitBefore.HR()) 53 } else { 54 logc.Println(ctx, "memlimit: was", memlimitBefore.HR(), "now", memlimitAfter.HR()) 55 } 56 return 57} 58 59func configureWildcards(_ context.Context) (err error) { 60 newWildcards, err := TranslateWildcards(config.Wildcard) 61 if err != nil { 62 return err 63 } else { 64 wildcards = newWildcards 65 return nil 66 } 67} 68 69func configureFallback(_ context.Context) (err error) { 70 if config.Fallback.ProxyTo != "" { 71 var fallbackURL *url.URL 72 fallbackURL, err = url.Parse(config.Fallback.ProxyTo) 73 if err != nil { 74 err = fmt.Errorf("fallback: %w", err) 75 return 76 } 77 78 fallback = &httputil.ReverseProxy{ 79 Rewrite: func(r *httputil.ProxyRequest) { 80 r.SetURL(fallbackURL) 81 r.Out.Host = r.In.Host 82 r.Out.Header["X-Forwarded-For"] = r.In.Header["X-Forwarded-For"] 83 }, 84 Transport: &http.Transport{ 85 TLSClientConfig: &tls.Config{ 86 InsecureSkipVerify: config.Fallback.Insecure, 87 }, 88 }, 89 } 90 } 91 return 92} 93 94func listen(ctx context.Context, name string, listen string) net.Listener { 95 if listen == "-" { 96 return nil 97 } 98 99 protocol, address, ok := strings.Cut(listen, "/") 100 if !ok { 101 logc.Fatalf(ctx, "%s: %s: malformed endpoint", name, listen) 102 } 103 104 listener, err := net.Listen(protocol, address) 105 if err != nil { 106 logc.Fatalf(ctx, "%s: %s\n", name, err) 107 } 108 109 return listener 110} 111 112func panicHandler(handler http.Handler) http.Handler { 113 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 114 defer func() { 115 if err := recover(); err != nil { 116 logc.Printf(r.Context(), "panic: %s %s %s: %s\n%s", 117 r.Method, r.Host, r.URL.Path, err, string(debug.Stack())) 118 http.Error(w, 119 fmt.Sprintf("internal server error: %s", err), 120 http.StatusInternalServerError, 121 ) 122 } 123 }() 124 handler.ServeHTTP(w, r) 125 }) 126} 127 128func serve(ctx context.Context, listener net.Listener, handler http.Handler) { 129 if listener != nil { 130 handler = panicHandler(handler) 131 132 server := http.Server{Handler: handler} 133 server.Protocols = new(http.Protocols) 134 server.Protocols.SetHTTP1(true) 135 if config.Feature("serve-h2c") { 136 server.Protocols.SetUnencryptedHTTP2(true) 137 } 138 logc.Fatalln(ctx, server.Serve(listener)) 139 } 140} 141 142func webRootArg(arg string) string { 143 switch strings.Count(arg, "/") { 144 case 0: 145 return arg + "/.index" 146 case 1: 147 return arg 148 default: 149 logc.Fatalln(context.Background(), 150 "webroot argument must be either 'domain.tld' or 'domain.tld/dir") 151 return "" 152 } 153} 154 155func fileOutputArg() (writer io.WriteCloser) { 156 var err error 157 if flag.NArg() == 0 { 158 writer = os.Stdout 159 } else { 160 writer, err = os.Create(flag.Arg(0)) 161 if err != nil { 162 logc.Fatalln(context.Background(), err) 163 } 164 } 165 return 166} 167 168func usage() { 169 fmt.Fprintf(os.Stderr, "Usage:\n") 170 fmt.Fprintf(os.Stderr, "(server) "+ 171 "git-pages [-config <file>|-no-config]\n") 172 fmt.Fprintf(os.Stderr, "(admin) "+ 173 "git-pages {-run-migration <name>|-freeze-domain <domain>|-unfreeze-domain <domain>}\n") 174 fmt.Fprintf(os.Stderr, "(info) "+ 175 "git-pages {-print-config-env-vars|-print-config}\n") 176 fmt.Fprintf(os.Stderr, "(cli) "+ 177 "git-pages {-get-blob|-get-manifest|-get-archive|-update-site} <ref> [file]\n") 178 flag.PrintDefaults() 179} 180 181func Main() { 182 ctx := context.Background() 183 184 flag.Usage = usage 185 printConfigEnvVars := flag.Bool("print-config-env-vars", false, 186 "print every recognized configuration environment variable and exit") 187 printConfig := flag.Bool("print-config", false, 188 "print configuration as JSON and exit") 189 configTomlPath := flag.String("config", "", 190 "load configuration from `filename` (default: 'config.toml')") 191 noConfig := flag.Bool("no-config", false, 192 "run without configuration file (configure via environment variables)") 193 runMigration := flag.String("run-migration", "", 194 "run a store `migration` (one of: create-domain-markers)") 195 getBlob := flag.String("get-blob", "", 196 "write contents of `blob` ('sha256-xxxxxxx...xxx')") 197 getManifest := flag.String("get-manifest", "", 198 "write manifest for `site` (either 'domain.tld' or 'domain.tld/dir') as ProtoJSON") 199 getArchive := flag.String("get-archive", "", 200 "write archive for `site` (either 'domain.tld' or 'domain.tld/dir') in tar format") 201 updateSite := flag.String("update-site", "", 202 "update `site` (either 'domain.tld' or 'domain.tld/dir') from archive or repository URL") 203 freezeDomain := flag.String("freeze-domain", "", 204 "prevent any site uploads to a given `domain`") 205 unfreezeDomain := flag.String("unfreeze-domain", "", 206 "allow site uploads to a `domain` again after it has been frozen") 207 flag.Parse() 208 209 var cliOperations int 210 if *runMigration != "" { 211 cliOperations += 1 212 } 213 if *getBlob != "" { 214 cliOperations += 1 215 } 216 if *getManifest != "" { 217 cliOperations += 1 218 } 219 if *getArchive != "" { 220 cliOperations += 1 221 } 222 if *updateSite != "" { 223 cliOperations += 1 224 } 225 if *freezeDomain != "" { 226 cliOperations += 1 227 } 228 if *unfreezeDomain != "" { 229 cliOperations += 1 230 } 231 if cliOperations > 1 { 232 logc.Fatalln(ctx, "-get-blob, -get-manifest, -get-archive, -update-site, -freeze, and -unfreeze are mutually exclusive") 233 } 234 235 if *configTomlPath != "" && *noConfig { 236 logc.Fatalln(ctx, "-no-config and -config are mutually exclusive") 237 } 238 239 if *printConfigEnvVars { 240 PrintConfigEnvVars() 241 return 242 } 243 244 var err error 245 if *configTomlPath == "" && !*noConfig { 246 *configTomlPath = "config.toml" 247 } 248 if config, err = Configure(*configTomlPath); err != nil { 249 logc.Fatalln(ctx, "config:", err) 250 } 251 252 if *printConfig { 253 fmt.Println(config.DebugJSON()) 254 return 255 } 256 257 InitObservability() 258 defer FiniObservability() 259 260 if err = errors.Join( 261 configureFeatures(ctx), 262 configureMemLimit(ctx), 263 configureWildcards(ctx), 264 configureFallback(ctx), 265 ); err != nil { 266 logc.Fatalln(ctx, err) 267 } 268 269 switch { 270 case *runMigration != "": 271 if backend, err = CreateBackend(&config.Storage); err != nil { 272 logc.Fatalln(ctx, err) 273 } 274 275 if err := RunMigration(ctx, *runMigration); err != nil { 276 logc.Fatalln(ctx, err) 277 } 278 279 case *getBlob != "": 280 if backend, err = CreateBackend(&config.Storage); err != nil { 281 logc.Fatalln(ctx, err) 282 } 283 284 reader, _, _, err := backend.GetBlob(ctx, *getBlob) 285 if err != nil { 286 logc.Fatalln(ctx, err) 287 } 288 io.Copy(fileOutputArg(), reader) 289 290 case *getManifest != "": 291 if backend, err = CreateBackend(&config.Storage); err != nil { 292 logc.Fatalln(ctx, err) 293 } 294 295 webRoot := webRootArg(*getManifest) 296 manifest, _, err := backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 297 if err != nil { 298 logc.Fatalln(ctx, err) 299 } 300 fmt.Fprintln(fileOutputArg(), ManifestDebugJSON(manifest)) 301 302 case *getArchive != "": 303 if backend, err = CreateBackend(&config.Storage); err != nil { 304 logc.Fatalln(ctx, err) 305 } 306 307 webRoot := webRootArg(*getArchive) 308 manifest, manifestMtime, err := 309 backend.GetManifest(ctx, webRoot, GetManifestOptions{}) 310 if err != nil { 311 logc.Fatalln(ctx, err) 312 } 313 CollectTar(ctx, fileOutputArg(), manifest, manifestMtime) 314 315 case *updateSite != "": 316 if backend, err = CreateBackend(&config.Storage); err != nil { 317 logc.Fatalln(ctx, err) 318 } 319 320 if flag.NArg() != 1 { 321 logc.Fatalln(ctx, "update source must be provided as the argument") 322 } 323 324 sourceURL, err := url.Parse(flag.Arg(0)) 325 if err != nil { 326 logc.Fatalln(ctx, err) 327 } 328 329 var result UpdateResult 330 if sourceURL.Scheme == "" { 331 file, err := os.Open(sourceURL.Path) 332 if err != nil { 333 logc.Fatalln(ctx, err) 334 } 335 defer file.Close() 336 337 var contentType string 338 switch { 339 case strings.HasSuffix(sourceURL.Path, ".zip"): 340 contentType = "application/zip" 341 case strings.HasSuffix(sourceURL.Path, ".tar"): 342 contentType = "application/x-tar" 343 case strings.HasSuffix(sourceURL.Path, ".tar.gz"): 344 contentType = "application/x-tar+gzip" 345 case strings.HasSuffix(sourceURL.Path, ".tar.zst"): 346 contentType = "application/x-tar+zstd" 347 default: 348 log.Fatalf("cannot determine content type from filename %q\n", sourceURL) 349 } 350 351 webRoot := webRootArg(*updateSite) 352 result = UpdateFromArchive(ctx, webRoot, contentType, file) 353 } else { 354 branch := "pages" 355 if sourceURL.Fragment != "" { 356 branch, sourceURL.Fragment = sourceURL.Fragment, "" 357 } 358 359 webRoot := webRootArg(*updateSite) 360 result = UpdateFromRepository(ctx, webRoot, sourceURL.String(), branch) 361 } 362 363 switch result.outcome { 364 case UpdateError: 365 logc.Printf(ctx, "error: %s\n", result.err) 366 os.Exit(2) 367 case UpdateTimeout: 368 logc.Println(ctx, "timeout") 369 os.Exit(1) 370 case UpdateCreated: 371 logc.Println(ctx, "created") 372 case UpdateReplaced: 373 logc.Println(ctx, "replaced") 374 case UpdateDeleted: 375 logc.Println(ctx, "deleted") 376 case UpdateNoChange: 377 logc.Println(ctx, "no-change") 378 } 379 380 case *freezeDomain != "" || *unfreezeDomain != "": 381 var domain string 382 var freeze bool 383 if *freezeDomain != "" { 384 domain = *freezeDomain 385 freeze = true 386 } else { 387 domain = *unfreezeDomain 388 freeze = false 389 } 390 391 if backend, err = CreateBackend(&config.Storage); err != nil { 392 logc.Fatalln(ctx, err) 393 } 394 395 if err = backend.FreezeDomain(ctx, domain, freeze); err != nil { 396 logc.Fatalln(ctx, err) 397 } 398 if freeze { 399 log.Println("frozen") 400 } else { 401 log.Println("thawed") 402 } 403 404 default: 405 // Hook a signal (SIGHUP on *nix, nothing on Windows) for reloading the configuration 406 // at runtime. This is useful because it preserves S3 backend cache contents. Failed 407 // configuration reloads will not crash the process; you may want to check the syntax 408 // first with `git-pages -config ... -print-config` since there is no other feedback. 409 // 410 // Note that not all of the configuration is updated on reload. Listeners are kept as-is. 411 // The backend is not recreated (this is intentional as it allows preserving the cache). 412 OnReload(func() { 413 if newConfig, err := Configure(*configTomlPath); err != nil { 414 logc.Println(ctx, "config: reload err:", err) 415 } else { 416 // From https://go.dev/ref/mem: 417 // > A read r of a memory location x holding a value that is not larger than 418 // > a machine word must observe some write w such that r does not happen before 419 // > w and there is no write w' such that w happens before w' and w' happens 420 // > before r. That is, each read must observe a value written by a preceding or 421 // > concurrent write. 422 config = newConfig 423 if err = errors.Join( 424 configureFeatures(ctx), 425 configureMemLimit(ctx), 426 configureWildcards(ctx), 427 configureFallback(ctx), 428 ); err != nil { 429 // At this point the configuration is in an in-between, corrupted state, so 430 // the only reasonable choice is to crash. 431 logc.Fatalln(ctx, "config: reload fail:", err) 432 } else { 433 logc.Println(ctx, "config: reload ok") 434 } 435 } 436 }) 437 438 // Start listening on all ports before initializing the backend, otherwise if the backend 439 // spends some time initializing (which the S3 backend does) a proxy like Caddy can race 440 // with git-pages on startup and return errors for requests that would have been served 441 // just 0.5s later. 442 pagesListener := listen(ctx, "pages", config.Server.Pages) 443 caddyListener := listen(ctx, "caddy", config.Server.Caddy) 444 metricsListener := listen(ctx, "metrics", config.Server.Metrics) 445 446 if backend, err = CreateBackend(&config.Storage); err != nil { 447 logc.Fatalln(ctx, err) 448 } 449 backend = NewObservedBackend(backend) 450 451 go serve(ctx, pagesListener, ObserveHTTPHandler(http.HandlerFunc(ServePages))) 452 go serve(ctx, caddyListener, ObserveHTTPHandler(http.HandlerFunc(ServeCaddy))) 453 go serve(ctx, metricsListener, promhttp.Handler()) 454 455 if config.Insecure { 456 logc.Println(ctx, "serve: ready (INSECURE)") 457 } else { 458 logc.Println(ctx, "serve: ready") 459 } 460 461 WaitForInterrupt() 462 logc.Println(ctx, "serve: exiting") 463 } 464}