738 lines
17 KiB
Go
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)
|
|
}
|