forked from
whitequark.org/git-pages
fork of whitequark.org/git-pages with mods for tangled
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}