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