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

Implement `-audit-log` option.

Also, record the principal of `git-pages -{freeze,unfreeze}-domain`
and `git-pages -update-site` as the CLI administrator.

+1 -1
flake.nix
··· 43 43 "-s -w" 44 44 ]; 45 45 46 - vendorHash = "sha256-LkHC/gFiSfYz9Z4bYMq1QNdapPYp8h1DSMRfFU9f7mw="; 46 + vendorHash = "sha256-40LyEXdJDpWPe9UvqM2siqXdpbae1ba7kN7FtySPpBc="; 47 47 }; 48 48 in 49 49 {
+3
go.mod
··· 8 8 github.com/KimMachineGun/automemlimit v0.7.5 9 9 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 10 10 github.com/creasty/defaults v1.8.0 11 + github.com/fatih/color v1.18.0 11 12 github.com/getsentry/sentry-go v0.40.0 12 13 github.com/getsentry/sentry-go/slog v0.40.0 13 14 github.com/go-git/go-billy/v6 v6.0.0-20251126203821-7f9c95185ee0 ··· 43 44 github.com/klauspost/cpuid/v2 v2.3.0 // indirect 44 45 github.com/klauspost/crc32 v1.3.0 // indirect 45 46 github.com/leodido/go-syslog/v4 v4.3.0 // indirect 47 + github.com/mattn/go-colorable v0.1.13 // indirect 48 + github.com/mattn/go-isatty v0.0.20 // indirect 46 49 github.com/minio/crc64nvme v1.1.0 // indirect 47 50 github.com/minio/md5-simd v1.1.2 // indirect 48 51 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
+9
go.sum
··· 33 33 github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 34 34 github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 35 35 github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 36 + github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= 37 + github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= 36 38 github.com/getsentry/sentry-go v0.40.0 h1:VTJMN9zbTvqDqPwheRVLcp0qcUcM+8eFivvGocAaSbo= 37 39 github.com/getsentry/sentry-go v0.40.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= 38 40 github.com/getsentry/sentry-go/slog v0.40.0 h1:uR2EPL9w6uHw3XB983IAqzqM9mP+fjJpNY9kfob3/Z8= ··· 81 83 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= 82 84 github.com/leodido/go-syslog/v4 v4.3.0 h1:bbSpI/41bYK9iSdlYzcwvlxuLOE8yi4VTFmedtnghdA= 83 85 github.com/leodido/go-syslog/v4 v4.3.0/go.mod h1:eJ8rUfDN5OS6dOkCOBYlg2a+hbAg6pJa99QXXgMrd98= 86 + github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 87 + github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 88 + github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 89 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 90 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 84 91 github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI= 85 92 github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs= 86 93 github.com/minio/crc64nvme v1.1.0 h1:e/tAguZ+4cw32D+IO/8GSf5UVr9y+3eJcxZI2WOO/7Q= ··· 150 157 golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= 151 158 golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= 152 159 golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= 160 + golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 161 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 153 162 golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= 154 163 golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 155 164 golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+31
src/audit.go
··· 87 87 return 88 88 } 89 89 90 + func (record *AuditRecord) GetAuditID() AuditID { 91 + return AuditID(record.GetId()) 92 + } 93 + 94 + func (record *AuditRecord) DescribePrincipal() string { 95 + var items []string 96 + if record.Principal != nil { 97 + if record.Principal.GetIpAddress() != "" { 98 + items = append(items, record.Principal.GetIpAddress()) 99 + } 100 + if record.Principal.GetCliAdmin() { 101 + items = append(items, "<cli-admin>") 102 + } 103 + } 104 + if len(items) > 0 { 105 + return strings.Join(items, ";") 106 + } else { 107 + return "<unknown>" 108 + } 109 + } 110 + 111 + func (record *AuditRecord) DescribeResource() string { 112 + desc := "<unknown>" 113 + if record.Domain != nil && record.Project != nil { 114 + desc = fmt.Sprintf("%s/%s", *record.Domain, *record.Project) 115 + } else if record.Domain != nil { 116 + desc = *record.Domain 117 + } 118 + return desc 119 + } 120 + 90 121 type AuditRecordScope int 91 122 92 123 const (
+6 -6
src/backend.go
··· 52 52 IfMatch string 53 53 } 54 54 55 - type QueryAuditLogOptions struct { 55 + type SearchAuditLogOptions struct { 56 56 // Inclusive lower bound on returned audit records, per their Snowflake ID (which may differ 57 57 // slightly from the embedded timestamp). If zero, audit records are returned since beginning 58 58 // of time. ··· 63 63 Until time.Time 64 64 } 65 65 66 - type QueryAuditLogResult struct { 66 + type SearchAuditLogResult struct { 67 67 ID AuditID 68 68 Err error 69 69 } ··· 130 130 QueryAuditLog(ctx context.Context, id AuditID) (record *AuditRecord, err error) 131 131 132 132 // Retrieve records from the audit log by time range. 133 - SearchAuditLog(ctx context.Context, opts QueryAuditLogOptions) iter.Seq[QueryAuditLogResult] 133 + SearchAuditLog(ctx context.Context, opts SearchAuditLogOptions) iter.Seq2[AuditID, error] 134 134 } 135 135 136 - func CreateBackend(config *StorageConfig) (backend Backend, err error) { 136 + func CreateBackend(ctx context.Context, config *StorageConfig) (backend Backend, err error) { 137 137 switch config.Type { 138 138 case "fs": 139 - if backend, err = NewFSBackend(context.Background(), &config.FS); err != nil { 139 + if backend, err = NewFSBackend(ctx, &config.FS); err != nil { 140 140 err = fmt.Errorf("fs backend: %w", err) 141 141 } 142 142 case "s3": 143 - if backend, err = NewS3Backend(context.Background(), &config.S3); err != nil { 143 + if backend, err = NewS3Backend(ctx, &config.S3); err != nil { 144 144 err = fmt.Errorf("s3 backend: %w", err) 145 145 } 146 146 default:
+13 -15
src/backend_fs.go
··· 434 434 } 435 435 436 436 func (fs *FSBackend) SearchAuditLog( 437 - ctx context.Context, opts QueryAuditLogOptions, 438 - ) iter.Seq[QueryAuditLogResult] { 439 - return func(yield func(QueryAuditLogResult) bool) { 437 + ctx context.Context, opts SearchAuditLogOptions, 438 + ) iter.Seq2[AuditID, error] { 439 + return func(yield func(AuditID, error) bool) { 440 440 iofs.WalkDir(fs.auditRoot.FS(), ".", 441 441 func(path string, entry iofs.DirEntry, err error) error { 442 442 if path == "." { 443 - return nil 443 + return nil // skip 444 444 } 445 - var result QueryAuditLogResult 445 + var id AuditID 446 446 if err != nil { 447 - result.Err = err 448 - } else if id, err := ParseAuditID(path); err != nil { 449 - result.Err = err 447 + // report error 448 + } else if id, err = ParseAuditID(path); err != nil { 449 + // report error 450 450 } else if !opts.Since.IsZero() && id.CompareTime(opts.Since) < 0 { 451 - return nil 451 + return nil // skip 452 452 } else if !opts.Until.IsZero() && id.CompareTime(opts.Until) > 0 { 453 - return nil 454 - } else { 455 - result.ID = id 453 + return nil // skip 456 454 } 457 - if !yield(result) { 458 - return iofs.SkipAll 455 + if !yield(id, err) { 456 + return iofs.SkipAll // break 459 457 } else { 460 - return nil 458 + return nil // continue 461 459 } 462 460 }) 463 461 }
+8 -9
src/backend_s3.go
··· 734 734 } 735 735 736 736 func (s3 *S3Backend) SearchAuditLog( 737 - ctx context.Context, opts QueryAuditLogOptions, 738 - ) iter.Seq[QueryAuditLogResult] { 739 - return func(yield func(QueryAuditLogResult) bool) { 737 + ctx context.Context, opts SearchAuditLogOptions, 738 + ) iter.Seq2[AuditID, error] { 739 + return func(yield func(AuditID, error) bool) { 740 740 logc.Printf(ctx, "s3: query audit\n") 741 741 742 742 ctx, cancel := context.WithCancel(ctx) ··· 746 746 for object := range s3.client.ListObjectsIter(ctx, s3.bucket, minio.ListObjectsOptions{ 747 747 Prefix: prefix, 748 748 }) { 749 - var result QueryAuditLogResult 749 + var id AuditID 750 + var err error 750 751 if object.Err != nil { 751 - result.Err = object.Err 752 - } else if id, err := ParseAuditID(strings.TrimPrefix(object.Key, prefix)); err != nil { 753 - result.Err = err 752 + err = object.Err 754 753 } else { 755 - result.ID = id 754 + id, err = ParseAuditID(strings.TrimPrefix(object.Key, prefix)) 756 755 } 757 - if !yield(result) { 756 + if !yield(id, err) { 758 757 break 759 758 } 760 759 }
+42 -9
src/main.go
··· 20 20 21 21 automemlimit "github.com/KimMachineGun/automemlimit/memlimit" 22 22 "github.com/c2h5oh/datasize" 23 + "github.com/fatih/color" 23 24 "github.com/kankanreno/go-snowflake" 24 25 "github.com/prometheus/client_golang/prometheus/promhttp" 26 + "google.golang.org/protobuf/proto" 25 27 ) 26 28 27 29 var config *Config ··· 175 177 fmt.Fprintf(os.Stderr, "(admin) "+ 176 178 "git-pages {-run-migration <name>|-freeze-domain <domain>|-unfreeze-domain <domain>}\n") 177 179 fmt.Fprintf(os.Stderr, "(audit) "+ 178 - "git-pages {-audit-read <id>}\n") 180 + "git-pages {-audit-log|-audit-read <id>}\n") 179 181 fmt.Fprintf(os.Stderr, "(info) "+ 180 182 "git-pages {-print-config-env-vars|-print-config}\n") 181 183 fmt.Fprintf(os.Stderr, "(cli) "+ ··· 209 211 "prevent any site uploads to a given `domain`") 210 212 unfreezeDomain := flag.String("unfreeze-domain", "", 211 213 "allow site uploads to a `domain` again after it has been frozen") 214 + auditLog := flag.Bool("audit-log", false, 215 + "display audit log") 212 216 auditRead := flag.String("audit-read", "", 213 217 "extract contents of audit record `id` to files '<id>-*'") 214 218 flag.Parse() ··· 222 226 *updateSite != "", 223 227 *freezeDomain != "", 224 228 *unfreezeDomain != "", 229 + *auditLog, 225 230 *auditRead != "", 226 231 } { 227 232 if selected { ··· 270 275 271 276 switch { 272 277 case *runMigration != "": 273 - if backend, err = CreateBackend(&config.Storage); err != nil { 278 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 274 279 logc.Fatalln(ctx, err) 275 280 } 276 281 ··· 279 284 } 280 285 281 286 case *getBlob != "": 282 - if backend, err = CreateBackend(&config.Storage); err != nil { 287 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 283 288 logc.Fatalln(ctx, err) 284 289 } 285 290 ··· 290 295 io.Copy(fileOutputArg(), reader) 291 296 292 297 case *getManifest != "": 293 - if backend, err = CreateBackend(&config.Storage); err != nil { 298 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 294 299 logc.Fatalln(ctx, err) 295 300 } 296 301 ··· 302 307 fmt.Fprintln(fileOutputArg(), string(ManifestJSON(manifest))) 303 308 304 309 case *getArchive != "": 305 - if backend, err = CreateBackend(&config.Storage); err != nil { 310 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 306 311 logc.Fatalln(ctx, err) 307 312 } 308 313 ··· 317 322 } 318 323 319 324 case *updateSite != "": 320 - if backend, err = CreateBackend(&config.Storage); err != nil { 325 + ctx = WithPrincipal(ctx) 326 + GetPrincipal(ctx).CliAdmin = proto.Bool(true) 327 + 328 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 321 329 logc.Fatalln(ctx, err) 322 330 } 323 331 ··· 382 390 } 383 391 384 392 case *freezeDomain != "" || *unfreezeDomain != "": 393 + ctx = WithPrincipal(ctx) 394 + GetPrincipal(ctx).CliAdmin = proto.Bool(true) 395 + 385 396 var domain string 386 397 var freeze bool 387 398 if *freezeDomain != "" { ··· 392 403 freeze = false 393 404 } 394 405 395 - if backend, err = CreateBackend(&config.Storage); err != nil { 406 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 396 407 logc.Fatalln(ctx, err) 397 408 } 398 409 ··· 405 416 logc.Println(ctx, "thawed") 406 417 } 407 418 419 + case *auditLog: 420 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 421 + logc.Fatalln(ctx, err) 422 + } 423 + 424 + for id, err := range backend.SearchAuditLog(ctx, SearchAuditLogOptions{}) { 425 + if err != nil { 426 + logc.Fatalln(ctx, err) 427 + } 428 + record, err := backend.QueryAuditLog(ctx, id) 429 + if err != nil { 430 + logc.Fatalln(ctx, err) 431 + } 432 + fmt.Fprintf(color.Output, "%s %s %s %s %s\n", 433 + record.GetAuditID().String(), 434 + color.HiWhiteString(record.GetTimestamp().AsTime().UTC().Format(time.RFC3339)), 435 + color.HiMagentaString(record.DescribePrincipal()), 436 + color.HiGreenString(record.DescribeResource()), 437 + record.GetEvent(), 438 + ) 439 + } 440 + 408 441 case *auditRead != "": 409 - if backend, err = CreateBackend(&config.Storage); err != nil { 442 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 410 443 logc.Fatalln(ctx, err) 411 444 } 412 445 ··· 475 508 caddyListener := listen(ctx, "caddy", config.Server.Caddy) 476 509 metricsListener := listen(ctx, "metrics", config.Server.Metrics) 477 510 478 - if backend, err = CreateBackend(&config.Storage); err != nil { 511 + if backend, err = CreateBackend(ctx, &config.Storage); err != nil { 479 512 logc.Fatalln(ctx, err) 480 513 } 481 514 backend = NewObservedBackend(backend)
+5 -5
src/observe.go
··· 457 457 } 458 458 459 459 func (backend *observedBackend) SearchAuditLog( 460 - ctx context.Context, opts QueryAuditLogOptions, 461 - ) iter.Seq[QueryAuditLogResult] { 462 - return func(yield func(QueryAuditLogResult) bool) { 460 + ctx context.Context, opts SearchAuditLogOptions, 461 + ) iter.Seq2[AuditID, error] { 462 + return func(yield func(AuditID, error) bool) { 463 463 span, ctx := ObserveFunction(ctx, "SearchAuditLog", 464 464 "audit.search.since", opts.Since, 465 465 "audit.search.until", opts.Until, 466 466 ) 467 - for result := range backend.inner.SearchAuditLog(ctx, opts) { 468 - if !yield(result) { 467 + for id, err := range backend.inner.SearchAuditLog(ctx, opts) { 468 + if !yield(id, err) { 469 469 break 470 470 } 471 471 }
+11 -2
src/schema.pb.go
··· 749 749 type Principal struct { 750 750 state protoimpl.MessageState `protogen:"open.v1"` 751 751 IpAddress *string `protobuf:"bytes,1,opt,name=ip_address,json=ipAddress" json:"ip_address,omitempty"` 752 + CliAdmin *bool `protobuf:"varint,2,opt,name=cli_admin,json=cliAdmin" json:"cli_admin,omitempty"` 752 753 unknownFields protoimpl.UnknownFields 753 754 sizeCache protoimpl.SizeCache 754 755 } ··· 788 789 return *x.IpAddress 789 790 } 790 791 return "" 792 + } 793 + 794 + func (x *Principal) GetCliAdmin() bool { 795 + if x != nil && x.CliAdmin != nil { 796 + return *x.CliAdmin 797 + } 798 + return false 791 799 } 792 800 793 801 var File_schema_proto protoreflect.FileDescriptor ··· 845 853 "\x06domain\x18\n" + 846 854 " \x01(\tR\x06domain\x12\x18\n" + 847 855 "\aproject\x18\v \x01(\tR\aproject\x12%\n" + 848 - "\bmanifest\x18\f \x01(\v2\t.ManifestR\bmanifest\"*\n" + 856 + "\bmanifest\x18\f \x01(\v2\t.ManifestR\bmanifest\"G\n" + 849 857 "\tPrincipal\x12\x1d\n" + 850 858 "\n" + 851 - "ip_address\x18\x01 \x01(\tR\tipAddress*V\n" + 859 + "ip_address\x18\x01 \x01(\tR\tipAddress\x12\x1b\n" + 860 + "\tcli_admin\x18\x02 \x01(\bR\bcliAdmin*V\n" + 852 861 "\x04Type\x12\x10\n" + 853 862 "\fInvalidEntry\x10\x00\x12\r\n" + 854 863 "\tDirectory\x10\x01\x12\x0e\n" +
+1
src/schema.proto
··· 131 131 132 132 message Principal { 133 133 string ip_address = 1; 134 + bool cli_admin = 2; 134 135 }