[mirror] Scalable static site server for Git forges (like GitHub Pages)

Allow updating wildcard domain sites from an archive with a forge token.

+3 -2
README.md
··· 82 82 - **`Pages` scheme:** Request includes an `Authorization: Pages <token>` header. 83 83 - **`Basic` scheme:** Request includes an `Authorization: Basic <basic>` header, where `<basic>` is equal to `Base64("Pages:<token>")`. (Useful for non-Forgejo forges.) 84 84 3. **DNS Allowlist:** If the method is `PUT` or `POST`, and a TXT record lookup at `_git-pages-repository.<host>` returns a set of well-formed absolute URLs, and (for `PUT` requests) the body contains a repository URL, and the requested clone URLs is contained in this set of URLs, the request is authorized. 85 - 4. **Wildcard Match (Site):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. 85 + 4. **Wildcard Match (Webhook):** If the method is `POST`, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and (for `PUT` requests) the body contains a repository URL, and the requested clone URL is a *matching* clone URL, the request is authorized. 86 86 - **Index repository:** If the request URL is `scheme://<user>.<host>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, where `<project>` is computed by templating each element of `[[wildcard]].index-repos` with `<user>`, and `[[wildcard]]` is the section where the match occurred. 87 87 - **Project repository:** If the request URL is `scheme://<user>.<host>/<project>/`, a *matching* clone URL is computed by templating `[[wildcard]].clone-url` with `<user>` and `<project>`, and `[[wildcard]]` is the section where the match occurred. 88 + 5. **Forge Authorization:** If the method is `PUT`, and the body contains an archive, and a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, and `[[wildcard]].authorization` is non-empty, and the request includes a `Forge-Authorization:` header, and the header grants push permissions to a repository at the *matching* clone URL (as defined above) as determined by an API call to the forge, the request is authorized. 88 89 5. **Default Deny:** Otherwise, the request is not authorized. 89 90 90 91 The authorization flow for metadata retrieval (`GET` requests with site paths starting with `.git-pages/`) in the following order, with the first of multiple applicable rule taking precedence: 91 92 92 93 1. **Development Mode:** Same as for content updates. 93 94 2. **DNS Challenge:** Same as for content updates. 94 - 3. **Wildcard Match (Domain):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized. 95 + 3. **Wildcard Match (Metadata):** If a `[[wildcard]]` configuration section exists where the suffix of a hostname (compared label-wise) is equal to `[[wildcard]].domain`, the request is authorized. 95 96 4. **Default Deny:** Otherwise, the request is not authorized. 96 97 97 98
+1
conf/config.example.toml
··· 14 14 clone-url = "https://codeberg.org/<user>/<project>.git" 15 15 index-repos = ["<user>.codeberg.page", "pages"] 16 16 index-repo-branch = "main" 17 + authorization = "forgejo" 17 18 fallback-proxy-to = "https://codeberg.page" 18 19 19 20 [storage]
+132 -7
src/auth.go
··· 3 3 import ( 4 4 "crypto/sha256" 5 5 "encoding/base64" 6 + "encoding/json" 6 7 "errors" 7 8 "fmt" 8 9 "log" ··· 11 12 "net/url" 12 13 "slices" 13 14 "strings" 15 + "time" 14 16 ) 15 17 16 18 type AuthError struct { ··· 511 513 } 512 514 } 513 515 516 + // Gogs, Gitea, and Forgejo all support the same API here. 517 + func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) error { 518 + ownerAndRepo := strings.TrimSuffix(strings.TrimPrefix(baseURL.Path, "/"), ".git") 519 + request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{ 520 + Path: fmt.Sprintf("/api/v1/repos/%s", ownerAndRepo), 521 + }).String(), nil) 522 + if err != nil { 523 + panic(err) // misconfiguration 524 + } 525 + request.Header.Set("Accept", "application/json") 526 + request.Header.Set("Authorization", authorization) 527 + 528 + httpClient := http.Client{Timeout: 5 * time.Second} 529 + response, err := httpClient.Do(request) 530 + if err != nil { 531 + return AuthError{ 532 + http.StatusServiceUnavailable, 533 + fmt.Sprintf("cannot check repository permissions: %s", err), 534 + } 535 + } 536 + defer response.Body.Close() 537 + 538 + if response.StatusCode == http.StatusNotFound { 539 + return AuthError{ 540 + http.StatusNotFound, 541 + fmt.Sprintf("no repository %s", ownerAndRepo), 542 + } 543 + } else if response.StatusCode != http.StatusOK { 544 + return AuthError{ 545 + http.StatusServiceUnavailable, 546 + fmt.Sprintf( 547 + "cannot check repository permissions: GET %s returned %s", 548 + request.URL, 549 + response.Status, 550 + ), 551 + } 552 + } 553 + decoder := json.NewDecoder(response.Body) 554 + 555 + var repositoryInfo struct{ Permissions struct{ Push bool } } 556 + if err = decoder.Decode(&repositoryInfo); err != nil { 557 + return errors.Join(AuthError{ 558 + http.StatusServiceUnavailable, 559 + fmt.Sprintf( 560 + "cannot check repository permissions: GET %s returned malformed JSON", 561 + request.URL, 562 + ), 563 + }, err) 564 + } 565 + 566 + if !repositoryInfo.Permissions.Push { 567 + return AuthError{ 568 + http.StatusUnauthorized, 569 + fmt.Sprintf("no push permission for %s", ownerAndRepo), 570 + } 571 + } 572 + 573 + // this token authorizes pushing to the repo, yay! 574 + return nil 575 + } 576 + 577 + func authorizeForgeWithToken(r *http.Request) (*Authorization, error) { 578 + authorization := r.Header.Get("Forge-Authorization") 579 + if authorization == "" { 580 + return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"} 581 + } 582 + 583 + host, err := GetHost(r) 584 + if err != nil { 585 + return nil, err 586 + } 587 + 588 + projectName, err := GetProjectName(r) 589 + if err != nil { 590 + return nil, err 591 + } 592 + 593 + var errs []error 594 + for _, pattern := range wildcardPatterns { 595 + if !pattern.Authorization { 596 + continue 597 + } 598 + 599 + if userName, found := pattern.Matches(host); found { 600 + repoURLs, branch := pattern.ApplyTemplate(userName, projectName) 601 + for _, repoURL := range repoURLs { 602 + parsedRepoURL, err := url.Parse(repoURL) 603 + if err != nil { 604 + panic(err) // misconfiguration 605 + } 606 + 607 + if err = checkGogsRepositoryPushPermission(parsedRepoURL, authorization); err != nil { 608 + errs = append(errs, err) 609 + continue 610 + } 611 + 612 + // This will actually be ignored by the caller of AuthorizeUpdateFromArchive, 613 + // but we return this information as it makes sense to do contextually here. 614 + return &Authorization{ 615 + []string{repoURL}, 616 + branch, 617 + }, nil 618 + } 619 + } 620 + } 621 + 622 + errs = append([]error{ 623 + AuthError{http.StatusUnauthorized, "not authorized by forge"}, 624 + }, errs...) 625 + return nil, joinErrors(errs...) 626 + } 627 + 514 628 func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) { 515 629 causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}} 516 630 ··· 523 637 return auth, nil 524 638 } 525 639 526 - if config.Limits.AllowedRepositoryURLPrefixes != nil { 527 - return nil, AuthError{http.StatusUnauthorized, "updating from archive not allowed"} 528 - } 529 - 530 - // DNS challenge gives absolute authority. 531 - auth, err := authorizeDNSChallenge(r) 640 + // Token authorization allows updating a site on a wildcard domain from an archive. 641 + auth, err := authorizeForgeWithToken(r) 532 642 if err != nil && IsUnauthorized(err) { 533 643 causes = append(causes, err) 534 644 } else if err != nil { // bad request 535 645 return nil, err 536 646 } else { 537 - log.Println("auth: DNS challenge") 647 + log.Printf("auth: forge token: allow\n") 538 648 return auth, nil 649 + } 650 + 651 + if config.Limits.AllowedRepositoryURLPrefixes != nil { 652 + causes = append(causes, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}) 653 + } else { 654 + // DNS challenge gives absolute authority. 655 + auth, err = authorizeDNSChallenge(r) 656 + if err != nil && IsUnauthorized(err) { 657 + causes = append(causes, err) 658 + } else if err != nil { // bad request 659 + return nil, err 660 + } else { 661 + log.Println("auth: DNS challenge") 662 + return auth, nil 663 + } 539 664 } 540 665 541 666 return nil, joinErrors(causes...)
+1
src/config.go
··· 56 56 CloneURL string `toml:"clone-url"` 57 57 IndexRepos []string `toml:"index-repos" default:"[]"` 58 58 IndexRepoBranch string `toml:"index-repo-branch" default:"pages"` 59 + Authorization string `toml:"authorization"` 59 60 FallbackProxyTo string `toml:"fallback-proxy-to"` 60 61 FallbackInsecure bool `toml:"fallback-insecure"` 61 62 }
+28 -12
src/wildcard.go
··· 14 14 ) 15 15 16 16 type WildcardPattern struct { 17 - Domain []string 18 - CloneURL *fasttemplate.Template 19 - IndexRepos []*fasttemplate.Template 20 - IndexBranch string 21 - FallbackURL *url.URL 22 - Fallback http.Handler 17 + Domain []string 18 + CloneURL *fasttemplate.Template 19 + IndexRepos []*fasttemplate.Template 20 + IndexBranch string 21 + Authorization bool 22 + FallbackURL *url.URL 23 + Fallback http.Handler 23 24 } 24 25 25 26 var wildcardPatterns []*WildcardPattern ··· 121 122 indexRepoTemplates = append(indexRepoTemplates, indexRepoTemplate) 122 123 } 123 124 125 + authorization := false 126 + if config.Authorization != "" { 127 + if slices.Contains([]string{"gogs", "gitea", "forgejo"}, config.Authorization) { 128 + // Currently these are the only supported forges, and the authorization mechanism 129 + // is the same for all of them. 130 + authorization = true 131 + } else { 132 + return fmt.Errorf( 133 + "wildcard pattern: unknown authorization mechanism: %s", 134 + config.Authorization, 135 + ) 136 + } 137 + } 138 + 124 139 var fallbackURL *url.URL 125 140 var fallback http.Handler 126 141 if config.FallbackProxyTo != "" { ··· 144 159 } 145 160 146 161 wildcardPatterns = append(wildcardPatterns, &WildcardPattern{ 147 - Domain: strings.Split(config.Domain, "."), 148 - CloneURL: cloneURLTemplate, 149 - IndexRepos: indexRepoTemplates, 150 - IndexBranch: indexRepoBranch, 151 - FallbackURL: fallbackURL, 152 - Fallback: fallback, 162 + Domain: strings.Split(config.Domain, "."), 163 + CloneURL: cloneURLTemplate, 164 + IndexRepos: indexRepoTemplates, 165 + IndexBranch: indexRepoBranch, 166 + Authorization: authorization, 167 + FallbackURL: fallbackURL, 168 + Fallback: fallback, 153 169 }) 154 170 } 155 171 return nil