Files
2026-07-12 14:12:15 -07:00

738 lines
17 KiB
Go

package gokrazy
import (
"container/ring"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/syslog"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"slices"
"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.SplitSeq(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 any) {
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 any) {
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
// getConfValue returns the value for the given configuration key,
// checking (in order): kernel command line, /perm, /etc/gokrazy.
// The key uses "/" as separator (e.g. "remote_syslog/target", which
// checks kernel cmdline gokrazy.remote_syslog.target, then
// /perm/remote_syslog/target, then /etc/gokrazy/remote_syslog/target.
//
// It returns the empty string if nothing is found.
func getConfValue(key string) string {
cmdlineKey := "gokrazy." + strings.ReplaceAll(key, "/", ".") + "="
b, _ := os.ReadFile("/proc/cmdline")
for field := range strings.FieldsSeq(string(b)) {
if v, ok := strings.CutPrefix(field, cmdlineKey); ok {
return v
}
}
if b, err := os.ReadFile("/perm/" + key); err == nil {
return strings.TrimSpace(string(b))
}
if b, err := os.ReadFile("/etc/gokrazy/" + key); err == nil {
return strings.TrimSpace(string(b))
}
return ""
}
func initRemoteSyslog() {
raddr := getConfValue("remote_syslog/target")
if raddr == "" {
return
}
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("gokrazy: 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
}
// If $PATH isn't already set, set $PATH
// so that binaries can find each other.
hasPath := slices.ContainsFunc(cmd.Env, func(s string) bool {
return strings.HasPrefix(s, "PATH=")
})
if !hasPath {
cmd.Env = append(cmd.Env, "PATH=/user:/gokrazy")
}
// If $USER isn't already set, set it to root
// in order to further increase the chance of daemons
// working properly out of the box
hasUser := slices.ContainsFunc(cmd.Env, func(s string) bool {
return strings.HasPrefix(s, "USER=")
})
if !hasUser {
cmd.Env = append(cmd.Env, "USER=root")
}
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)
}
}
// Sanity check pid value.
// Sending 0 for pid in Wait4 has special meaning, which we don't want.
for pid > 1 {
// 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)
}