vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 25m ago
4b8c9e1d
| 1 | package web |
| 2 | |
| 3 | // The status tabs on the Admin page report how much disk the instance uses, what |
| 4 | // the LFS pool contains, and whether people are reading it. They expose counts and |
| 5 | // sizes, never repository contents; /-/healthz remains the separate liveness probe. |
| 6 | |
| 7 | import ( |
| 8 | "fmt" |
| 9 | "net/http" |
| 10 | "net/url" |
| 11 | "strings" |
| 12 | "sync" |
| 13 | "time" |
| 14 | |
| 15 | "rodent.software/vole/forge/pageview" |
| 16 | "rodent.software/vole/internal/format" |
| 17 | "rodent.software/vole/internal/storage" |
| 18 | ) |
| 19 | |
| 20 | const statusStatsTTL = 30 * time.Second |
| 21 | |
| 22 | type statusObjectCount struct { |
| 23 | Objects int |
| 24 | Bytes int64 |
| 25 | } |
| 26 | |
| 27 | type statusStorageView struct { |
| 28 | Used int64 |
| 29 | Files int |
| 30 | Categories []storage.Category |
| 31 | Disk storage.Disk |
| 32 | DiskErr string |
| 33 | Err string |
| 34 | Measured time.Time |
| 35 | } |
| 36 | |
| 37 | type statusLFSView struct { |
| 38 | Total statusObjectCount |
| 39 | Artifacts statusObjectCount |
| 40 | Other statusObjectCount |
| 41 | Err string |
| 42 | Disabled bool |
| 43 | } |
| 44 | |
| 45 | type statusViewsView struct { |
| 46 | Today pageview.Count |
| 47 | Week pageview.Count |
| 48 | Month pageview.Count |
| 49 | Err string |
| 50 | Disabled bool |
| 51 | } |
| 52 | |
| 53 | type statusSetting struct { |
| 54 | Key string |
| 55 | Value string |
| 56 | Note string |
| 57 | Warn bool |
| 58 | } |
| 59 | |
| 60 | type statusSettingGroup struct { |
| 61 | Name string |
| 62 | Settings []statusSetting |
| 63 | } |
| 64 | |
| 65 | type statusConfigView struct { |
| 66 | Path string |
| 67 | Loaded bool |
| 68 | Groups []statusSettingGroup |
| 69 | } |
| 70 | |
| 71 | // statusCache keeps the filesystem walks off the request path when somebody |
| 72 | // refreshes the status page repeatedly. Page-view counters are cheap to query and |
| 73 | // are not cached. |
| 74 | type statusCache struct { |
| 75 | mu sync.Mutex |
| 76 | at time.Time |
| 77 | storage storage.Stats |
| 78 | storageErr error |
| 79 | lfs statusLFSView |
| 80 | } |
| 81 | |
| 82 | // StatusRedirect keeps old links working: the status tabs now live on the Admin |
| 83 | // page, and the tab name is preserved so a bookmark lands on the same section. |
| 84 | func (w *Web) StatusRedirect(rw http.ResponseWriter, r *http.Request) { |
| 85 | target := "/-/admin" |
| 86 | if tab := adminTabFrom(r.URL.Query().Get("tab")); tab != "overview" { |
| 87 | target += "?tab=" + url.QueryEscape(tab) |
| 88 | } |
| 89 | http.Redirect(rw, r, target, http.StatusMovedPermanently) |
| 90 | } |
| 91 | |
| 92 | // fillStatusView reads the instance state the status tabs show. It is a method on |
| 93 | // the Admin view because those tabs are sections of that page, not a page of |
| 94 | // their own; each branch reads only what its tab renders. |
| 95 | func (w *Web) fillStatusView(r *http.Request, view *adminPageView) { |
| 96 | tab := view.Tab |
| 97 | if tab == "overview" { |
| 98 | if w.Ident != nil { |
| 99 | view.Accounts = w.Ident.Snapshot().Count |
| 100 | } |
| 101 | if w.Repos != nil { |
| 102 | if repos, err := w.Repos.List(); err == nil { |
| 103 | view.Repos = len(repos) |
| 104 | } |
| 105 | } |
| 106 | if w.PageViews != nil { |
| 107 | stats, err := w.PageViews.Stats(time.Now().UTC()) |
| 108 | view.Views = statusViewsView{Today: stats.Today, Week: stats.Week, Month: stats.Month} |
| 109 | if err != nil { |
| 110 | view.Views.Err = err.Error() |
| 111 | } |
| 112 | } else { |
| 113 | view.Views.Disabled = true |
| 114 | } |
| 115 | |
| 116 | // The overview is the whole admin surface at a glance, so it repeats the |
| 117 | // head of the other tabs rather than making the reader visit each one. |
| 118 | view.AccountSummary = w.accountSummary() |
| 119 | view.RecentAudit = w.recentAudit() |
| 120 | view.ConfigWarnings = w.configWarnings() |
| 121 | } |
| 122 | if tab == "storage" || tab == "overview" { |
| 123 | if w.Cfg != nil { |
| 124 | st, err, lfs := w.instanceStats() |
| 125 | view.Storage = statusStorageView{ |
| 126 | Used: st.Bytes, Files: st.Files, Disk: st.Disk, DiskErr: st.DiskErr, |
| 127 | Measured: st.Measured, |
| 128 | } |
| 129 | if err != nil { |
| 130 | view.Storage.Err = err.Error() |
| 131 | } |
| 132 | for _, c := range st.Categories { |
| 133 | if c.Bytes > 0 || c.Files > 0 { |
| 134 | view.Storage.Categories = append(view.Storage.Categories, c) |
| 135 | } |
| 136 | } |
| 137 | view.LFS = lfs |
| 138 | } |
| 139 | } |
| 140 | if tab == "runners" { |
| 141 | sessions, queue, runnerProblems := w.runnerStatus() |
| 142 | view.RunnerSessions, view.RunnerQueue = sessions, queue |
| 143 | view.RunnerWindow = presenceWindow.String() |
| 144 | view.RunnerErr = strings.Join(runnerProblems, "; ") |
| 145 | } |
| 146 | // Configuration is host-level detail. The page is instance-admin only, so it |
| 147 | // can show the effective values; the file itself may be package-owned and is |
| 148 | // never edited here. |
| 149 | if tab == "configuration" && w.Cfg != nil { |
| 150 | view.Config = w.statusConfig() |
| 151 | view.InstanceRepo = w.instanceRepoView(r) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | // configWarnings collects the configuration settings worth a second look: a |
| 156 | // development-only switch, or a browser that cannot sign anyone in. The full |
| 157 | // configuration tab lists every value; this is only what would surprise an |
| 158 | // operator reading the overview. |
| 159 | func (w *Web) configWarnings() []statusSetting { |
| 160 | var out []statusSetting |
| 161 | if w.Auth == nil || !w.Auth.Enabled() { |
| 162 | note := "" |
| 163 | if w.Auth != nil { |
| 164 | note = w.Auth.Disabled |
| 165 | } |
| 166 | out = append(out, statusSetting{Key: "browser sign-in", Value: "disabled", Note: note, Warn: true}) |
| 167 | } |
| 168 | if w.Cfg == nil { |
| 169 | return out |
| 170 | } |
| 171 | for _, group := range w.statusConfig().Groups { |
| 172 | for _, setting := range group.Settings { |
| 173 | if setting.Warn { |
| 174 | out = append(out, setting) |
| 175 | } |
| 176 | } |
| 177 | } |
| 178 | return out |
| 179 | } |
| 180 | |
| 181 | func (w *Web) instanceStats() (storage.Stats, error, statusLFSView) { |
| 182 | c := &w.statusCache |
| 183 | c.mu.Lock() |
| 184 | defer c.mu.Unlock() |
| 185 | if !c.at.IsZero() && time.Since(c.at) < statusStatsTTL { |
| 186 | return c.storage, c.storageErr, c.lfs |
| 187 | } |
| 188 | st, err := storage.Measure(w.Cfg.DataDir) |
| 189 | c.storage, c.storageErr = st, err |
| 190 | c.lfs = w.lfsUsage() |
| 191 | c.at = time.Now() |
| 192 | return c.storage, c.storageErr, c.lfs |
| 193 | } |
| 194 | |
| 195 | // lfsUsage classifies the object pool by what the run queue says produced each |
| 196 | // object. The pool itself is the only source of sizes: a stale run row must not |
| 197 | // make the storage numbers disagree with the files on disk. |
| 198 | func (w *Web) lfsUsage() statusLFSView { |
| 199 | var v statusLFSView |
| 200 | if w.LFS == nil { |
| 201 | v.Disabled = true |
| 202 | return v |
| 203 | } |
| 204 | artifacts := map[string]bool{} |
| 205 | var problems []string |
| 206 | if w.Runs != nil { |
| 207 | artifactOIDs, err := w.Runs.ArtifactOIDs() |
| 208 | if err != nil { |
| 209 | problems = append(problems, "run artifacts: "+err.Error()) |
| 210 | } else { |
| 211 | artifacts = objectSet(artifactOIDs) |
| 212 | } |
| 213 | } |
| 214 | err := w.LFS.Walk(func(oid string, size int64) error { |
| 215 | v.Total.Objects++ |
| 216 | v.Total.Bytes += size |
| 217 | switch { |
| 218 | case artifacts[oid]: |
| 219 | v.Artifacts.Objects++ |
| 220 | v.Artifacts.Bytes += size |
| 221 | default: |
| 222 | v.Other.Objects++ |
| 223 | v.Other.Bytes += size |
| 224 | } |
| 225 | return nil |
| 226 | }) |
| 227 | if err != nil { |
| 228 | problems = append(problems, err.Error()) |
| 229 | } |
| 230 | v.Err = strings.Join(problems, "; ") |
| 231 | return v |
| 232 | } |
| 233 | |
| 234 | func objectSet(refs map[string][]string) map[string]bool { |
| 235 | set := make(map[string]bool) |
| 236 | for _, oids := range refs { |
| 237 | for _, oid := range oids { |
| 238 | if oid != "" { |
| 239 | set[oid] = true |
| 240 | } |
| 241 | } |
| 242 | } |
| 243 | return set |
| 244 | } |
| 245 | |
| 246 | func (w *Web) statusConfig() *statusConfigView { |
| 247 | cfg := w.Cfg |
| 248 | v := &statusConfigView{Path: cfg.Path, Loaded: cfg.Loaded} |
| 249 | |
| 250 | loginDisabled := w.Auth == nil || !w.Auth.Enabled() |
| 251 | publicNote := "" |
| 252 | if loginDisabled && w.Auth != nil { |
| 253 | publicNote = w.Auth.Disabled |
| 254 | } |
| 255 | registrationNote := "" |
| 256 | if cfg.OpenRegistration && loginDisabled { |
| 257 | registrationNote = "inactive while browser sign-in is disabled" |
| 258 | } |
| 259 | instanceNameNote := "" |
| 260 | if strings.TrimSpace(cfg.InstanceName) == "" { |
| 261 | instanceNameNote = "default: Vole" |
| 262 | } |
| 263 | instanceRepoNote := "" |
| 264 | if strings.TrimSpace(cfg.InstanceRepo) == "" { |
| 265 | instanceRepoNote = "customization disabled" |
| 266 | } |
| 267 | |
| 268 | devOnly := statusSetting{Key: "dev_only", Value: yesNo(cfg.DevOnly)} |
| 269 | if cfg.DevOnly { |
| 270 | devOnly.Note = "development instance" |
| 271 | devOnly.Warn = true |
| 272 | } |
| 273 | devLogin := statusSetting{Key: "dev_login", Value: "off"} |
| 274 | if strings.TrimSpace(cfg.DevLogin) != "" { |
| 275 | devLogin.Value = cfg.DevLogin |
| 276 | devLogin.Note = "development-only sign-in" |
| 277 | devLogin.Warn = true |
| 278 | } |
| 279 | |
| 280 | httpsNote := "" |
| 281 | if strings.TrimSpace(cfg.HTTPSAddr) == "" { |
| 282 | httpsNote = "automatic HTTPS disabled" |
| 283 | } |
| 284 | acmeDomainsNote := "" |
| 285 | if len(cfg.ACMEDomains) == 0 { |
| 286 | acmeDomainsNote = "derived from public_url" |
| 287 | } |
| 288 | acmeCacheNote := "" |
| 289 | if strings.TrimSpace(cfg.ACMECacheDir) == "" { |
| 290 | acmeCacheNote = "derived from data_dir" |
| 291 | } |
| 292 | secretsKeyNote := "keep this out of a backup of data_dir" |
| 293 | if strings.TrimSpace(cfg.SecretsKey) == "" { |
| 294 | secretsKeyNote = "derived from data_dir; keep it out of the data backup" |
| 295 | } |
| 296 | pagesNote := "" |
| 297 | if strings.TrimSpace(cfg.PagesDomain) == "" { |
| 298 | pagesNote = "sites disabled" |
| 299 | } |
| 300 | jobsNote := "" |
| 301 | if cfg.MaxJobsPerRepo == 0 { |
| 302 | jobsNote = "unlimited" |
| 303 | } |
| 304 | leaseNote := "" |
| 305 | if cfg.LeaseSeconds <= 0 { |
| 306 | leaseNote = "default" |
| 307 | } |
| 308 | lfsWarnNote := "" |
| 309 | if cfg.LFSWarnBytes == 0 { |
| 310 | lfsWarnNote = "warning disabled" |
| 311 | } |
| 312 | gitHTTPNote := "" |
| 313 | if !cfg.GitHTTP { |
| 314 | gitHTTPNote = "SSH only" |
| 315 | } |
| 316 | clientIPNote := "connection address" |
| 317 | if cfg.GitHTTPClientIPHeader != "" { |
| 318 | clientIPNote = "from " + cfg.GitHTTPClientIPHeader |
| 319 | } |
| 320 | quotaNote := func(limit int64) string { |
| 321 | if limit == 0 { |
| 322 | return "unlimited" |
| 323 | } |
| 324 | return "" |
| 325 | } |
| 326 | perClientNote := "unlimited" |
| 327 | if cfg.GitHTTPMaxPerClient > 0 { |
| 328 | perClientNote = "" |
| 329 | } |
| 330 | sshConnNote, sshProcNote, sshHandshakeNote := "", "", "" |
| 331 | if cfg.SSHMaxConnections == 0 { |
| 332 | sshConnNote = "default: 64" |
| 333 | } |
| 334 | if cfg.SSHMaxGitProcesses == 0 { |
| 335 | sshProcNote = "default: 16" |
| 336 | } |
| 337 | if cfg.SSHHandshakeTimeoutSeconds == 0 { |
| 338 | sshHandshakeNote = "default: 20" |
| 339 | } |
| 340 | |
| 341 | v.Groups = []statusSettingGroup{ |
| 342 | {Name: "Instance and access", Settings: []statusSetting{ |
| 343 | configSetting("instance_name", cfg.InstanceName, instanceNameNote), |
| 344 | configSetting("instance_repo", cfg.InstanceRepo, instanceRepoNote), |
| 345 | configSetting("public_url", cfg.PublicURL, publicNote), |
| 346 | configSetting("open_registration", yesNo(cfg.OpenRegistration), registrationNote), |
| 347 | configSetting("open_registration_role", cfg.OpenRegistrationRole, ""), |
| 348 | devOnly, |
| 349 | devLogin, |
| 350 | }}, |
| 351 | {Name: "Listeners and TLS", Settings: []statusSetting{ |
| 352 | configSetting("http_addr", cfg.HTTPAddr, ""), |
| 353 | configSetting("https_addr", cfg.HTTPSAddr, httpsNote), |
| 354 | configSetting("ssh_addr", cfg.SSHAddr, ""), |
| 355 | configSetting("ssh_max_connections", fmt.Sprintf("%d", cfg.SSHMaxConnections), sshConnNote), |
| 356 | configSetting("ssh_max_git_processes", fmt.Sprintf("%d", cfg.SSHMaxGitProcesses), sshProcNote), |
| 357 | configSetting("ssh_handshake_timeout_seconds", fmt.Sprintf("%d seconds", cfg.SSHHandshakeTimeoutSeconds), sshHandshakeNote), |
| 358 | configSetting("acme_email", cfg.ACMEEmail, ""), |
| 359 | configSetting("acme_domains", strings.Join(cfg.ACMEDomains, ", "), acmeDomainsNote), |
| 360 | configSetting("acme_cache_dir", cfg.ACMECachePath(), acmeCacheNote), |
| 361 | configSetting("acme_directory_url", cfg.ACMEDirectoryURL, ""), |
| 362 | }}, |
| 363 | {Name: "Storage and push limits", Settings: []statusSetting{ |
| 364 | configSetting("data_dir", cfg.DataDir, ""), |
| 365 | configSetting("secrets_key", cfg.SecretsKeyPath(), secretsKeyNote), |
| 366 | configSetting("max_blob_bytes", byteSetting(cfg.MaxBlobBytes), ""), |
| 367 | configSetting("receive_max_bytes", byteSetting(cfg.ReceiveMaxBytes), ""), |
| 368 | configSetting("lfs_object_max_bytes", byteSetting(cfg.LFSObjectMaxBytes), ""), |
| 369 | configSetting("lfs_warn_bytes", byteSetting(cfg.LFSWarnBytes), lfsWarnNote), |
| 370 | configSetting("repo_max_refs", fmt.Sprintf("%d", cfg.RepoMaxRefs), quotaNote(int64(cfg.RepoMaxRefs))), |
| 371 | configSetting("repo_max_metadata_entries", fmt.Sprintf("%d", cfg.RepoMaxMetadataEntries), quotaNote(int64(cfg.RepoMaxMetadataEntries))), |
| 372 | configSetting("repo_max_metadata_bytes", byteSetting(cfg.RepoMaxMetadataBytes), quotaNote(cfg.RepoMaxMetadataBytes)), |
| 373 | configSetting("repo_max_bytes", byteSetting(cfg.RepoMaxBytes), quotaNote(cfg.RepoMaxBytes)), |
| 374 | configSetting("repo_max_lfs_bytes", byteSetting(cfg.RepoMaxLFSBytes), quotaNote(cfg.RepoMaxLFSBytes)), |
| 375 | configSetting("contributor_writes_per_hour", fmt.Sprintf("%d", cfg.ContributorWritesPerHour), quotaNote(int64(cfg.ContributorWritesPerHour))), |
| 376 | }}, |
| 377 | {Name: "Anonymous read limits", Settings: []statusSetting{ |
| 378 | configSetting("git_http", yesNo(cfg.GitHTTP), gitHTTPNote), |
| 379 | configSetting("git_http_max_concurrent", fmt.Sprintf("%d", cfg.GitHTTPMaxConcurrent), ""), |
| 380 | configSetting("git_http_max_per_client", fmt.Sprintf("%d", cfg.GitHTTPMaxPerClient), perClientNote), |
| 381 | configSetting("git_http_client_ip_header", cfg.GitHTTPClientIPHeader, clientIPNote), |
| 382 | configSetting("git_http_timeout_seconds", fmt.Sprintf("%d seconds", cfg.GitHTTPTimeoutSeconds), ""), |
| 383 | }}, |
| 384 | {Name: "CI", Settings: []statusSetting{ |
| 385 | configSetting("lease_seconds", fmt.Sprintf("%d seconds", int(cfg.Lease().Seconds())), leaseNote), |
| 386 | configSetting("max_jobs_per_repo", fmt.Sprintf("%d", cfg.MaxJobsPerRepo), jobsNote), |
| 387 | }}, |
| 388 | {Name: "Sites", Settings: []statusSetting{ |
| 389 | configSetting("pages_domain", cfg.PagesDomain, pagesNote), |
| 390 | configSetting("pages_ref", cfg.PagesRef, ""), |
| 391 | configSetting("pages_procs", fmt.Sprintf("%d", cfg.PagesProcs), ""), |
| 392 | configSetting("pages_cache_bytes", byteSetting(cfg.PagesCacheBytes), ""), |
| 393 | }}, |
| 394 | } |
| 395 | return v |
| 396 | } |
| 397 | |
| 398 | func configSetting(key, value, note string) statusSetting { |
| 399 | if strings.TrimSpace(value) == "" { |
| 400 | value = "not set" |
| 401 | } |
| 402 | return statusSetting{Key: key, Value: value, Note: note} |
| 403 | } |
| 404 | |
| 405 | func byteSetting(n int64) string { |
| 406 | return fmt.Sprintf("%s (%d bytes)", format.Bytes(n), n) |
| 407 | } |
| 408 | |
| 409 | func yesNo(v bool) string { |
| 410 | if v { |
| 411 | return "yes" |
| 412 | } |
| 413 | return "no" |
| 414 | } |
| 415 |