import some gokrazy sheeet
Jes Olson j3s@c3f.net
Sun, 08 Dec 2024 17:58:14 -0500
11 files changed,
1690 insertions(+),
44 deletions(-)
M
go.mod
→
go.mod
@@ -1,3 +1,30 @@
module j3s.sh/gore go 1.23.4 + +require ( + golang.org/x/mod v0.8.0 + golang.org/x/sync v0.1.0 +) + +require ( + filippo.io/age v1.1.1 // indirect + git.j3s.sh/j3s.sh v0.0.0-20241123045355-cc3c8f66cf75 // indirect + git.j3s.sh/vore v0.0.0-20240814184024-9d6fb2a0444f // indirect + github.com/PuerkitoBio/goquery v1.9.1 // indirect + github.com/SlyMarbo/rss v1.0.5 // indirect + github.com/andybalholm/cascadia v1.3.2 // indirect + github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.19.0 // indirect + golang.org/x/net v0.21.0 // indirect + golang.org/x/sys v0.17.0 // indirect + modernc.org/libc v1.24.1 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.6.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +)
A
go.sum
@@ -0,0 +1,75 @@
+filippo.io/age v1.1.1 h1:pIpO7l151hCnQ4BdyBujnGP2YlUo0uj6sAVNHGBvXHg= +filippo.io/age v1.1.1/go.mod h1:l03SrzDUrBkdBx8+IILdnn2KZysqQdbEBUQ4p3sqEQE= +git.j3s.sh/j3s.sh v0.0.0-20241123045355-cc3c8f66cf75 h1:tZqmB7FGTPEQOCd4dMO2om2XADeSXpVKVZj2QcjR4QY= +git.j3s.sh/j3s.sh v0.0.0-20241123045355-cc3c8f66cf75/go.mod h1:qZTbyY+tJQQEtOeih2vTXZkzvbDumS1KntndDR/9Sqo= +git.j3s.sh/vore v0.0.0-20240814184024-9d6fb2a0444f h1:BBpvqHX3WoXjpv+gsLDIgqhAJKCwvERyeRu8rrS0kS8= +git.j3s.sh/vore v0.0.0-20240814184024-9d6fb2a0444f/go.mod h1:yd/dKD18ES90S8zi/MkTAX2vOZqplPzs7yNUl8RbxN0= +github.com/PuerkitoBio/goquery v1.9.1 h1:mTL6XjbJTZdpfL+Gwl5U2h1l9yEkJjhmlTeV9VPW7UI= +github.com/PuerkitoBio/goquery v1.9.1/go.mod h1:cW1n6TmIMDoORQU5IU/P1T3tGFunOeXEpGP2WHRwkbY= +github.com/SlyMarbo/rss v1.0.5 h1:DPcZ4aOXXHJ5yNLXY1q/57frIixMmAvTtLxDE3fsMEI= +github.com/SlyMarbo/rss v1.0.5/go.mod h1:w6Bhn1BZs91q4OlEnJVZEUNRJmlbFmV7BkAlgCN8ofM= +github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss= +github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394 h1:OYA+5W64v3OgClL+IrOD63t4i/RW7RqrAVl9LTZ9UqQ= +github.com/axgle/mahonia v0.0.0-20180208002826-3358181d7394/go.mod h1:Q8n74mJTIgjX4RBBcHnJ05h//6/k6foqmgE45jTQtxg= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM= +modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.6.0 h1:i6mzavxrE9a30whzMfwf7XWVODx2r5OYXvU46cirX7o= +modernc.org/memory v1.6.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
A
internal/assets/assets.go
@@ -0,0 +1,6 @@
+package assets + +import "embed" + +//go:embed * +var Assets embed.FS
A
internal/assets/header.tmpl
@@ -0,0 +1,78 @@
+<!DOCTYPE html> +<html lang="en"> +<title>{{ .Hostname }} — gokrazy</title> +<link rel="stylesheet" href="/assets/bootstrap-3.3.7.min.css" /> +<link rel="stylesheet" href="/assets/bootstrap-table-1.11.0.min.css" /> +<link rel="icon" href="/assets/favicon.ico" type="image/x-icon" /> +<style type="text/css"> +.progress-bar:nth-child(5n) { + background-color: #337ab7; +} +.progress-bar:nth-child(5n+1) { + background-color: #5cb85c; +} +.progress-bar:nth-child(5n+2) { + background-color: #5bc0de; +} +.progress-bar:nth-child(5n+3) { + background-color: #f0ad4e; +} +.progress-bar:nth-child(5n+4) { + background-color: #d9534f; +} +.lastlog { + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; +} +table { + table-layout: fixed; +} +</style> + +<nav class="navbar navbar-default"> + <div class="container-fluid"> + <!-- Brand and toggle get grouped for better mobile display --> + <div class="navbar-header"> + <div style="clear: left;"> + <p style="float: left;"><img src = "assets/gokrazy-logo.svg" alt="the gokrazy logo: a mad gopher" width="70px"/></p> + <p style="width: 50ex; margin-top: 0.25em; font-size: 18px"><a href="/">gokrazy</a><br> + <small style="font-size: 11px" class="text-muted">built at {{ .BuildTimestamp }}</small></p> + </div> + </div> + + <div class="collapse navbar-collapse" id="navbar-collapse-1"> + <table class="navbar-text navbar-right" style="border-spacing: 10px 0; border-collapse: separate"> + <tr> + <th>host</th> + <td>{{ .Hostname }}</td> + </tr> + <tr> + <th>kernel</th> + <td>{{ .Kernel }}</td> + </tr> + {{ if (ne .Model "") }} + <tr> + <th>model</th> + <td>{{ .Model }}</td> + </tr> + {{ end }} + {{ if (ne .SBOMHash "") }} + <tr> + <th>SBOM</th> + <td>{{ .SBOMHash | printSBOMHash }}</td> + </tr> + {{ end }} + {{ if .EEPROM }} + <tr> + <th>EEPROM<br>(SHA256)</th> + <td>{{ shortenSHA256 .EEPROM.PieepromSHA256 }}<br>{{ shortenSHA256 .EEPROM.VL805SHA256 }}</td> + </tr> + {{ end }} + </table> + + </div><!-- /.navbar-collapse --> + </div><!-- /.container-fluid --> +</nav> + +<div class="container">
A
internal/module/module.go
@@ -0,0 +1,171 @@
+package module + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path" + "strings" + + "golang.org/x/mod/module" + "golang.org/x/sync/errgroup" +) + +func New() *Module { + return &Module{} +} + +type Module struct { + Path string + Version string +} + +type latestResp struct { + Version string `json:"Version"` +} + +type resolvedModule struct { + module string + version string + goMod []byte +} + +func proxyRequest(importPath, suffix string) (*http.Request, error) { + proxyBase := "https://proxy.golang.org" + if gp := os.Getenv("GOPROXY"); gp != "" { + if strings.ContainsRune(gp, ',') || + strings.ContainsRune(gp, '|') || + gp == "off" || + gp == "direct" { + return nil, fmt.Errorf("only GOPROXY=<url> is supported by gore, not %q", gp) + } + proxyBase = strings.TrimSuffix(gp, "/") + } + escapedSuffix, err := module.EscapeVersion(suffix) + if err != nil { + return nil, err + } + if escapedSuffix != "@latest" { + escapedSuffix = "@v/" + escapedSuffix + } + escapedImportPath, err := module.EscapePath(importPath) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", proxyBase+"/"+escapedImportPath+"/"+escapedSuffix, nil) + if err != nil { + return nil, err + } + // TODO: dynamic version here pls + req.Header.Set("User-Agent", "gore runs everything alpha") + return req, nil +} + +func moduleInfo(ctx context.Context, importPath, version string) (*latestResp, error) { + suffix := version + ".info" + if version == "latest" { + suffix = "@latest" + } + req, err := proxyRequest(importPath, suffix) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer func() { + io.ReadAll(resp.Body) + resp.Body.Close() + }() + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if got, want := resp.StatusCode, http.StatusOK; got != want { + return nil, fmt.Errorf("unexpected HTTP status: got %v, want %v", resp.Status, want) + } + var latest latestResp + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading HTTP response: %v", err) + } + if err := json.Unmarshal(b, &latest); err != nil { + return nil, fmt.Errorf("decoding /@latest response: %v", err) + } + return &latest, nil +} + +func resolveGoMod(ctx context.Context, importPath string, latest *latestResp) (*resolvedModule, error) { + req, err := proxyRequest(importPath, latest.Version+".mod") + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, err + } + defer func() { + io.ReadAll(resp.Body) + resp.Body.Close() + }() + if got, want := resp.StatusCode, http.StatusOK; got != want { + return nil, fmt.Errorf("unexpected HTTP status: got %v, want %v", resp.Status, want) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading HTTP response: %v", err) + } + return &resolvedModule{ + module: importPath, + version: latest.Version, + goMod: b, + }, nil +} + +func resolveModule(ctx context.Context, importPath, version string) (*resolvedModule, error) { + eg, latestctx := errgroup.WithContext(ctx) + + parts := strings.Split(path.Clean(importPath), "/") + resps := make([]*latestResp, len(parts)) + for idx := len(parts); idx > 0; idx-- { + idx := idx // copy + importPath := strings.Join(parts[:idx], "/") + eg.Go(func() error { + if importPath == "github.com" { + // Short-circuit: github.com is not a Go module :) + return nil + } + if strings.HasPrefix(importPath, "github.com/") && + !strings.ContainsRune(strings.TrimPrefix(importPath, "github.com/"), '/') { + // Short-circuit: github.com/<something> references an + // organisation or user, not a repository. + return nil + } + resp, err := moduleInfo(latestctx, importPath, version) + if err != nil { + return err + } + resps[idx-1] = resp + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + for idx := len(parts); idx > 0; idx-- { + importPath := strings.Join(parts[:idx], "/") + resp := resps[idx-1] + if resp == nil { + continue + } + return resolveGoMod(ctx, importPath, resp) + } + + return nil, fmt.Errorf("could not resolve import path %q to any Go module", importPath) +}
M
main.go
→
main.go
@@ -2,41 +2,191 @@ package main
import ( "fmt" + "html/template" "log" "net/http" "os" "os/exec" "path/filepath" - "sync" + "strings" + "time" + + "j3s.sh/gore/internal/module" ) -var mu sync.Mutex +var ( + buildTimestamp = "uninitialized" + httpPassword string // or empty to only permit unix socket access + hostname string +) + +func WaitForClock() { + epochPlus1Year := time.Unix(60*60*24*365, 0) + for { + if time.Now().After(epochPlus1Year) { + return + } + // Sleeps for 1 real second, regardless of wall-clock time. + // See https://github.com/golang/proposal/blob/master/design/12914-monotonic.md + time.Sleep(1 * time.Second) + } +} + +type LogEntry struct { + IsStdErr bool + Message string +} func main() { - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintln(w, "Welcome to Gore: Submit a Go module for execution!") - fmt.Fprintln(w, "Use /submit?module=<module-url> to submit a Go module.") - }) - http.HandleFunc("/submit", handleModuleSubmission) + http.HandleFunc("GET /", indexHandler) + http.HandleFunc("POST /submit", submitHandler) + http.HandleFunc("GET /logs", fetchLogs) log.Println("Starting Gore server on :8080...") log.Fatal(http.ListenAndServe(":8080", nil)) } -func handleModuleSubmission(w http.ResponseWriter, r *http.Request) { - module := r.URL.Query().Get("module") - if module == "" { - http.Error(w, "Module URL is required", http.StatusBadRequest) +func indexHandler(w http.ResponseWriter, r *http.Request) { + tmpl := ` +<!DOCTYPE html> +<html> +<head> + <title>Gore</title> + <style> + body { font-family: monospace; background-color: #000; color: #fff; } + .log-stdout { color: #0f0; } + .log-stderr { color: #f00; } + </style> + <script> + function toggleLogs(module) { + const logContainer = document.getElementById("logs-" + module); + if (logContainer.style.display === "none") { + fetch("/logs?module=" + module) + .then(response => response.json()) + .then(data => { + logContainer.innerHTML = ""; + data.forEach(entry => { + const logLine = document.createElement("div"); + logLine.textContent = entry.message; + logLine.className = entry.isStdErr ? "log-stderr" : "log-stdout"; + logContainer.appendChild(logLine); + }); + logContainer.style.display = "block"; + }) + .catch(err => console.error("Error fetching logs:", err)); + } else { + logContainer.style.display = "none"; + } + } + </script> +</head> +<body> + <h1>Gore</h1> + <p>Submit a Go module for execution:</p> + <form action="/submit" method="POST"> + <input type="text" name="module" placeholder="Go module URL" required style="width: 300px;"> + <button type="submit">Submit</button> + </form> + <h2>Services</h2> + <table> + <tr><th>Module</th><th>Status</th><th>Logs</th></tr> + {{range $module, $status := .Modules}} + <tr> + <td>{{$module}}</td> + <td>{{$status.Status}}</td> + <td><button onclick="toggleLogs('{{ $module }}')">View Logs</button></td> + </tr> + <tr> + <td colspan="3"> + <div id="logs-{{ $module }}" style="display: none; background-color: #111; padding: 10px;"></div> + </td> + </tr> + {{end}} + </table> +</body> +</html> +` + services.Lock() + defer services.Unlock() + + data := struct { + Modules map[string]*services.S + }{ + Modules: services.S, + } + + t := template.Must(template.New("index").Parse(tmpl)) + t.Execute(w, data) +} + +func submitHandler(w http.ResponseWriter, r *http.Request) { + formInput := r.FormValue("module") + if formInput == "" { + http.Error(w, "Module URL is required (e.g. git.j3s.sh/vore@latest)", http.StatusBadRequest) + return + } + + m := module.New() + before, after, found := strings.Cut(formInput, "@") + if found { + m.Path = before + m.Version = after + } else { + // assume latest if not specified + m.Path = formInput + m.Version = "latest" + } + + supervisedMu.Lock() + if _, exists := moduleStatuses[module]; exists { + supervisedMu.Unlock() + http.Error(w, "Module already submitted", http.StatusBadRequest) return } + moduleStatuses[module] = &ModuleStatus{Status: "Building", Logs: []LogEntry{}} + supervisedMu.Unlock() go func() { if err := processModule(module); err != nil { log.Printf("Error processing module %s: %v", module, err) + supervisedMu.Lock() + moduleStatuses[module].Status = "Failed" + supervisedMu.Unlock() + } else { + supervisedMu.Lock() + moduleStatuses[module].Status = "Running" + supervisedMu.Unlock() } }() - fmt.Fprintf(w, "Module %s submitted for processing.\n", module) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func fetchLogs(w http.ResponseWriter, r *http.Request) { + module := r.URL.Query().Get("module") + if module == "" { + http.Error(w, "Module URL is required", http.StatusBadRequest) + return + } + + supervisedMu.Lock() + status, exists := moduleStatuses[module] + supervisedMu.Unlock() + + if !exists { + http.Error(w, "No logs found for the specified module", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, "[") + for i, log := range status.Logs { + fmt.Fprintf(w, `{"isStdErr":%t,"message":%q}`, log.IsStdErr, log.Message) + if i < len(status.Logs)-1 { + fmt.Fprintf(w, ",") + } + } + fmt.Fprintf(w, "]") } func processModule(module string) error {@@ -45,55 +195,28 @@ defer mu.Unlock()
log.Printf("Processing module: %s", module) - // Create a temporary directory for the module tempDir, err := os.MkdirTemp("", "gore-*") if err != nil { return fmt.Errorf("failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) - // `go get` the module - cmd := exec.Command("go", "mod", "init", "gore-temp") - cmd.Dir = tempDir - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to initialize module: %v", err) - } - - cmd = exec.Command("go", "get", module) + cmd := exec.Command("go", "get", module) cmd.Dir = tempDir if err := cmd.Run(); err != nil { return fmt.Errorf("failed to get module: %v", err) } - // Build the module outputBinary := filepath.Join(tempDir, "module-binary") - cmd = exec.Command("go", "build", "-o", outputBinary) + cmd = exec.Command("go", "build", "-o", outputBinary, module) cmd.Dir = tempDir if err := cmd.Run(); err != nil { return fmt.Errorf("failed to build module: %v", err) } - log.Printf("Module built: %s", outputBinary) - - // Launch QEMU VM with u-root and the built binary - if err := launchQEMU(outputBinary); err != nil { - return fmt.Errorf("failed to launch QEMU: %v", err) - } - - return nil -} - -func launchQEMU(binary string) error { - // Simulate QEMU launching - log.Printf("Launching QEMU VM with binary: %s", binary) - - // Replace this with actual QEMU invocation - cmd := exec.Command("qemu-system-x86_64", "-kernel", binary, "-append", "console=ttyS0", "-nographic") - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to start QEMU: %v", err) - } + supervisedMu.Lock() + moduleStatuses[module].Logs = append(moduleStatuses[module].Logs, LogEntry{IsStdErr: false, Message: "Module built successfully."}) + supervisedMu.Unlock() return nil }
A
proc.go
@@ -0,0 +1,52 @@
+package main + +import "sync" + +type statusCode int32 + +const ( + Stopped statusCode = iota // Process not running + Running // Process was started and is very likely still running + Stopping // Process is being stopped, but it might still be running +) + +type processState struct { + lock sync.Mutex + statusChange *sync.Cond + status statusCode +} + +func NewProcessState() *processState { + p := &processState{} + p.statusChange = sync.NewCond(&p.lock) + + return p +} + +func (p *processState) Get() statusCode { + p.lock.Lock() + defer p.lock.Unlock() + return p.status +} + +func (p *processState) Set(status statusCode) { + p.lock.Lock() + defer p.lock.Unlock() + + if p.status == Stopped && status == Stopping { + // Not a valid state transition. Ignore it. + return + } + + p.status = status + p.statusChange.Broadcast() +} + +func (p *processState) WaitTill(status statusCode) { + p.lock.Lock() + defer p.lock.Unlock() + + for p.status != status { + p.statusChange.Wait() + } +}
A
status.go
@@ -0,0 +1,365 @@
+package main + +import ( + "bytes" + "debug/buildinfo" + "encoding/json" + "fmt" + "html/template" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync/atomic" + "time" + + "j3s.sh/gore/internal/assets" + + "golang.org/x/sys/unix" +) + +func parseMeminfo() map[string]int64 { + meminfo, err := os.ReadFile("/proc/meminfo") + if err != nil { + return nil + } + vals := make(map[string]int64) + for _, line := range strings.Split(string(meminfo), "\n") { + if !strings.HasPrefix(line, "MemTotal") && + !strings.HasPrefix(line, "MemAvailable") { + continue + } + parts := strings.Split(line, ":") + if len(parts) < 2 { + continue + } + val, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(parts[1], " kB")), 0, 64) + if err != nil { + continue + } + vals[parts[0]] = val * 1024 // KiB to B + } + return vals +} + +// readFile0 returns the file contents or an empty string if the file could not +// be read. All bytes from any \0 byte onwards are stripped (as found in +// /proc/device-tree/model). +// +// Additionally, whitespace is trimmed. +func readFile0(filename string) string { + b, _ := os.ReadFile(filename) + if idx := bytes.IndexByte(b, 0); idx > -1 { + b = b[:idx] + } + return string(bytes.TrimSpace(b)) +} + +var modelCache atomic.Value // of string + +// Model returns a human readable description of the current device model, +// e.g. “Raspberry Pi 4 Model B Rev 1.1” or “PC Engines apu2” or “QEMU” +// or ultimately “unknown model”. +func Model() string { + if s, ok := modelCache.Load().(string); ok { + return s + } + andCache := func(s string) string { + modelCache.Store(s) + return s + } + // the supported Raspberry Pis have this file + if m := readFile0("/proc/device-tree/model"); m != "" { + return andCache(m) + } + // The PC Engines apu2c4 (and other PCs) have this file instead: + vendor := readFile0("/sys/class/dmi/id/board_vendor") + name := readFile0("/sys/class/dmi/id/board_name") + if vendor != "" || name != "" { + return andCache(vendor + " " + name) + } + // QEMU has none of that. But it does say "QEMU" here, so use this as + // another fallback: + if v := readFile0("/sys/class/dmi/id/sys_vendor"); v != "" { + return andCache(v) + } + // If we can't find anything else, at least return some non-empty string so + // fbstatus doesn't render funny with empty parens. Plus this gives people + // something to grep for to add more model detection. + return "unknown model" +} + +func readModuleInfo(path string) (string, error) { + bi, err := buildinfo.ReadFile(path) + if err != nil { + return "", err + } + lines := strings.Split(strings.TrimSpace(bi.String()), "\n") + shortened := make([]string, len(lines)) + for idx, line := range lines { + row := strings.Split(line, "\t") + if len(row) > 3 { + row = row[:3] + } + shortened[idx] = strings.Join(row, "\t") + } + return strings.Join(shortened, "\n"), nil +} + +func parseUtsname(u unix.Utsname) string { + if u == (unix.Utsname{}) { + // Empty utsname, no info to parse. + return "unknown" + } + + str := func(b [65]byte) string { + // Trim all trailing NULL bytes. + return string(bytes.TrimRight(b[:], "\x00")) + } + + return fmt.Sprintf("%s %s (%s)", + str(u.Sysname), str(u.Release), str(u.Machine)) +} + +func jsonRequested(r *http.Request) bool { + // When this function was introduced, it incorrectly checked the + // Content-Type header (which specifies the type of the body, if any), where + // it should have looked at the Accept header. Hence, we now consider both, + // at least for some time. + return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "application/json") || + strings.Contains(strings.ToLower(r.Header.Get("Content-type")), "application/json") +} + +func eventStreamRequested(r *http.Request) bool { + return strings.Contains(strings.ToLower(r.Header.Get("Accept")), "text/event-stream") +} + +var templates = template.Must(template.New("root"). + Funcs(map[string]interface{}{ + "printSBOMHash": func(sbomHash string) string { + const sbomHashLen = 10 + if len(sbomHash) < sbomHashLen { + return sbomHash + } + return sbomHash[:sbomHashLen] + }, + + "shortenSHA256": func(hash string) string { + if len(hash) > 10 { + return hash[:10] + } + return hash + }, + "restarting": func(t time.Time) bool { + return time.Since(t).Seconds() < 5 + }, + + "last": func(s []string) string { + if len(s) == 0 { + return "" + } + return s[len(s)-1] + }, + + "megabytes": func(val int64) string { + return fmt.Sprintf("%.1f MiB", float64(val)/1024/1024) + }, + + "gigabytes": func(val int64) string { + return fmt.Sprintf("%.1f GiB", float64(val)/1024/1024/1024) + }, + + "baseName": func(path string) string { + return filepath.Base(path) + }, + + "initRss": func() int64 { + return rssOfPid(1) + }, + + "rssPercentage": func(meminfo map[string]int64, rss int64) string { + used := float64(meminfo["MemTotal"] - meminfo["MemAvailable"]) + return fmt.Sprintf("%.f", float64(rss)/used*100) + }, + }). + ParseFS(assets.Assets, "*.tmpl")) + +func initStatus() { + model := Model() + + var uname unix.Utsname + if err := unix.Uname(&uname); err != nil { + log.Printf("getting uname: %v", err) + } + kernel := parseUtsname(uname) + + http.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assets.Assets)))) + + http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + token := xsrfTokenFromCookies(r.Cookies()) + if token == 0 { + // Only generate a new XSRF token if the old one is expired, so that + // loading a different form in the background doesn’t render the + // current one unusable. + token = xsrfToken() + } + + http.SetCookie(w, &http.Cookie{ + Name: "gore_xsrf", + Value: fmt.Sprintf("%d", token), + Expires: time.Now().Add(24 * time.Hour), + HttpOnly: true, + }) + + path := r.FormValue("path") + svc := findSvc(path) + if svc == nil { + http.Error(w, "service not found", http.StatusNotFound) + return + } + + if jsonRequested(r) { + b, err := json.Marshal(svc) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + return + } + + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "status.tmpl", struct { + Service *service + BuildTimestamp string + Hostname string + Model string + XsrfToken int32 + Kernel string + }{ + Service: svc, + BuildTimestamp: buildTimestamp, + Hostname: hostname, + Model: model, + XsrfToken: token, + Kernel: kernel, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + status := "started" + if svc.Stopped() { + status = "stopped" + } + w.Header().Set("X-Gokrazy-Status", status) + w.Header().Set("X-Gokrazy-GOARCH", runtime.GOARCH) + io.Copy(w, &buf) + }) + + http.HandleFunc("/log", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + evtStream := eventStreamRequested(r) + if evtStream { + w.Header().Set("Content-type", "text/event-stream") + } + + path := r.FormValue("path") + svc := findSvc(path) + if svc == nil { + http.Error(w, "service not found", http.StatusNotFound) + return + } + + streamName := r.FormValue("stream") + + var stream <-chan string + var closeFunc func() + + switch streamName { + case "stdout": + stream, closeFunc = svc.Stdout.Stream() + case "stderr": + stream, closeFunc = svc.Stderr.Stream() + default: + http.Error(w, "stream not found", http.StatusNotFound) + return + } + defer closeFunc() + + for { + select { + case line := <-stream: + // See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events for description + // of server-sent events protocol. + if evtStream { + line = fmt.Sprintf("data: %s\n", line) + } + if _, err := fmt.Fprintln(w, line); err != nil { + return + } + if f, ok := w.(http.Flusher); ok { + f.Flush() + } + case <-r.Context().Done(): + // Client closed stream. Stop and release all resources immediately. + return + } + } + }) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.Error(w, "not found", http.StatusNotFound) + return + } + w.Header().Set("Access-Control-Allow-Origin", "*") + + var st unix.Statfs_t + if err := unix.Statfs("/perm", &st); err != nil { + log.Printf("could not stat /perm: %v", err) + } + + services.Lock() + defer services.Unlock() + status := struct { + Services []*service + BuildTimestamp string + Meminfo map[string]int64 + Hostname string + Kernel string + }{ + Services: services.S, + Meminfo: parseMeminfo(), + Hostname: hostname, + Kernel: kernel, + } + + if jsonRequested(r) { + b, err := json.Marshal(status) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(b) + return + } + + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "overview.tmpl", status); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + io.Copy(w, &buf) + }) +}
A
supervise.go
@@ -0,0 +1,700 @@
+package main + +import ( + "container/ring" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "log/syslog" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" +) + +// remoteSyslogError throttles printing error messages about remote +// syslog. Since a remote syslog writer is created for stdout and stderr of each +// supervised process, error messages during early boot spam the serial console +// without limiting. When the value is 0, a log message can be printed. A +// background goroutine resets the value to 0 once a second. +var remoteSyslogError uint32 + +func init() { + go func() { + for range time.Tick(1 * time.Second) { + atomic.StoreUint32(&remoteSyslogError, 0) + } + }() +} + +type remoteSyslogWriter struct { + raddr, tag string + + lines *lineRingBuffer + + syslogMu sync.Mutex + syslog io.Writer +} + +func (w *remoteSyslogWriter) establish() { + for { + sl, err := syslog.Dial("udp", w.raddr, syslog.LOG_INFO, w.tag) + if err != nil { + if atomic.SwapUint32(&remoteSyslogError, 1) == 0 { + log.Printf("remote syslog: %v", err) + } + time.Sleep(1 * time.Second) + continue + } + w.syslogMu.Lock() + defer w.syslogMu.Unlock() + // replay buffer in case any messages were sent before the connection + // could be established (before the network is ready) + for _, line := range w.lines.Lines() { + sl.Write([]byte(line + "\n")) + } + // send all future writes to syslog + w.syslog = sl + return + } +} + +func (w *remoteSyslogWriter) Lines() []string { + return w.lines.Lines() +} + +func (w *remoteSyslogWriter) Stream() (<-chan string, func()) { + return w.lines.Stream() +} + +func (w *remoteSyslogWriter) Write(b []byte) (int, error) { + w.lines.Write(b) + w.syslogMu.Lock() + defer w.syslogMu.Unlock() + if w.syslog == nil { + return len(b), nil + } + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + w.syslog.Write([]byte(line + "\n")) + } + return len(b), nil +} + +type lineRingBuffer struct { + sync.RWMutex + remainder string + r *ring.Ring + streams map[chan string]struct{} +} + +func newLineRingBuffer(size int) *lineRingBuffer { + return &lineRingBuffer{ + r: ring.New(size), + streams: make(map[chan string]struct{}), + } +} + +func (lrb *lineRingBuffer) Write(b []byte) (int, error) { + lrb.Lock() + defer lrb.Unlock() + text := lrb.remainder + string(b) + for { + idx := strings.Index(text, "\n") + if idx == -1 { + break + } + + line := text[:idx] + lrb.r.Value = line + for stream := range lrb.streams { + select { + case stream <- line: + default: + // If receiver channel is blocking, skip. This means streams + // will miss log lines if they are full. + } + } + lrb.r = lrb.r.Next() + text = text[idx+1:] + } + lrb.remainder = text + return len(b), nil +} + +func (lrb *lineRingBuffer) Lines() []string { + lrb.RLock() + defer lrb.RUnlock() + lines := make([]string, 0, lrb.r.Len()) + lrb.r.Do(func(x interface{}) { + if x != nil { + lines = append(lines, x.(string)) + } + }) + return lines +} + +// Stream generates a new channel which will stream any logged lines, including everything currently +// in the ring buffer. Deregister the stream by calling the close function. +func (lrb *lineRingBuffer) Stream() (<-chan string, func()) { + lrb.Lock() + defer lrb.Unlock() + + // Need a chan that has at least len(ring) entries in it, otherwise populating it with existing + // contents of the ring will block forever. + stream := make(chan string, 101) + lrb.r.Do(func(x interface{}) { + if x != nil { + stream <- x.(string) + } + }) + lrb.streams[stream] = struct{}{} + + return stream, func() { + lrb.Lock() + defer lrb.Unlock() + + delete(lrb.streams, stream) + close(stream) + } +} + +type lineswriter interface { + io.Writer + Lines() []string + Stream() (<-chan string, func()) +} + +type supervisionMode int + +const ( + superviseLoop supervisionMode = iota + superviseOnce + superviseDone +) + +type service struct { + // config (never updated) + ModuleInfo string + + // state + stopped bool + stoppedMu sync.RWMutex + cmd *exec.Cmd + cmdMu sync.Mutex + Stdout lineswriter + Stderr lineswriter + started time.Time + startedMu sync.RWMutex + process *os.Process + processMu sync.RWMutex + + diversionMu sync.Mutex + diversion string + + supervisionMu sync.Mutex + supervision supervisionMode + + waitForClock bool + + state *processState +} + +func (s *service) setDiversion(d string) { + s.diversionMu.Lock() + defer s.diversionMu.Unlock() + s.diversion = d +} + +func (s *service) Diverted() string { + s.diversionMu.Lock() + defer s.diversionMu.Unlock() + return s.diversion +} + +func (s *service) Path() string { + if d := s.Diverted(); d != "" { + return d + } + return s.Cmd().Path +} + +func (s *service) Cmd() *exec.Cmd { + s.cmdMu.Lock() + defer s.cmdMu.Unlock() + return s.cmd +} + +func (s *service) setCmd(cmd *exec.Cmd) { + s.cmdMu.Lock() + defer s.cmdMu.Unlock() + s.cmd = cmd +} + +func (s *service) Name() string { + return s.Cmd().Args[0] +} + +func (s *service) supervisionMode() supervisionMode { + s.supervisionMu.Lock() + defer s.supervisionMu.Unlock() + return s.supervision +} + +func (s *service) setSupervisionMode(mode supervisionMode) { + s.supervisionMu.Lock() + defer s.supervisionMu.Unlock() + s.supervision = mode +} + +func (s *service) Stopped() bool { + s.stoppedMu.RLock() + defer s.stoppedMu.RUnlock() + return s.stopped +} + +func (s *service) setStopped(val bool) { + s.stoppedMu.Lock() + defer s.stoppedMu.Unlock() + s.stopped = val +} + +func (s *service) Started() time.Time { + s.startedMu.RLock() + defer s.startedMu.RUnlock() + return s.started +} + +func (s *service) setStarted(t time.Time) { + s.startedMu.Lock() + defer s.startedMu.Unlock() + s.started = t +} + +func (s *service) Process() *os.Process { + s.processMu.RLock() + defer s.processMu.RUnlock() + return s.process +} + +func (s *service) Signal(signal syscall.Signal) error { + s.processMu.RLock() + defer s.processMu.RUnlock() + if s.process != nil { + // Use syscall.Kill instead of s.process.Signal since we want + // to the send the signal to all process of the group (-pid) + err := syscall.Kill(-s.process.Pid, signal) + if errno, ok := err.(syscall.Errno); ok { + if errno == syscall.ESRCH { + return nil // no such process, nothing to signal + } + } + return err + } + return nil // no process, nothing to signal +} + +func (s *service) setProcess(p *os.Process) { + s.processMu.Lock() + defer s.processMu.Unlock() + s.process = p +} + +func (s *service) MarshalJSON() ([]byte, error) { + pid := 0 + if proc := s.Process(); proc != nil { + pid = proc.Pid + } + return json.Marshal(&struct { + Stopped bool + StartTime time.Time + Pid int + Path string + Args []string + Diverted string + }{ + Stopped: s.Stopped(), + StartTime: s.Started(), + Pid: pid, + Path: s.Cmd().Path, + Args: s.Cmd().Args, + Diverted: s.Diverted(), + }) +} + +func rssOfPid(pid int) int64 { + statm, err := os.ReadFile(fmt.Sprintf("/proc/%d/statm", pid)) + if err != nil { + return 0 + } + parts := strings.Split(strings.TrimSpace(string(statm)), " ") + if len(parts) < 2 { + return 0 + } + rss, err := strconv.ParseInt(parts[1], 0, 64) + if err != nil { + return 0 + } + return rss * 4096 +} + +func (s *service) RSS() int64 { + if p := s.Process(); p != nil { + return rssOfPid(s.Process().Pid) + } + return 0 +} + +var syslogRaddr string + +func initRemoteSyslog() { + b, err := os.ReadFile("/perm/remote_syslog/target") + if err != nil { + if !os.IsNotExist(err) { + log.Print(err) + } + return + } + raddr := strings.TrimSpace(string(b)) + log.Printf("sending process stdout/stderr to remote syslog %s", raddr) + syslogRaddr = raddr +} + +func newLogWriter(tag string) lineswriter { + lb := newLineRingBuffer(100) + if syslogRaddr == "" { + return lb + } + wr := &remoteSyslogWriter{ + raddr: syslogRaddr, + tag: tag, + lines: lb, + } + go wr.establish() + return wr +} + +func isDontSupervise(err error) bool { + ee, ok := err.(*exec.ExitError) + if !ok { + return false + } + + ws, ok := ee.Sys().(syscall.WaitStatus) + if !ok { + return false + } + + return ws.ExitStatus() == 125 +} + +func supervise(s *service) { + if modInfo, err := readModuleInfo(s.Path()); err == nil { + s.ModuleInfo = modInfo + } else { + log.Printf("cannot read module info from %s: %v", s.Cmd().Path, err) + } + + l := log.New(s.Stderr, "", log.LstdFlags|log.Ldate|log.Ltime) + attempt := 0 + + // Wait for clock to be updated via ntp for services + // that need correct time. This can be enabled + // by adding a settings file named waitforclock.txt under + // waitforclock/<package> directory. + if strings.HasPrefix(s.Cmd().Path, "/user/") && s.waitForClock { + l.Print("gore: waiting for clock to be synced") + WaitForClock() + } + + for { + if s.Stopped() { + time.Sleep(1 * time.Second) + continue + } + + cmd := &exec.Cmd{ + Path: s.Cmd().Path, + Args: s.Cmd().Args, + Env: s.Cmd().Env, + Stdout: s.Stdout, + Stderr: s.Stderr, + SysProcAttr: &syscall.SysProcAttr{ + // create a new process group for each service to make it easier to terminate all its + // processes with a single signal. + Setpgid: true, + }, + } + if d := s.Diverted(); d != "" { + cmd.Path = d + args := make([]string, len(cmd.Args)) + copy(args, cmd.Args) + args[0] = d + cmd.Args = args + } + if cmd.Env == nil { + cmd.Env = os.Environ() // for older gokr-packer versions + } + if attempt == 0 { + cmd.Env = append(cmd.Env, "GOKRAZY_FIRST_START=1") + } + // Designate a subdirectory under /perm/home as $HOME. + // This mirrors what gokrazy system daemons and + // ported daemons would do, so setting $HOME + // increases the chance that third-party daemons + // just work. + base := filepath.Base(s.Cmd().Path) + oldDir := "/perm/" + base + homeDir := "/perm/home/" + base + // Older gokrazy installations used /perm/<base>, + // but since we started creating one directory for each + // supervised process, it is better to use /perm/home/<base> + // to avoid cluttering the /perm partition. + if _, err := os.Stat(oldDir); err == nil { + homeDir = oldDir + } + cmd.Env = append(cmd.Env, "HOME="+homeDir) + if err := os.MkdirAll(homeDir, 0700); err != nil { + if errors.Is(err, syscall.EROFS) { + l.Printf("gokrazy: cannot create $HOME directory without writeable /perm partition") + } else { + l.Printf("gokrazy: creating $HOME: %v", err) + } + } else { + // Process execution fails when cmd.Dir points to + // a non-existant directory. + cmd.Dir = homeDir + } + + l.Printf("gokrazy: attempt %d, starting %q", attempt, cmd.Args) + s.setStarted(time.Now()) + attempt++ + + pid := -1 + if err := cmd.Start(); err != nil { + if d := s.Diverted(); os.IsNotExist(err) && d != "" { + l.Printf("gokrazy: removing no longer existing diversion %q", d) + s.setDiversion("") + } + l.Println("gokrazy: " + err.Error()) + } else { + pid = cmd.Process.Pid + } + + s.state.Set(Running) + s.setProcess(cmd.Process) + + err := cmd.Wait() + if err != nil { + if isDontSupervise(err) { + l.Println("gokrazy: process should not be supervised, stopping") + s.setStopped(true) + } + l.Println("gokrazy: " + err.Error()) + } else { + l.Printf("gokrazy: exited successfully, stopping") + s.setStopped(true) + } + + if s.supervisionMode() == superviseOnce { + s.setSupervisionMode(superviseDone) + if !s.Stopped() { + l.Println("gokrazy: running process only once, stopping") + s.setStopped(true) + } + } + + for { + if pid <= 0 { + // Sanity check pid value. + // Sending 0 for pid in Wait4 has special meaning, which we don't want. + break + } + + // Wait4 return the pid of a process that exited, + // or -1 if there are no processes to be waited on (or error). + wpid, _ := syscall.Wait4(-pid, nil, 0, nil) + if wpid == -1 { + break + } + } + s.state.Set(Stopped) + time.Sleep(1 * time.Second) + } +} + +var services struct { + sync.Mutex + S []*service +} + +// signalSupervisedServices sends a given signal to all non-stopped processes. +// It returns the corresponding processState to allow waiting for a given state. +func signalSupervisedServices(signal syscall.Signal) []*processState { + services.Lock() + defer services.Unlock() + + states := make([]*processState, 0, len(services.S)) + for _, s := range services.S { + // s.Stopped() only checks the "stopped" flag of the service (if it shouldn't restart). + // We check the actual state as well to be sure to re-send a signal if we are already + // in the "Stopping" state. + if s.Stopped() && s.state.Get() == Stopped { + continue + } + + // NOTE: Stopping can be inaccurate if the process exited after the check above. + // In that case, `state.Set(Stopping)` will be ignored - see `processState.Set()`. + s.state.Set(Stopping) + + s.setStopped(true) + s.Signal(signal) + states = append(states, s.state) + } + return states +} + +// killSupervisedServices is called before rebooting when upgrading, allowing +// processes to terminate in an orderly fashion. +func killSupervisedServices(signalDelay time.Duration) { + log.Println("sending sigterm to all services") + termStates := signalSupervisedServices(syscall.SIGTERM) + termDone := make(chan struct{}) + go func() { + for _, s := range termStates { + s.WaitTill(Stopped) + } + close(termDone) + }() + + select { + case <-termDone: + log.Println("all services shut down") + return + case <-time.After(signalDelay): + } + log.Println("some services did not stop, send sigkill") + + killStates := signalSupervisedServices(syscall.SIGKILL) + killDone := make(chan struct{}) + go func() { + for _, s := range killStates { + s.WaitTill(Stopped) + } + close(killDone) + }() + + select { + case <-killDone: + log.Println("all services shut down") + return + case <-time.After(signalDelay): + } + + log.Println("some services did not stop after sigkill") +} + +func findSvc(path string) *service { + services.Lock() + defer services.Unlock() + for _, s := range services.S { + if s.Cmd().Path == path { + return s + } + } + return nil +} + +func restart(s *service, signal syscall.Signal) error { + if s.Stopped() { + s.setStopped(false) // start process in next supervise iteration + return nil + } + + return s.Signal(signal) // kill to restart +} + +func stop(s *service, signal syscall.Signal) error { + if s.Stopped() { + return nil // nothing to do + } + + s.setStopped(true) + return s.Signal(signal) +} + +func stopstartHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected a POST request", http.StatusBadRequest) + return + } + + cookieToken := xsrfTokenFromCookies(r.Cookies()) + if cookieToken == 0 { + http.Error(w, "XSRF cookie missing", http.StatusBadRequest) + return + } + i, err := strconv.ParseInt(r.FormValue("xsrftoken"), 0, 32) + if err != nil { + http.Error(w, fmt.Sprintf("parsing XSRF token form value: %v", err), http.StatusBadRequest) + return + } + if formToken := int32(i); cookieToken != formToken { + http.Error(w, "XSRF token mismatch", http.StatusForbidden) + return + } + + signal := syscall.SIGTERM + if r.FormValue("signal") == "kill" { + signal = syscall.SIGKILL + } + + path := r.FormValue("path") + s := findSvc(path) + if s == nil { + http.Error(w, "no such service", http.StatusNotFound) + return + } + + if r.URL.Path == "/restart" { + if r.FormValue("supervise") == "once" { + s.setSupervisionMode(superviseOnce) + } else { + s.setSupervisionMode(superviseLoop) + } + err = restart(s, signal) + } else { + err = stop(s, signal) + } + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // StatusSeeOther will result in a GET request for the + // redirect location + u, _ := url.Parse("/status") + u.RawQuery = url.Values{ + "path": []string{path}, + }.Encode() + http.Redirect(w, r, u.String(), http.StatusSeeOther) +} + +func superviseServices(svc []*service) { + services.Lock() + services.S = svc + defer services.Unlock() + for _, s := range services.S { + go supervise(s) + } + + http.HandleFunc("/stop", stopstartHandler) + http.HandleFunc("/restart", stopstartHandler) +}
A
xsrf.go
@@ -0,0 +1,42 @@
+package main + +import ( + cryptorand "crypto/rand" + "encoding/binary" + "log" + "math/rand" + "net/http" + "strconv" + "sync" +) + +func xsrfTokenFromCookies(cookies []*http.Cookie) int32 { + for _, c := range cookies { + if c.Name != "gore_xsrf" { + continue + } + if i, err := strconv.ParseInt(c.Value, 0, 32); err == nil { + return int32(i) + } + } + return 0 +} + +// lazyXsrf is a lazily initialized source of random numbers for generating XSRF +// tokens. It is lazily initialized to not block early boot when reading +// cryptographically strong random bytes to seed the RNG. +var lazyXsrf struct { + once sync.Once + rnd *rand.Rand +} + +func xsrfToken() int32 { + lazyXsrf.once.Do(func() { + var buf [8]byte + if _, err := cryptorand.Read(buf[:]); err != nil { + log.Fatalf("lazyXsrf: cryptorand.Read: %v", err) + } + lazyXsrf.rnd = rand.New(rand.NewSource(int64(binary.BigEndian.Uint64(buf[:])))) + }) + return lazyXsrf.rnd.Int31() +}