From 38af7fd18d53121d56a9db53faf4d60d26bdc421 Mon Sep 17 00:00:00 2001 From: Michael Stapelberg Date: Sat, 4 Mar 2017 11:22:48 +0100 Subject: [PATCH] Initial commit --- LICENSE | 27 +++ README.md | 121 +++++++++++ assets/footer.tmpl | 7 + assets/header.tmpl | 57 +++++ assets/overview.tmpl | 82 ++++++++ assets/status.tmpl | 34 +++ authenticated.go | 38 ++++ bundle.go | 3 + cmd/dhcp/dhcp.go | 233 +++++++++++++++++++++ cmd/ntp/ntp.go | 32 +++ doc.go | 4 + goembed.go | 190 +++++++++++++++++ gokrazy.go | 159 ++++++++++++++ internal/bundled/GENERATED_bundled.go | 13 ++ internal/bundled/bundled.go | 5 + internal/iface/iface.go | 124 +++++++++++ listeners.go | 135 ++++++++++++ mount.go | 85 ++++++++ status.go | 156 ++++++++++++++ supervise.go | 290 ++++++++++++++++++++++++++ update.go | 154 ++++++++++++++ 21 files changed, 1949 insertions(+) create mode 100644 LICENSE create mode 100644 README.md create mode 100644 assets/footer.tmpl create mode 100644 assets/header.tmpl create mode 100644 assets/overview.tmpl create mode 100644 assets/status.tmpl create mode 100644 authenticated.go create mode 100644 bundle.go create mode 100644 cmd/dhcp/dhcp.go create mode 100644 cmd/ntp/ntp.go create mode 100644 doc.go create mode 100644 goembed.go create mode 100644 gokrazy.go create mode 100644 internal/bundled/GENERATED_bundled.go create mode 100644 internal/bundled/bundled.go create mode 100644 internal/iface/iface.go create mode 100644 listeners.go create mode 100644 mount.go create mode 100644 status.go create mode 100644 supervise.go create mode 100644 update.go diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..97a080f --- /dev/null +++ b/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2017 the gokrazy authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of gokrazy nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e03b93c --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Overview + +gokrazy packs your Go application(s) into an SD card image for the +Raspberry Pi 3 which — aside from the Linux kernel and proprietary +Raspberry Pi bootloader — only contains Go software. + +The motivation is that [@stapelberg](https://github.com/stapelberg) +spends way more time on C software and their various issues than he +would like. Hence, he is going Go-only where feasible. + +# Usage + +## Installation + +Install Go if you haven’t already: +``` +sudo apt install golang-go +export GOPATH=~/go +export PATH=$GOPATH/bin:$PATH +``` + +Then, use the `go` tool to download and install `gokr-packer`: +``` +go get -u github.com/gokrazy/tools/cmd/gokr-packer +``` + +## Overwriting an SD card for the Raspberry Pi 3 + +To re-partition and overwrite the SD card `/dev/sdb`, use: + +``` +sudo setcap CAP_SYS_ADMIN,CAP_DAC_OVERRIDE=ep ~/go/bin/gokr-packer +gokr-packer -overwrite=/dev/sdb github.com/gokrazy/hello +``` + +Then, put the SD card into your Raspberry Pi 3 and power it up! Once +the Raspberry Pi 3 has booted (takes about 10 seconds), you should be +able to reach the gokrazy web interface at the URL which `gokr-packer` +printed. + +Under the hood, `gokr-packer`… + +1. …packed the latest [firmware](https://github.com/gokrazy/firmware) + and [kernel](https://github.com/gokrazy/kernel) binaries into the + boot file system. + +2. …built the specified Go packages using `go install` and packed all + their binaries into the `/user` directory of the root file system. + +3. …created a minimal gokrazy init program which supervises all + binaries (i.e. restarts them when they exit). + +## Updating your installation + +To update gokrazy, including the firmware and kernel binaries, use: +``` +go get -u github.com/gokrazy/tools/cmd/gokr-packer +``` + +To update your gokrazy installation (running on a Raspberry Pi 3), +use: +``` +gokr-packer -update=http://gokrazy:mysecretpassword@gokrazy/ github.com/gokrazy/hello +``` + +# SD card contents + +gokrazy uses the following partition table: + +num | size | purpose | file system +----|--------|------------------------|--------------- +1 | 100 MB | boot (kernel+firmware) | FAT16B +2 | 500 MB | root2 (gokrazy+apps) | FAT16B (but see [issue #10](https://github.com/gokrazy/gokrazy/issues/10)) +3 | 500 MB | root3 (gokrazy+apps) | FAT16B (but see [issue #10](https://github.com/gokrazy/gokrazy/issues/10)) +4 | rest | permanent data | ext4 + +The two root partitions are used alternatingly (to avoid modifying the +currently active file system) when updating. + +If you’d like to store permanent data (i.e. data which will not be +overwritten on the next update), you’ll need to create an ext4 file +system on the last partition. If your SD card is `/dev/sdb`, use +`mkfs.ext4 /dev/sdb4`. + +# Customization + +## Changing program behavior for gokrazy + +`gokr-packer` sets the “gokrazy” [build +tag](https://golang.org/pkg/go/build/#hdr-Build_Constraints) for +conditional compilation. + +You can find an example commit which implements a gokrazy-specific +controller that triggers the main program logic every weekday at 10:00 +at https://github.com/stapelberg/zkj-nas-tools/commit/6f90ace35981f78dcd66d611269f17f37ce4b4ef + +## Changing init behavior + +``` +gokr-packer \ + -overwrite_init=$(go env GOPATH)/src/github.com/stapelberg/mediaserver/cmd/init/init.go \ + github.com/gokrazy/hello +``` + +(Note that the package must result in a binary called “init”.) + +Then, edit the `github.com/stapelberg/mediaserver` package to your +liking. When done, pack an image with your own init package: +``` +gokr-packer \ + -init_pkg=github.com/stapelberg/mediaserver \ + -overwrite=/dev/sdb \ + github.com/gokrazy/hello +``` + +# Repository structure + +* [github.com/gokrazy/gokrazy](https://github.com/gokrazy/gokrazy): system code, main issue tracker, documentation +* [github.com/gokrazy/tools](https://github.com/gokrazy/tools): SD card image creation code, pulling in: + * [github.com/gokrazy/firmware](https://github.com/gokrazy/firmware): Raspberry Pi 3 firmware files + * [github.com/gokrazy/kernel](https://github.com/gokrazy/kernel): pre-built kernel image and bootloader config diff --git a/assets/footer.tmpl b/assets/footer.tmpl new file mode 100644 index 0000000..b328bfa --- /dev/null +++ b/assets/footer.tmpl @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/header.tmpl b/assets/header.tmpl new file mode 100644 index 0000000..e52b2ad --- /dev/null +++ b/assets/header.tmpl @@ -0,0 +1,57 @@ + + +gokrazy + + + + + + +
diff --git a/assets/overview.tmpl b/assets/overview.tmpl new file mode 100644 index 0000000..2d0b6b8 --- /dev/null +++ b/assets/overview.tmpl @@ -0,0 +1,82 @@ +{{ template "header" . }} + +
+
+ +

services

+ + + + + + + +{{ range $idx, $svc := .Services }} + + + + +{{ end }} + +
pathlast log line
+{{ $svc.Name }} +{{ if restarting $svc.Started }} +restarting +{{ end }} +{{ if $svc.Stopped }} +stopped +{{ end }} + +{{ last $svc.Stdout.Lines $svc.Stderr.Lines }} +
+
+
+

memory

+{{ megabytes (index .Meminfo "MemTotal") }} total, {{ megabytes (index .Meminfo "MemAvailable") }} available
+resident set size (RSS) by service: +
+ +{{ with $rss := initRss }} +
+ +init +
+{{ end }} + +{{ range $idx, $svc := .Services }} +{{ with $rss := $svc.RSS }} +
+ +{{ baseName $svc.Name }} +
+{{ end }} +{{ end }} +
+ +unaccounted +
+
+
+ +
+ + +

storage

+ +{{ if eq .PermAvail 0 }} +No permanent storage mounted. To create a filesystem for permanent storage, plug the SD card into a Linux computer and, if your SD card is /dev/sdb, use mkfs.ext4 /dev/sdb4. +{{ else }} +/dev/mmcblk0p4: {{ gigabytes .PermTotal }} total, {{ gigabytes .PermUsed }} used, {{ gigabytes .PermAvail }} avail
+{{ end }} + +

private network addresses

+
    +{{ range $idx, $host := .Hosts }} +
  • {{ $host }}
  • +{{ end }} +
+ +
+
+ +{{ template "footer" . }} \ No newline at end of file diff --git a/assets/status.tmpl b/assets/status.tmpl new file mode 100644 index 0000000..84b3231 --- /dev/null +++ b/assets/status.tmpl @@ -0,0 +1,34 @@ +{{ template "header" . }} + +
+
+ + + + + + + + + + + +
NameStartedActions
{{ .Service.Name }}{{ .Service.Started }}
+ +

stdout

+
+  {{ range $idx, $line := .Service.Stdout.Lines -}}
+    {{ $line }}
+  {{ end }}
+  
+ +

stderr

+
+  {{ range $idx, $line := .Service.Stderr.Lines -}}
+    {{ $line }}
+  {{ end }}
+  
+
+
+ +{{ template "footer" . }} \ No newline at end of file diff --git a/authenticated.go b/authenticated.go new file mode 100644 index 0000000..9901def --- /dev/null +++ b/authenticated.go @@ -0,0 +1,38 @@ +package gokrazy + +import ( + "encoding/base64" + "fmt" + "net/http" + "strings" +) + +func authenticated(w http.ResponseWriter, r *http.Request) { + // defense in depth + if httpPassword == "" { + http.Error(w, "httpPassword not set", http.StatusInternalServerError) + return + } + s := strings.SplitN(r.Header.Get("Authorization"), " ", 2) + if len(s) != 2 || s[0] != "Basic" { + w.Header().Set("WWW-Authenticate", `Basic realm="gokrazy"`) + http.Error(w, "no Basic Authorization header set", http.StatusUnauthorized) + return + } + + b, err := base64.StdEncoding.DecodeString(s[1]) + if err != nil { + http.Error(w, fmt.Sprintf("could not decode Authorization header as base64: %v", err), http.StatusUnauthorized) + return + } + + pair := strings.SplitN(string(b), ":", 2) + if len(pair) != 2 || + pair[0] != "gokrazy" || + pair[1] != httpPassword { + http.Error(w, "invalid username/password", http.StatusUnauthorized) + return + } + + http.DefaultServeMux.ServeHTTP(w, r) +} diff --git a/bundle.go b/bundle.go new file mode 100644 index 0000000..c41b577 --- /dev/null +++ b/bundle.go @@ -0,0 +1,3 @@ +package gokrazy + +//go:generate sh -c "go run goembed.go -package bundled -var assets assets/header.tmpl assets/footer.tmpl assets/overview.tmpl assets/status.tmpl > internal/bundled/GENERATED_bundled.go" diff --git a/cmd/dhcp/dhcp.go b/cmd/dhcp/dhcp.go new file mode 100644 index 0000000..2327e0f --- /dev/null +++ b/cmd/dhcp/dhcp.go @@ -0,0 +1,233 @@ +// Only build for (linux AND (amd64 OR arm64)) due to using +// linux-specific syscalls with uint64 for “unsigned long”: + +// +build linux +// +build amd64 arm64 + +// dhcp is a minimal DHCP client for gokrazy. +package main + +import ( + "encoding/binary" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "strings" + "syscall" + "time" + + "golang.org/x/sys/unix" + + "github.com/d2g/dhcp4" + "github.com/d2g/dhcp4client" + "github.com/gokrazy/gokrazy/internal/iface" +) + +func parseDHCPDuration(b []byte) time.Duration { + return time.Duration(binary.BigEndian.Uint32(b)) * time.Second +} + +var ( + defaultDst = net.IP([]byte{0, 0, 0, 0}) + defaultNetmask = net.IPMask([]byte{0, 0, 0, 0}) +) + +// dhcpRequest is a copy of (dhcp4client/Client).Request which +// includes the hostname. +func dhcpRequest(c *dhcp4client.Client) (bool, dhcp4.Packet, error) { + var utsname unix.Utsname + if err := unix.Uname(&utsname); err != nil { + log.Fatal(err) + } + nnb := make([]byte, 0, len(utsname.Nodename)) + for _, i := range utsname.Nodename { + if i == 0 { + break + } + nnb = append(nnb, byte(i)) + } + + discoveryPacket := c.DiscoverPacket() + discoveryPacket.AddOption(dhcp4.OptionHostName, nnb) + discoveryPacket.PadToMinSize() + + if err := c.SendPacket(discoveryPacket); err != nil { + return false, discoveryPacket, err + } + + offerPacket, err := c.GetOffer(&discoveryPacket) + if err != nil { + return false, offerPacket, err + } + + requestPacket := c.RequestPacket(&offerPacket) + requestPacket.AddOption(dhcp4.OptionHostName, nnb) + requestPacket.PadToMinSize() + + if err := c.SendPacket(requestPacket); err != nil { + return false, requestPacket, err + } + + acknowledgement, err := c.GetAcknowledgement(&requestPacket) + if err != nil { + return false, acknowledgement, err + } + + acknowledgementOptions := acknowledgement.ParseOptions() + if dhcp4.MessageType(acknowledgementOptions[dhcp4.OptionDHCPMessageType][0]) != dhcp4.ACK { + return false, acknowledgement, nil + } + + return true, acknowledgement, nil +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + eth0, err := net.InterfaceByName("eth0") + if err != nil { + log.Fatal(err) + } + + cs, err := iface.NewConfigSocket("eth0") + if err != nil { + log.Fatalf("config socket: %v", err) + } + defer cs.Close() + + // Ensure the interface is up so that we can send DHCP packets. + if err := cs.Up(); err != nil { + log.Fatal(err) + } + + // Wait for up to 10 seconds for the link to indicate it has a + // carrier. + for i := 0; i < 10; i++ { + b, err := ioutil.ReadFile("/sys/class/net/eth0/carrier") + if err == nil && strings.TrimSpace(string(b)) == "1" { + break + } + time.Sleep(1 * time.Second) + } + + pktsock, err := dhcp4client.NewPacketSock(eth0.Index) + if err != nil { + log.Fatal(err) + } + dhcp, err := dhcp4client.New( + dhcp4client.HardwareAddr(eth0.HardwareAddr), + dhcp4client.Timeout(5*time.Second), + dhcp4client.Broadcast(false), + dhcp4client.Connection(pktsock), + ) + if err != nil { + log.Fatal(err) + } + + ok, ack, err := dhcpRequest(dhcp) + for { + if err != nil { + log.Fatal(err) + } + if !ok { + log.Fatal("received DHCPNAK") + } + opts := ack.ParseOptions() + + // DHCPACK (described in RFC2131 4.3.1) + // - yiaddr: IP address assigned to client + details := []string{ + fmt.Sprintf("IP %v", ack.YIAddr()), + } + + if b, ok := opts[dhcp4.OptionSubnetMask]; ok { + ipnet := net.IPNet{ + IP: ack.YIAddr(), + Mask: net.IPMask(b), + } + details[0] = fmt.Sprintf("IP %v", ipnet.String()) + } + + if b, ok := opts[dhcp4.OptionBroadcastAddress]; ok { + details = append(details, fmt.Sprintf("broadcast %v", net.IP(b))) + } + + if b, ok := opts[dhcp4.OptionRouter]; ok { + details = append(details, fmt.Sprintf("router %v", net.IP(b))) + } + + if b, ok := opts[dhcp4.OptionDomainNameServer]; ok { + details = append(details, fmt.Sprintf("DNS %v", net.IP(b))) + } + + log.Printf("DHCPACK: %v", strings.Join(details, ", ")) + + if err := cs.SetAddress(ack.YIAddr()); err != nil { + log.Fatal(err) + } + + if b, ok := opts[dhcp4.OptionSubnetMask]; ok { + if err := cs.SetNetmask(net.IPMask(b)); err != nil { + log.Fatalf("setNetmask(%v): %v", net.IPMask(b), err) + } + } + + if b, ok := opts[dhcp4.OptionBroadcastAddress]; ok { + if err := cs.SetBroadcast(net.IP(b)); err != nil { + log.Fatalf("setBroadcast(%v): %v", net.IP(b), err) + } + } + + if err := cs.Up(); err != nil { + log.Fatal(err) + } + + if b, ok := opts[dhcp4.OptionRouter]; ok { + if errno := cs.AddRoute(defaultDst, net.IP(b), defaultNetmask); errno != 0 { + if errno == syscall.EEXIST { + if errno := cs.DelRoute(defaultDst, net.IP(b), defaultNetmask); errno != 0 { + log.Printf("delRoute(%v): %v", net.IP(b), errno) + } + if errno := cs.AddRoute(defaultDst, net.IP(b), defaultNetmask); errno != 0 { + log.Fatalf("addRoute(%v): %v", net.IP(b), errno) + } + } else { + log.Fatalf("addRoute(%v): %v", net.IP(b), errno) + } + } + } + + if b, ok := opts[dhcp4.OptionDomainNameServer]; ok { + // Get the symlink out of the way, if any. + if err := os.Remove("/etc/resolv.conf"); err != nil && !os.IsNotExist(err) { + log.Fatalf("resolv.conf: %v", err) + } + if err := ioutil.WriteFile("/etc/resolv.conf", []byte(fmt.Sprintf("nameserver %v\n", net.IP(b))), 0644); err != nil { + log.Fatalf("resolv.conf: %v", err) + } + } + + // Notify init of new addresses + p, _ := os.FindProcess(1) + if err := p.Signal(syscall.SIGHUP); err != nil { + log.Printf("send SIGHUP to init: %v", err) + } + + leaseTime := 10 * time.Minute // seems sensible as a fallback + if b, ok := opts[dhcp4.OptionIPAddressLeaseTime]; ok && len(b) == 4 { + leaseTime = parseDHCPDuration(b) + } + + // As per RFC 2131 section 4.4.5: + // renewal time defaults to 50% of the lease time + renewalTime := time.Duration(float64(leaseTime) * 0.5) + if b, ok := opts[dhcp4.OptionRenewalTimeValue]; ok && len(b) == 4 { + renewalTime = parseDHCPDuration(b) + } + + time.Sleep(renewalTime) + ok, ack, err = dhcp.Renew(ack) + } +} diff --git a/cmd/ntp/ntp.go b/cmd/ntp/ntp.go new file mode 100644 index 0000000..4d3e4ad --- /dev/null +++ b/cmd/ntp/ntp.go @@ -0,0 +1,32 @@ +// ntp is a minimal NTP client for gokrazy. +package main + +import ( + "log" + "math/rand" + "syscall" + "time" + + "github.com/beevik/ntp" +) + +func set() error { + r, err := ntp.Query("pool.ntp.org", 4) + if err != nil { + return err + } + + tv := syscall.NsecToTimeval(r.Time.UnixNano()) + return syscall.Settimeofday(&tv) +} + +func main() { + log.SetFlags(log.LstdFlags | log.Lshortfile) + + for { + if err := set(); err != nil { + log.Fatalf("setting time failed: %v", err) + } + time.Sleep(1*time.Hour + time.Duration(rand.Int63n(250))*time.Millisecond) + } +} diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..ed9cad5 --- /dev/null +++ b/doc.go @@ -0,0 +1,4 @@ +// gokrazy packs your Go application(s) into an SD card image for the +// Raspberry Pi 3 which — aside from the Linux kernel and proprietary +// Raspberry Pi bootloader — only contains Go software. +package gokrazy diff --git a/goembed.go b/goembed.go new file mode 100644 index 0000000..73fbfb4 --- /dev/null +++ b/goembed.go @@ -0,0 +1,190 @@ +// copied from https://github.com/dsymonds/goembed/ with pull requests applied + +// +build ignore + +// goembed generates a Go source file from an input file. +package main + +import ( + "bufio" + "bytes" + "compress/gzip" + "flag" + "fmt" + "io" + "log" + "os" + "text/template" + "unicode/utf8" +) + +var ( + packageFlag = flag.String("package", "", "Go package name") + varFlag = flag.String("var", "", "Go var name") + gzipFlag = flag.Bool("gzip", false, "Whether to gzip contents") +) + +func main() { + flag.Parse() + + fmt.Printf("package %s\n\n", *packageFlag) + + if *gzipFlag { + err := gzipPrologue.Execute(os.Stdout, map[string]interface{}{ + "Args": flag.Args(), + "VarName": *varFlag, + }) + if err != nil { + log.Fatal(err) + } + } + + if flag.NArg() > 0 { + fmt.Println("// Table of contents") + fmt.Printf("var %v = map[string][]byte{\n", *varFlag) + for i, filename := range flag.Args() { + fmt.Printf("\t%#v: %s_%d,\n", filename, *varFlag, i) + } + fmt.Println("}") + + // Using a separate variable for each []byte, instead of + // combining them into a single map literal, enables a storage + // optimization: the compiler places the data directly in the + // program's noptrdata section instead of the heap. + for i, filename := range flag.Args() { + if err := oneVar(fmt.Sprintf("%s_%d", *varFlag, i), filename); err != nil { + log.Fatal(err) + } + } + } else { + if err := oneVarReader(*varFlag, os.Stdin); err != nil { + log.Fatal(err) + } + } +} + +func oneVar(varName, filename string) error { + f, err := os.Open(filename) + if err != nil { + return err + } + defer f.Close() + return oneVarReader(varName, f) +} + +func oneVarReader(varName string, r io.Reader) error { + // Generate []byte() instead of []byte{}. + // The latter causes a memory explosion in the compiler (60 MB of input chews over 9 GB RAM). + // Doing a string conversion avoids some of that, but incurs a slight startup cost. + if !*gzipFlag { + fmt.Printf(`var %s = []byte("`, varName) + } else { + var buf bytes.Buffer + gzw, _ := gzip.NewWriterLevel(&buf, gzip.BestCompression) + if _, err := io.Copy(gzw, r); err != nil { + return err + } + if err := gzw.Close(); err != nil { + return err + } + fmt.Printf("var %s []byte // set in init\n\n", varName) + fmt.Printf(`var %s_gzip = []byte("`, varName) + r = &buf + } + + bufw := bufio.NewWriter(os.Stdout) + if _, err := io.Copy(&writer{w: bufw}, r); err != nil { + return err + } + if err := bufw.Flush(); err != nil { + return err + } + fmt.Println(`")`) + return nil +} + +type writer struct { + w io.Writer +} + +func (w *writer) Write(data []byte) (n int, err error) { + n = len(data) + + for err == nil && len(data) > 0 { + // https://golang.org/ref/spec#String_literals: "Within the quotes, any + // character may appear except newline and unescaped double quote. The + // text between the quotes forms the value of the literal, with backslash + // escapes interpreted as they are in rune literals […]." + switch b := data[0]; b { + case '\\': + _, err = w.w.Write([]byte(`\\`)) + case '"': + _, err = w.w.Write([]byte(`\"`)) + case '\n': + _, err = w.w.Write([]byte(`\n`)) + + case '\x00': + // https://golang.org/ref/spec#Source_code_representation: "Implementation + // restriction: For compatibility with other tools, a compiler may + // disallow the NUL character (U+0000) in the source text." + _, err = w.w.Write([]byte(`\x00`)) + + default: + // https://golang.org/ref/spec#Source_code_representation: "Implementation + // restriction: […] A byte order mark may be disallowed anywhere else in + // the source." + const byteOrderMark = '\uFEFF' + + if r, size := utf8.DecodeRune(data); r != utf8.RuneError && r != byteOrderMark { + _, err = w.w.Write(data[:size]) + data = data[size:] + continue + } + + _, err = fmt.Fprintf(w.w, `\x%02x`, b) + } + data = data[1:] + } + + return n - len(data), err +} + +var gzipPrologue = template.Must(template.New("").Parse(` +import ( + "bytes" + "compress/gzip" + "io/ioutil" +) + +func init() { + var ( + r *gzip.Reader + err error + ) + +{{ if gt (len .Args) 0 }} +{{ range $idx, $var := .Args }} +{{ $n := printf "%s_%d" $.VarName $idx }} + r, err = gzip.NewReader(bytes.NewReader({{ $n }}_gzip)) + if err != nil { + panic(err) + } + {{ $n }}, err = ioutil.ReadAll(r) + r.Close() + if err != nil { + panic(err) + } +{{ end }} +{{ else }} + r, err = gzip.NewReader(bytes.NewReader({{ .VarName }}_gzip)) + if err != nil { + panic(err) + } + {{ .VarName }}, err = ioutil.ReadAll(r) + r.Close() + if err != nil { + panic(err) + } +{{ end }} +} +`)) diff --git a/gokrazy.go b/gokrazy.go new file mode 100644 index 0000000..15c818b --- /dev/null +++ b/gokrazy.go @@ -0,0 +1,159 @@ +// Boot and Supervise are called by the auto-generated init +// program. They are provided in case you need to implement a custom +// init program. +// +// PrivateInterfaceAddrs is useful for init and other processes. +// +// DontStartOnBoot and WaitForClock are useful for non-init processes. +package gokrazy + +import ( + "fmt" + "io/ioutil" + "log" + "net" + "os" + "os/exec" + "os/signal" + "strings" + "time" + + "github.com/gokrazy/gokrazy/internal/iface" + + "golang.org/x/sys/unix" +) + +var ( + buildTimestamp = "uninitialized" + httpPassword string + hostname string +) + +func configureLoopback() error { + cs, err := iface.NewConfigSocket("lo") + if err != nil { + return fmt.Errorf("config socket: %v", err) + } + defer cs.Close() + + if err := cs.Up(); err != nil { + return err + } + + if err := cs.SetAddress(net.IP([]byte{127, 0, 0, 1})); err != nil { + return err + } + + return cs.SetNetmask(net.IPMask([]byte{255, 0, 0, 0})) +} + +// Boot configures basic system settings. More specifically, it: +// +// - mounts /dev, /tmp, /proc, /sys and /perm file systems +// - mounts and populate /etc tmpfs overlay +// - sets hostname from the /hostname file +// - sets HTTP password from the gokr-pw.txt file +// - configures the loopback network interface +// +// Boot should always be called to transition the machine into a +// useful state, even in custom init process implementations or +// single-process applications. +// +// userBuildTimestamp will be exposed on the HTTP status handlers that +// are set up by Supervise. +func Boot(userBuildTimestamp string) error { + buildTimestamp = userBuildTimestamp + + if err := mountfs(); err != nil { + return err + } + + hostnameb, err := ioutil.ReadFile("/hostname") + if err != nil { + return err + } + if err := unix.Sethostname(hostnameb); err != nil { + return err + } + hostname = string(hostnameb) + + pw, err := ioutil.ReadFile("/perm/gokr-pw.txt") + if err != nil { + pw, err = ioutil.ReadFile("/gokr-pw.txt") + if err != nil { + return fmt.Errorf("could read neither /perm/gokr-pw.txt nor /gokr-pw.txt: %v", err) + } + } + + httpPassword = strings.TrimSpace(string(pw)) + + if err := configureLoopback(); err != nil { + return err + } + + return nil +} + +// Supervise continuously restarts the processes specified in commands +// unless they run DontStartOnBoot. +// +// Password-protected HTTP handlers are installed, allowing to inspect +// the supervised services and update the gokrazy installation over +// the network. +// +// HTTP is served on PrivateInterfaceAddrs(). New IP addresses will be +// picked up upon receiving SIGHUP. +func Supervise(commands []*exec.Cmd) error { + services := make([]*service, len(commands)) + for idx, cmd := range commands { + services[idx] = &service{cmd: cmd} + } + superviseServices(services) + + initStatus(services) + + if err := initUpdate(); err != nil { + return err + } + + if err := updateListeners("80"); err != nil { + return fmt.Errorf("updating listeners: %v", err) + } + + go func() { + c := make(chan os.Signal, 1) + signal.Notify(c, unix.SIGHUP) + + for range c { + if err := updateListeners("80"); err != nil { + log.Printf("updating listeners: %v", err) + } + } + }() + + return nil +} + +// WaitForClock returns once the system clock appears to have been +// set. Assumes that the system boots with a clock value of January 1, +// 1970 UTC (UNIX epoch), as is the case on the Raspberry Pi 3. +func WaitForClock() { + epochPlus1Minute := time.Unix(60, 0) + for { + if time.Now().After(epochPlus1Minute) { + return + } + // Sleeps for 1 real minute, regardless of wall-clock time. + // See https://github.com/golang/proposal/blob/master/design/12914-monotonic.md + time.Sleep(1 * time.Second) + } +} + +// DontStartOnBoot informs the gokrazy init process to not supervise +// the process and exits. The user can manually start supervision, +// which turns DontStartOnBoot into a no-op. +func DontStartOnBoot() { + if os.Getenv("GOKRAZY_FIRST_START") == "1" { + os.Exit(125) + } +} diff --git a/internal/bundled/GENERATED_bundled.go b/internal/bundled/GENERATED_bundled.go new file mode 100644 index 0000000..b1430d9 --- /dev/null +++ b/internal/bundled/GENERATED_bundled.go @@ -0,0 +1,13 @@ +package bundled + +// Table of contents +var assets = map[string][]byte{ + "assets/header.tmpl": assets_0, + "assets/footer.tmpl": assets_1, + "assets/overview.tmpl": assets_2, + "assets/status.tmpl": assets_3, +} +var assets_0 = []byte("\n\ngokrazy\n\n\n\n\n \n\n
\n") +var assets_1 = []byte("\n
\n\n\n\n\n") +var assets_2 = []byte("{{ template \"header\" . }}\n\n
\n
\n\n

services

\n\n\n\n\n\n\n\n{{ range $idx, $svc := .Services }}\n\n\n\n\n{{ end }}\n\n
pathlast log line
\n{{ $svc.Name }}\n{{ if restarting $svc.Started }}\nrestarting\n{{ end }}\n{{ if $svc.Stopped }}\nstopped\n{{ end }}\n\n{{ last $svc.Stdout.Lines $svc.Stderr.Lines }}\n
\n
\n
\n

memory

\n{{ megabytes (index .Meminfo \"MemTotal\") }} total, {{ megabytes (index .Meminfo \"MemAvailable\") }} available
\nresident set size (RSS) by service:\n
\n\n{{ with $rss := initRss }}\n
\n\ninit\n
\n{{ end }}\n\n{{ range $idx, $svc := .Services }}\n{{ with $rss := $svc.RSS }}\n
\n\n{{ baseName $svc.Name }}\n
\n{{ end }}\n{{ end }}\n
\n\nunaccounted\n
\n
\n
\n\n
\n\n\n

storage

\n\n{{ if eq .PermAvail 0 }}\nNo permanent storage mounted. To create a filesystem for permanent storage, plug the SD card into a Linux computer and, if your SD card is /dev/sdb, use mkfs.ext4 /dev/sdb4.\n{{ else }}\n/dev/mmcblk0p4: {{ gigabytes .PermTotal }} total, {{ gigabytes .PermUsed }} used, {{ gigabytes .PermAvail }} avail
\n{{ end }}\n\n

private network addresses

\n
    \n{{ range $idx, $host := .Hosts }} \n
  • {{ $host }}
  • \n{{ end }}\n
\n\n
\n
\n\n{{ template \"footer\" . }}") +var assets_3 = []byte("{{ template \"header\" . }}\n\n
\n
\n\n\n\n\n\n\n\n\n\n\n\n
NameStartedActions
{{ .Service.Name }}{{ .Service.Started }}
\n\n

stdout

\n
\n  {{ range $idx, $line := .Service.Stdout.Lines -}}\n    {{ $line }}\n  {{ end }}\n  
\n\n

stderr

\n
\n  {{ range $idx, $line := .Service.Stderr.Lines -}}\n    {{ $line }}\n  {{ end }}\n  
\n
\n
\n\n{{ template \"footer\" . }}") diff --git a/internal/bundled/bundled.go b/internal/bundled/bundled.go new file mode 100644 index 0000000..9516a6f --- /dev/null +++ b/internal/bundled/bundled.go @@ -0,0 +1,5 @@ +package bundled + +func Asset(basename string) string { + return string(assets["assets/"+basename]) +} diff --git a/internal/iface/iface.go b/internal/iface/iface.go new file mode 100644 index 0000000..806120a --- /dev/null +++ b/internal/iface/iface.go @@ -0,0 +1,124 @@ +package iface + +import ( + "net" + "syscall" + "unsafe" +) + +// as per https://manpages.debian.org/jessie/manpages/netdevice.7.en.html +type ifreqAddr struct { + name [16]byte + addr syscall.RawSockaddrInet4 + pad [8]byte +} + +// as per https://manpages.debian.org/jessie/manpages/netdevice.7.en.html +type ifreqFlags struct { + name [16]byte + flags uint16 + pad [22]byte +} + +// as per http://lxr.free-electrons.com/source/include/uapi/linux/route.h#L30 +type rtentry struct { + pad1 uint64 + dst syscall.RawSockaddrInet4 + gateway syscall.RawSockaddrInet4 + genmask syscall.RawSockaddrInet4 + flags uint16 + pad [512]byte +} + +type Configsocket struct { + fd int + name [16]byte +} + +func NewConfigSocket(iface string) (Configsocket, error) { + fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) + if err != nil { + return Configsocket{}, err + } + + cs := Configsocket{ + fd: fd, + } + copy(cs.name[:], []byte(iface)) + return cs, nil +} + +func (cs Configsocket) Close() error { + return syscall.Close(cs.fd) +} + +func (cs Configsocket) ifreqAddr(request uintptr, addr net.IP) error { + req := ifreqAddr{ + name: cs.name, + addr: syscall.RawSockaddrInet4{ + Family: syscall.AF_INET, + }, + } + copy(req.addr.Addr[:], addr) + + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(cs.fd), request, uintptr(unsafe.Pointer(&req))); errno != 0 { + return errno + } + return nil +} + +func (cs Configsocket) SetAddress(addr net.IP) error { + return cs.ifreqAddr(syscall.SIOCSIFADDR, addr) +} + +func (cs Configsocket) SetNetmask(addr net.IPMask) error { + return cs.ifreqAddr(syscall.SIOCSIFNETMASK, net.IP(addr)) +} + +func (cs Configsocket) SetBroadcast(addr net.IP) error { + return cs.ifreqAddr(syscall.SIOCSIFBRDADDR, addr) +} + +func (cs Configsocket) Up() error { + req := ifreqFlags{name: cs.name} + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(cs.fd), syscall.SIOCGIFFLAGS, uintptr(unsafe.Pointer(&req))); errno != 0 { + return errno + } + + req.flags |= syscall.IFF_UP + req.flags |= syscall.IFF_RUNNING + + if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(cs.fd), syscall.SIOCSIFFLAGS, uintptr(unsafe.Pointer(&req))); errno != 0 { + return errno + } + + return nil +} + +func (cs Configsocket) AddRoute(dst, gateway net.IP, genmask net.IPMask) syscall.Errno { + req := rtentry{ + dst: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + gateway: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + genmask: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + flags: syscall.RTF_UP | syscall.RTF_GATEWAY, + } + copy(req.dst.Addr[:], dst) + copy(req.gateway.Addr[:], gateway) + copy(req.genmask.Addr[:], genmask) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(cs.fd), syscall.SIOCADDRT, uintptr(unsafe.Pointer(&req))) + return errno +} + +func (cs Configsocket) DelRoute(dst, gateway net.IP, genmask net.IPMask) syscall.Errno { + req := rtentry{ + dst: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + gateway: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + genmask: syscall.RawSockaddrInet4{Family: syscall.AF_INET}, + flags: syscall.RTF_UP | syscall.RTF_GATEWAY, + } + copy(req.dst.Addr[:], dst) + copy(req.gateway.Addr[:], gateway) + copy(req.genmask.Addr[:], genmask) + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(cs.fd), syscall.SIOCDELRT, uintptr(unsafe.Pointer(&req))) + return errno +} diff --git a/listeners.go b/listeners.go new file mode 100644 index 0000000..e9f00a6 --- /dev/null +++ b/listeners.go @@ -0,0 +1,135 @@ +package gokrazy + +import ( + "log" + "net" + "net/http" + "sync" +) + +var privateNets []net.IPNet +var ipv6LinkLocal net.IPNet + +func init() { + var pn = []string{ + // loopback: https://tools.ietf.org/html/rfc3330#section-2 + "127.0.0.0/8", + // loopback: https://tools.ietf.org/html/rfc3513#section-2.4 + "::1/128", + + // reserved: https://tools.ietf.org/html/rfc1918#section-3 + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + // reserved: https://tools.ietf.org/html/rfc4193#section-3.1 + "fc00::/7", + + // link-local: https://tools.ietf.org/html/rfc3927#section-1.2 + "169.254.0.0/16", + // link-local: https://tools.ietf.org/html/rfc4291#section-2.4 + "fe80::/10", + } + privateNets = make([]net.IPNet, len(pn)) + for idx, s := range pn { + _, net, err := net.ParseCIDR(s) + if err != nil { + log.Panicf(err.Error()) + } + privateNets[idx] = *net + if s == "fe80::/10" { + ipv6LinkLocal = *net + } + } +} + +func isPrivate(ipaddr net.IP) bool { + for _, n := range privateNets { + if n.Contains(ipaddr) { + return true + } + } + return false +} + +// PrivateInterfaceAddrs returns all private (as per RFC1918, RFC4193, +// RFC3330, RFC3513, RFC3927, RFC4291) host addresses of all active +// interfaces, suitable to be passed to net.Listen. +func PrivateInterfaceAddrs() ([]string, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, err + } + + var hosts []string + for _, i := range ifaces { + if i.Flags&net.FlagUp != net.FlagUp { + continue + } + addrs, err := i.Addrs() + if err != nil { + return nil, err + } + + for _, a := range addrs { + ipaddr, _, err := net.ParseCIDR(a.String()) + if err != nil { + return nil, err + } + + if !isPrivate(ipaddr) { + continue + } + + host := ipaddr.String() + if ipv6LinkLocal.Contains(ipaddr) { + host = host + "%" + i.Name + } + hosts = append(hosts, host) + } + } + return hosts, nil +} + +var ( + listeners = make(map[string]*http.Server) + listenersMu sync.Mutex +) + +func updateListeners(port string) error { + hosts, err := PrivateInterfaceAddrs() + if err != nil { + return err + } + + listenersMu.Lock() + defer listenersMu.Unlock() + vanished := make(map[string]bool) + for host := range listeners { + vanished[host] = false + } + for _, host := range hosts { + if _, ok := listeners[host]; ok { + // confirm found + delete(vanished, host) + } else { + // add a new listener + srv := &http.Server{ + Addr: net.JoinHostPort(host, port), + Handler: http.HandlerFunc(authenticated), + } + listeners[host] = srv + go func(host string, srv *http.Server) { + err := srv.ListenAndServe() + log.Printf("listener for %q died: %v", host, err) + listenersMu.Lock() + defer listenersMu.Unlock() + delete(listeners, host) + }(host, srv) + } + } + for host := range vanished { + listeners[host].Close() + delete(listeners, host) + } + return nil +} diff --git a/mount.go b/mount.go new file mode 100644 index 0000000..407dd70 --- /dev/null +++ b/mount.go @@ -0,0 +1,85 @@ +package gokrazy + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "syscall" +) + +func mountfs() error { + if err := syscall.Mount("tmpfs", "/tmp", "tmpfs", syscall.MS_NOSUID|syscall.MS_NODEV|syscall.MS_RELATIME, "size=50M"); err != nil { + return fmt.Errorf("tmpfs on /tmp: %v", err) + } + + // Symlink /etc/resolv.conf. We cannot do this in the root file + // system itself, as FAT does not support symlinks. + if err := syscall.Mount("tmpfs", "/etc", "tmpfs", syscall.MS_NOSUID|syscall.MS_NODEV|syscall.MS_RELATIME, "size=1M"); err != nil { + return fmt.Errorf("tmpfs on /etc: %v", err) + } + + if err := os.Symlink("/proc/net/pnp", "/etc/resolv.conf"); err != nil { + return fmt.Errorf("etc: %v", err) + } + + // Symlink /etc/localtime. We cannot do this in the root file + // system, as FAT filenames are limited to 8.3. + if err := os.Symlink("/localtim", "/etc/localtime"); err != nil { + return fmt.Errorf("etc: %v", err) + } + + if err := os.Mkdir("/etc/ssl", 0755); err != nil { + return fmt.Errorf("/etc/ssl: %v", err) + } + + if err := os.Symlink("/cacerts", "/etc/ssl/ca-bundle.pem"); err != nil { + return fmt.Errorf("/etc/ssl: %v", err) + } + + if err := ioutil.WriteFile("/etc/hosts", []byte("127.0.0.1 localhost\n::1 localhost\n"), 0644); err != nil { + return fmt.Errorf("/etc/hosts: %v", err) + } + + if err := syscall.Mount("devtmpfs", "/dev", "devtmpfs", 0, ""); err != nil { + if sce, ok := err.(syscall.Errno); ok && sce == syscall.EBUSY { + // /dev was already mounted (common in setups using nfsroot= or initramfs) + } else { + return fmt.Errorf("devtmpfs: %v", err) + } + } + + if err := os.MkdirAll("/dev/pts", 0755); err != nil { + return fmt.Errorf("mkdir /dev/pts: %v", err) + } + + if err := syscall.Mount("devpts", "/dev/pts", "devpts", 0, ""); err != nil { + return fmt.Errorf("devpts: %v", err) + } + + // /proc is useful for exposing process details and for + // interactive debugging sessions. + if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil { + if sce, ok := err.(syscall.Errno); ok && sce == syscall.EBUSY { + // /proc was already mounted (common in setups using nfsroot= or initramfs) + } else { + return fmt.Errorf("proc: %v", err) + } + } + + // /sys is useful for retrieving additional status from the + // kernel, e.g. ethernet device carrier status. + if err := syscall.Mount("sysfs", "/sys", "sysfs", 0, ""); err != nil { + if sce, ok := err.(syscall.Errno); ok && sce == syscall.EBUSY { + // /sys was already mounted (common in setups using nfsroot= or initramfs) + } else { + return fmt.Errorf("sys: %v", err) + } + } + + if err := syscall.Mount("/dev/mmcblk0p4", "/perm", "ext4", 0, ""); err != nil { + log.Printf("Could not mount permanent storage partition: %v", err) + } + + return nil +} diff --git a/status.go b/status.go new file mode 100644 index 0000000..e105fb4 --- /dev/null +++ b/status.go @@ -0,0 +1,156 @@ +package gokrazy + +import ( + "bytes" + "fmt" + "html/template" + "io" + "io/ioutil" + "log" + "net/http" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/gokrazy/gokrazy/internal/bundled" + + "golang.org/x/sys/unix" +) + +func parseMeminfo() map[string]int64 { + meminfo, err := ioutil.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 +} + +var commonTmpls = mustParseCommonTmpls() + +func mustParseCommonTmpls() *template.Template { + t := template.New("root") + t = template.Must(t.New("header").Parse(bundled.Asset("header.tmpl"))) + t = template.Must(t.New("footer").Parse(bundled.Asset("footer.tmpl"))) + return t +} + +var overviewTmpl = template.Must(template.Must(commonTmpls.Clone()).New("overview"). + Funcs(map[string]interface{}{ + "restarting": func(t time.Time) bool { + return time.Since(t).Seconds() < 5 + }, + + "last": func(stdout, stderr []string) string { + if len(stdout) == 0 && len(stderr) == 0 { + return "" + } + both := append(stdout, stderr...) + sort.Strings(both) + return both[len(both)-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) + }, + }). + Parse(bundled.Asset("overview.tmpl"))) + +var statusTmpl = template.Must(template.Must(commonTmpls.Clone()).New("statusTmpl").Parse(bundled.Asset("status.tmpl"))) + +func initStatus(services []*service) { + http.HandleFunc("/status", func(w http.ResponseWriter, r *http.Request) { + path := r.FormValue("path") + var svc *service + for _, s := range services { + if s.cmd.Path != path { + continue + } + svc = s + break + } + var buf bytes.Buffer + if err := statusTmpl.Execute(&buf, struct { + Service *service + BuildTimestamp string + Hostname string + }{ + Service: svc, + BuildTimestamp: buildTimestamp, + Hostname: hostname, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + io.Copy(w, &buf) + }) + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + var st unix.Statfs_t + if err := unix.Statfs("/perm", &st); err != nil { + log.Printf("could not stat /perm: %v", err) + } + hosts, err := PrivateInterfaceAddrs() + if err != nil { + log.Printf("could not get addrs: %v", err) + } + var buf bytes.Buffer + if err := overviewTmpl.Execute(&buf, struct { + Services []*service + PermUsed int64 + PermAvail int64 + PermTotal int64 + Hosts []string + BuildTimestamp string + Meminfo map[string]int64 + Hostname string + }{ + Services: services, + PermUsed: int64(st.Bsize) * int64(st.Blocks-st.Bfree), + PermAvail: int64(st.Bsize) * int64(st.Bavail), + PermTotal: int64(st.Bsize) * int64(st.Blocks), + Hosts: hosts, + BuildTimestamp: buildTimestamp, + Meminfo: parseMeminfo(), + Hostname: hostname, + }); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + io.Copy(w, &buf) + }) +} diff --git a/supervise.go b/supervise.go new file mode 100644 index 0000000..1034293 --- /dev/null +++ b/supervise.go @@ -0,0 +1,290 @@ +package gokrazy + +import ( + "container/ring" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +type lineRingBuffer struct { + sync.RWMutex + remainder string + r *ring.Ring +} + +func newLineRingBuffer(size int) *lineRingBuffer { + return &lineRingBuffer{ + r: ring.New(size), + } +} + +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 + } + + lrb.r.Value = text[:idx] + 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 +} + +type service struct { + stopped bool + stoppedMu sync.RWMutex + cmd *exec.Cmd + Stdout *lineRingBuffer + Stderr *lineRingBuffer + started time.Time + startedMu sync.RWMutex + attempt uint64 + process *os.Process + processMu sync.RWMutex +} + +func (s *service) Name() string { + return s.cmd.Args[0] +} + +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) setProcess(p *os.Process) { + s.processMu.Lock() + defer s.processMu.Unlock() + s.process = p +} + +func rssOfPid(pid int) int64 { + statm, err := ioutil.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 +} + +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) { + s.Stdout = newLineRingBuffer(100) + s.Stderr = newLineRingBuffer(100) + l := log.New(s.Stderr, "", log.LstdFlags|log.Ldate|log.Ltime) + attempt := 0 + for { + if s.Stopped() { + time.Sleep(1 * time.Second) + continue + } + + l.Printf("gokrazy: attempt %d, starting %+v", attempt, s.cmd.Args) + s.setStarted(time.Now()) + cmd := &exec.Cmd{ + Path: s.cmd.Path, + Args: s.cmd.Args, + Stdout: s.Stdout, + Stderr: s.Stderr, + SysProcAttr: &syscall.SysProcAttr{ + Unshareflags: syscall.CLONE_NEWNS, + }, + } + if attempt == 0 { + cmd.Env = append(cmd.Env, "GOKRAZY_FIRST_START=1") + } + + attempt++ + + if err := cmd.Start(); err != nil { + l.Println("gokrazy: " + err.Error()) + } + + s.setProcess(cmd.Process) + + if err := cmd.Wait(); 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") + } + time.Sleep(1 * time.Second) + } +} + +func redirectToStatus(w http.ResponseWriter, r *http.Request, path string) { + // 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(services []*service) { + for _, s := range services { + go supervise(s) + } + + findSvc := func(path string) *service { + for _, s := range services { + if s.cmd.Path == path { + return s + } + } + return nil + } + + http.HandleFunc("/stop", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected a POST request", http.StatusBadRequest) + return + } + + path := r.FormValue("path") + + s := findSvc(path) + if s == nil || s.Stopped() { + redirectToStatus(w, r, path) + return + } + + s.setStopped(true) + + p := s.Process() + if p == nil { + redirectToStatus(w, r, path) + return + } + + if err := p.Signal(syscall.SIGTERM); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + redirectToStatus(w, r, path) + }) + + http.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected a POST request", http.StatusBadRequest) + return + } + + signal := syscall.SIGTERM + if r.FormValue("signal") == "kill" { + signal = syscall.SIGKILL + } + + path := r.FormValue("path") + + s := findSvc(path) + if s == nil { + redirectToStatus(w, r, path) + return + } + + if s.Stopped() { + s.setStopped(false) + redirectToStatus(w, r, path) + return + } + + p := s.Process() + if p == nil { + redirectToStatus(w, r, path) + return + } + + if err := p.Signal(signal); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + redirectToStatus(w, r, path) + }) +} diff --git a/update.go b/update.go new file mode 100644 index 0000000..3730a96 --- /dev/null +++ b/update.go @@ -0,0 +1,154 @@ +package gokrazy + +import ( + "crypto/sha256" + "fmt" + "io" + "io/ioutil" + "log" + "net/http" + "os" + "regexp" + "sync" + "syscall" + "time" + + "golang.org/x/sys/unix" + + "github.com/gokrazy/fat" +) + +var rootRe = regexp.MustCompile(`root=/dev/mmcblk0p([2-3])`) + +func switchRootPartition(newRootPartition string) error { + f, err := os.OpenFile("/dev/mmcblk0p1", os.O_RDWR, 0600) + if err != nil { + return err + } + defer f.Close() + rd, err := fat.NewReader(f) + if err != nil { + return err + } + offset, length, err := rd.Extents("/cmdline.txt") + if err != nil { + return err + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return err + } + b := make([]byte, length) + if _, err := f.Read(b); err != nil { + return err + } + if _, err := f.Seek(offset, io.SeekStart); err != nil { + return err + } + + rep := rootRe.ReplaceAllLiteral(b, []byte("root=/dev/mmcblk0p"+newRootPartition)) + if _, err := f.Write(rep); err != nil { + return err + } + + return f.Close() +} + +func streamRequestTo(path string, r io.Reader) error { + f, err := os.OpenFile(path, os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + if _, err := io.Copy(f, r); err != nil { + return err + } + return f.Close() +} + +func nonConcurrentUpdateHandler(dest string) func(http.ResponseWriter, *http.Request) { + var mu sync.Mutex + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "expected a PUT request", http.StatusBadRequest) + return + } + + mu.Lock() + defer mu.Unlock() + + hash := sha256.New() + if err := streamRequestTo(dest, io.TeeReader(r.Body, hash)); err != nil { + log.Printf("updating %q failed: %v", dest, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "%x", hash.Sum(nil)) + } +} + +func nonConcurrentSwitchHandler(newRootPartition string) func(http.ResponseWriter, *http.Request) { + var mu sync.Mutex + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected a POST request", http.StatusBadRequest) + return + } + + mu.Lock() + defer mu.Unlock() + + if err := switchRootPartition(newRootPartition); err != nil { + log.Printf("switching root partition to %q failed: %v", newRootPartition, err) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +func initUpdate() error { + cmdline, err := ioutil.ReadFile("/proc/cmdline") + if err != nil { + return err + } + + matches := rootRe.FindStringSubmatch(string(cmdline)) + if matches == nil { + return fmt.Errorf("identify 2/3 partition: kernel command line %q did not match %v", string(cmdline), rootRe) + } + + rootPartition := matches[1] + var inactiveRootPartition string + switch rootPartition { + case "2": + inactiveRootPartition = "3" + case "3": + inactiveRootPartition = "2" + default: + return fmt.Errorf("root partition %q (from %q) is unexpectedly neither 2 nor 3", rootPartition, matches[0]) + } + + http.HandleFunc("/update/boot", nonConcurrentUpdateHandler("/dev/mmcblk0p1")) + http.HandleFunc("/update/root", nonConcurrentUpdateHandler("/dev/mmcblk0p"+inactiveRootPartition)) + http.HandleFunc("/update/switch", nonConcurrentSwitchHandler(inactiveRootPartition)) + http.HandleFunc("/reboot", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "expected a POST request", http.StatusBadRequest) + return + } + + // TODO: implement a shutdown sequence, i.e. kill + timeout + term; unmount, then force-unmount? + + if err := syscall.Unmount("/perm", unix.MNT_FORCE); err != nil { + log.Printf("unmounting /perm failed: %v", err) + } + + go func() { + time.Sleep(1 * time.Second) // give the HTTP response some time to be sent + if err := unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + }() + }) + + return nil +}