github.com/keybase/client/go@v0.0.0-20240309051027-028f7c731f8b/launchd/launchd.go (about)

     1  // Copyright 2015 Keybase, Inc. All rights reserved. Use of
     2  // this source code is governed by the included BSD license.
     3  
     4  //go:build darwin
     5  // +build darwin
     6  
     7  package launchd
     8  
     9  import (
    10  	"bufio"
    11  	"bytes"
    12  	"fmt"
    13  
    14  	"os"
    15  	"os/exec"
    16  	"os/user"
    17  	"path/filepath"
    18  	"strconv"
    19  	"strings"
    20  	"syscall"
    21  	"time"
    22  
    23  	"golang.org/x/sys/unix"
    24  
    25  	"github.com/keybase/client/go/libkb"
    26  )
    27  
    28  // Service defines a service
    29  type Service struct {
    30  	label string
    31  	log   Log
    32  }
    33  
    34  // NewService constructs a launchd service.
    35  func NewService(label string) Service {
    36  	return Service{
    37  		label: label,
    38  		log:   emptyLog{},
    39  	}
    40  }
    41  
    42  // SetLogger sets the logger
    43  func (s *Service) SetLogger(log Log) {
    44  	if log != nil {
    45  		s.log = log
    46  	} else {
    47  		s.log = emptyLog{}
    48  	}
    49  }
    50  
    51  // Label for service
    52  func (s Service) Label() string { return s.label }
    53  
    54  // EnvVar defines and environment variable for the Plist
    55  type EnvVar struct {
    56  	key   string
    57  	value string
    58  }
    59  
    60  // NewEnvVar creates a new environment variable
    61  func NewEnvVar(key string, value string) EnvVar {
    62  	return EnvVar{key, value}
    63  }
    64  
    65  // Plist defines a launchd plist
    66  type Plist struct {
    67  	label     string
    68  	binPath   string
    69  	args      []string
    70  	envVars   []EnvVar
    71  	keepAlive bool
    72  	runAtLoad bool
    73  	logPath   string
    74  	comment   string
    75  }
    76  
    77  // NewPlist constructs a launchd service plist
    78  func NewPlist(label string, binPath string, args []string, envVars []EnvVar, logPath string, comment string) Plist {
    79  	return Plist{
    80  		label:     label,
    81  		binPath:   binPath,
    82  		args:      args,
    83  		envVars:   envVars,
    84  		keepAlive: true,
    85  		runAtLoad: false,
    86  		logPath:   logPath,
    87  		comment:   comment,
    88  	}
    89  }
    90  
    91  // Start will start the service.
    92  func (s Service) Start(wait time.Duration) error {
    93  	if !s.HasPlist() {
    94  		return fmt.Errorf("No service (plist) installed with label: %s", s.label)
    95  	}
    96  
    97  	plistDest := s.plistDestination()
    98  	s.log.Info("Starting %s", s.label)
    99  	// We start using load -w on plist file
   100  	output, err := exec.Command("/bin/launchctl", "load", "-w", plistDest).CombinedOutput()
   101  	s.log.Debug("Output (launchctl load): %s", string(output))
   102  	if err != nil {
   103  		return err
   104  	}
   105  
   106  	if wait > 0 {
   107  		status, waitErr := s.WaitForStatus(wait, 100*time.Millisecond)
   108  		if waitErr != nil {
   109  			return waitErr
   110  		}
   111  		if status == nil {
   112  			return fmt.Errorf("%s is not running", s.label)
   113  		}
   114  		s.log.Debug("Service status: %#v", status)
   115  	}
   116  
   117  	return nil
   118  }
   119  
   120  // HasPlist returns true if service has plist installed
   121  func (s Service) HasPlist() bool {
   122  	plistDest := s.plistDestination()
   123  	if _, err := os.Stat(plistDest); os.IsNotExist(err) {
   124  		s.log.Info("HasPlist: %s does not exist", plistDest)
   125  		return false
   126  	} else if err != nil {
   127  		s.log.Info("HasPlist: %s stat error: %s", plistDest, err)
   128  		return false
   129  	}
   130  
   131  	return true
   132  }
   133  
   134  func exitStatus(err error) int {
   135  	if exitErr, ok := err.(*exec.ExitError); ok {
   136  		if status, ok := exitErr.Sys().(syscall.WaitStatus); ok {
   137  			return status.ExitStatus()
   138  		}
   139  	}
   140  	return 0
   141  }
   142  
   143  // Stop a service.
   144  // Returns true, nil on successful stop.
   145  // If false, nil is returned it means there was nothing to stop.
   146  func (s Service) Stop(wait time.Duration) (bool, error) {
   147  	// We stop by removing the job. This works for non-demand and demand jobs.
   148  	output, err := exec.Command("/bin/launchctl", "remove", s.label).CombinedOutput()
   149  	s.log.Debug("Output (launchctl remove): %s", string(output))
   150  	if err != nil {
   151  		exitStatus := exitStatus(err)
   152  		// Exit status 3 on remove means there was no job to remove
   153  		if exitStatus == 3 {
   154  			s.log.Info("Nothing to stop (%s)", s.label)
   155  			return false, nil
   156  		}
   157  		return false, fmt.Errorf("Error removing via launchctl: %s", err)
   158  	}
   159  	if wait > 0 {
   160  		// The docs say launchd ExitTimeOut defaults to 20 seconds, but in practice
   161  		// it seems more like 5 seconds before it resorts to a SIGKILL.
   162  		// Because of the SIGKILL fallback we can use a large timeout here of 25
   163  		// seconds, which we'll likely never reach unless the process is zombied.
   164  		if waitErr := s.WaitForExit(wait); waitErr != nil {
   165  			return false, waitErr
   166  		}
   167  	}
   168  	s.log.Info("Stopped %s", s.label)
   169  	return true, nil
   170  }
   171  
   172  // Restart a service.
   173  func (s Service) Restart(wait time.Duration) error {
   174  	return Restart(s.Label(), wait, s.log)
   175  }
   176  
   177  type serviceStatusResult struct {
   178  	status *ServiceStatus
   179  	err    error
   180  }
   181  
   182  // WaitForStatus waits for service status to be available
   183  func (s Service) WaitForStatus(wait time.Duration, delay time.Duration) (*ServiceStatus, error) {
   184  	s.log.Info("Waiting for %s to be loaded...", s.label)
   185  	return waitForStatus(wait, delay, s.LoadStatus)
   186  }
   187  
   188  type loadStatusFn func() (*ServiceStatus, error)
   189  
   190  func waitForStatus(wait time.Duration, delay time.Duration, fn loadStatusFn) (*ServiceStatus, error) {
   191  	if wait <= 0 {
   192  		return fn()
   193  	}
   194  
   195  	ticker := time.NewTicker(delay)
   196  	defer ticker.Stop()
   197  	resultChan := make(chan serviceStatusResult, 1)
   198  	go func() {
   199  		for range ticker.C {
   200  			status, err := fn()
   201  			if err != nil {
   202  				resultChan <- serviceStatusResult{status: nil, err: err}
   203  				return
   204  			}
   205  			if status != nil && status.HasRun() {
   206  				resultChan <- serviceStatusResult{status: status, err: nil}
   207  				return
   208  			}
   209  		}
   210  	}()
   211  
   212  	select {
   213  	case res := <-resultChan:
   214  		return res.status, res.err
   215  	case <-time.After(wait):
   216  		return nil, nil
   217  	}
   218  }
   219  
   220  // WaitForExit waits for service to exit
   221  func (s Service) WaitForExit(wait time.Duration) error {
   222  	s.log.Info("Waiting for %s to exit...", s.label)
   223  	return waitForExit(wait, 200*time.Millisecond, s.LoadStatus)
   224  }
   225  
   226  func waitForExit(wait time.Duration, delay time.Duration, fn loadStatusFn) error {
   227  	ticker := time.NewTicker(delay)
   228  	defer ticker.Stop()
   229  	errChan := make(chan error, 1)
   230  	go func() {
   231  		for range ticker.C {
   232  			status, err := fn()
   233  			if err != nil {
   234  				errChan <- err
   235  				return
   236  			}
   237  			if status == nil || !status.IsRunning() {
   238  				errChan <- nil
   239  				return
   240  			}
   241  		}
   242  	}()
   243  
   244  	select {
   245  	case err := <-errChan:
   246  		return err
   247  	case <-time.After(wait):
   248  		return fmt.Errorf("Waiting for service exit timed out")
   249  	}
   250  }
   251  
   252  // Install will install the launchd service
   253  func (s Service) Install(p Plist, wait time.Duration) error {
   254  	return s.install(p, wait)
   255  }
   256  
   257  func (s Service) checkPlistPaths(p Plist) error {
   258  	if err := libkb.CanExec(p.binPath); err != nil {
   259  		s.log.Info("cannot exec binPath %s: %s", p.binPath, err)
   260  		return err
   261  	}
   262  
   263  	if p.logPath != "" {
   264  		// make sure the log directory is writable
   265  		logDir := filepath.Dir(p.logPath)
   266  		fi, err := os.Stat(logDir)
   267  		if err != nil {
   268  			s.log.Info("log directory %q stat error: %s", logDir, err)
   269  			return err
   270  		}
   271  		if !fi.IsDir() {
   272  			s.log.Info("log directory %q is not a directory", logDir)
   273  			return fmt.Errorf("plist logPath error: not a directory %q (full logPath = %q)", logDir, p.logPath)
   274  		}
   275  
   276  		if !writable(logDir) {
   277  			s.log.Info("log directory %q is not writable by current user", logDir)
   278  			return fmt.Errorf("log directory %q is not writable by current user (full logPath = %q)", logDir, p.logPath)
   279  		}
   280  
   281  		if otherWritable(logDir) {
   282  			s.log.Info("warning: log directory %q is writable by anyone", logDir)
   283  		}
   284  
   285  		// log directory looks ok
   286  		s.log.Info("log directory %q is writable by current user", logDir)
   287  
   288  		// make sure the log file is writable if it exists
   289  		_, err = os.Stat(p.logPath)
   290  		if err == nil {
   291  			if !writable(p.logPath) {
   292  				s.log.Info("log path %q exists but isn't writable by current user", p.logPath)
   293  				return fmt.Errorf("log path %q exists but isn't writable by current user", p.logPath)
   294  			}
   295  			s.log.Info("log path %q exists and is writable by current user", p.logPath)
   296  		} else if os.IsNotExist(err) {
   297  			s.log.Info("log path file %q doesn't exist yet (should be ok)", p.logPath)
   298  		} else {
   299  			s.log.Info("unexpected stat error on %q: %s", p.logPath, err)
   300  			return err
   301  		}
   302  	}
   303  
   304  	// Plist directory (~/Library/LaunchAgents/) might not exist on clean OS installs
   305  	// See GH issue: https://github.com/keybase/client/pull/1399#issuecomment-164810645
   306  	plistDest := s.plistDestination()
   307  	if err := libkb.MakeParentDirs(s.log, plistDest); err != nil {
   308  		s.log.Info("error making parent directories for %s: %s", plistDest, err)
   309  		return err
   310  	}
   311  
   312  	plistDir := filepath.Dir(plistDest)
   313  	if !writable(plistDir) {
   314  		s.log.Info("plist destination %q is not writable by current user", plistDir)
   315  		return fmt.Errorf("plist destination dir %q is not writable by current user (full filename = %q)", plistDir, plistDest)
   316  	}
   317  
   318  	if otherWritable(plistDir) {
   319  		s.log.Info("warning: plist destination %q is writable by anyone", plistDir)
   320  	}
   321  
   322  	s.log.Info("paths in plist look ok and have valid permissions")
   323  
   324  	return nil
   325  }
   326  
   327  func (s Service) savePlist(p Plist) error {
   328  	if err := s.checkPlistPaths(p); err != nil {
   329  		return err
   330  	}
   331  
   332  	plistDest := s.plistDestination()
   333  
   334  	plist := p.plistXML()
   335  
   336  	s.log.Info("Saving %s", plistDest)
   337  	file := libkb.NewFile(plistDest, []byte(plist), 0644)
   338  	return file.Save(s.log)
   339  }
   340  
   341  func (s Service) install(p Plist, wait time.Duration) error {
   342  	if err := s.savePlist(p); err != nil {
   343  		return err
   344  	}
   345  	return s.Start(wait)
   346  }
   347  
   348  // Uninstall will uninstall the launchd service
   349  func (s Service) Uninstall(wait time.Duration) error {
   350  	errs := []error{}
   351  	// It's safer to remove the plist before stopping in case stopping
   352  	// hangs the system somehow, the plist will still be removed.
   353  	plistDest := s.plistDestination()
   354  	if _, err := os.Stat(plistDest); err == nil {
   355  		s.log.Info("Removing %s", plistDest)
   356  		if err := os.Remove(plistDest); err != nil {
   357  			errs = append(errs, err)
   358  		}
   359  	}
   360  
   361  	if _, err := s.Stop(wait); err != nil {
   362  		errs = append(errs, err)
   363  	}
   364  
   365  	return libkb.CombineErrors(errs...)
   366  }
   367  
   368  // ListServices will return service with label that starts with a filter string.
   369  func ListServices(filters []string) (services []Service, err error) {
   370  	launchAgentDir := launchAgentDir()
   371  	if _, derr := os.Stat(launchAgentDir); os.IsNotExist(derr) {
   372  		return
   373  	}
   374  	files, err := os.ReadDir(launchAgentDir)
   375  	if err != nil {
   376  		return
   377  	}
   378  	for _, f := range files {
   379  		fileName := f.Name()
   380  		suffix := ".plist"
   381  		// We care about services that contain the filter word and end in .plist
   382  		for _, filter := range filters {
   383  			if strings.HasPrefix(fileName, filter) && strings.HasSuffix(fileName, suffix) {
   384  				label := fileName[0 : len(fileName)-len(suffix)]
   385  				service := NewService(label)
   386  				services = append(services, service)
   387  			}
   388  		}
   389  	}
   390  	return
   391  }
   392  
   393  // ServiceStatus defines status for a service
   394  type ServiceStatus struct {
   395  	label          string
   396  	pid            string // May be blank if not set, or a number "123"
   397  	lastExitStatus string // Will be blank if pid > 0, or a number "123"
   398  }
   399  
   400  // Label for status
   401  func (s ServiceStatus) Label() string { return s.label }
   402  
   403  // Pid for status (empty string if not running)
   404  func (s ServiceStatus) Pid() string { return s.pid }
   405  
   406  // LastExitStatus will be blank if pid > 0, or a number "123"
   407  func (s ServiceStatus) LastExitStatus() string { return s.lastExitStatus }
   408  
   409  // HasRun returns true if service is running, or has run and failed
   410  func (s ServiceStatus) HasRun() bool {
   411  	return s.Pid() != "" || s.LastExitStatus() != "0"
   412  }
   413  
   414  // Description returns service status info
   415  func (s ServiceStatus) Description() string {
   416  	var status string
   417  	infos := []string{}
   418  	if s.IsRunning() {
   419  		status = "Running"
   420  		infos = append(infos, fmt.Sprintf("(pid=%s)", s.pid))
   421  	} else {
   422  		status = "Not Running"
   423  	}
   424  	if s.lastExitStatus != "" {
   425  		infos = append(infos, fmt.Sprintf("exit=%s", s.lastExitStatus))
   426  	}
   427  	return status + " " + strings.Join(infos, ", ")
   428  }
   429  
   430  // IsRunning is true if the service is running (with a pid)
   431  func (s ServiceStatus) IsRunning() bool {
   432  	return s.pid != ""
   433  }
   434  
   435  // IsErrored is true if the service errored trying to start
   436  func (s ServiceStatus) IsErrored() bool {
   437  	return s.lastExitStatus != ""
   438  }
   439  
   440  // StatusDescription returns the service status description
   441  func (s Service) StatusDescription() string {
   442  	status, err := s.LoadStatus()
   443  	if status == nil {
   444  		return fmt.Sprintf("%s: Not Running", s.label)
   445  	}
   446  	if err != nil {
   447  		return fmt.Sprintf("%s: %v", s.label, err)
   448  	}
   449  	return fmt.Sprintf("%s: %s", s.label, status.Description())
   450  }
   451  
   452  // LoadStatus returns service status
   453  func (s Service) LoadStatus() (*ServiceStatus, error) {
   454  	out, err := exec.Command("/bin/launchctl", "list").Output()
   455  	if err != nil {
   456  		return nil, err
   457  	}
   458  
   459  	var pid, lastExitStatus string
   460  	var found bool
   461  	scanner := bufio.NewScanner(bytes.NewBuffer(out))
   462  	for scanner.Scan() {
   463  		line := scanner.Text()
   464  		fields := strings.Fields(line)
   465  		if len(fields) == 3 && fields[2] == s.label {
   466  			found = true
   467  			if fields[0] != "-" {
   468  				pid = fields[0]
   469  			}
   470  			if fields[1] != "-" {
   471  				lastExitStatus = fields[1]
   472  			}
   473  		}
   474  	}
   475  
   476  	if found {
   477  		// If pid is set and > 0, then clear lastExitStatus which is the
   478  		// exit status of the previous run and doesn't mean anything for
   479  		// the current state. Clearing it to avoid confusion.
   480  		pidInt, _ := strconv.ParseInt(pid, 0, 64)
   481  		if pid != "" && pidInt > 0 {
   482  			lastExitStatus = ""
   483  		}
   484  		return &ServiceStatus{label: s.label, pid: pid, lastExitStatus: lastExitStatus}, nil
   485  	}
   486  
   487  	return nil, nil
   488  }
   489  
   490  // CheckPlist returns false, if the plist destination doesn't match what we
   491  // would install. This means the plist is old and we need to update it.
   492  func (s Service) CheckPlist(plist Plist) (bool, error) {
   493  	plistDest := s.plistDestination()
   494  	return plist.Check(plistDest)
   495  }
   496  
   497  // Install will install a service
   498  func Install(plist Plist, wait time.Duration, log Log) error {
   499  	service := NewService(plist.label)
   500  	service.SetLogger(log)
   501  	return service.Install(plist, wait)
   502  }
   503  
   504  // Uninstall will uninstall a service
   505  func Uninstall(label string, wait time.Duration, log Log) error {
   506  	service := NewService(label)
   507  	service.SetLogger(log)
   508  	return service.Uninstall(wait)
   509  }
   510  
   511  // Start will start a service
   512  func Start(label string, wait time.Duration, log Log) error {
   513  	service := NewService(label)
   514  	service.SetLogger(log)
   515  	return service.Start(wait)
   516  }
   517  
   518  // Stop a service.
   519  // Returns true, nil on successful stop.
   520  // If false, nil is returned it means there was nothing to stop.
   521  func Stop(label string, wait time.Duration, log Log) (bool, error) {
   522  	service := NewService(label)
   523  	service.SetLogger(log)
   524  	return service.Stop(wait)
   525  }
   526  
   527  // ShowStatus shows status info for a service
   528  func ShowStatus(label string, log Log) error {
   529  	service := NewService(label)
   530  	service.SetLogger(log)
   531  	status, err := service.LoadStatus()
   532  	if err != nil {
   533  		return err
   534  	}
   535  	if status != nil {
   536  		log.Info("%s", status.Description())
   537  	} else {
   538  		log.Info("No service found with label: %s", label)
   539  	}
   540  	return nil
   541  }
   542  
   543  // Restart restarts a service
   544  func Restart(label string, wait time.Duration, log Log) error {
   545  	service := NewService(label)
   546  	service.SetLogger(log)
   547  	if _, err := service.Stop(wait); err != nil {
   548  		return err
   549  	}
   550  	return service.Start(wait)
   551  }
   552  
   553  func launchAgentDir() string {
   554  	return filepath.Join(launchdHomeDir(), "Library", "LaunchAgents")
   555  }
   556  
   557  // PlistDestination is the plist path for a label
   558  func PlistDestination(label string) string {
   559  	return filepath.Join(launchAgentDir(), label+".plist")
   560  }
   561  
   562  // PlistDestination is the service plist path
   563  func (s Service) PlistDestination() string {
   564  	return s.plistDestination()
   565  }
   566  
   567  func (s Service) plistDestination() string {
   568  	return PlistDestination(s.label)
   569  }
   570  
   571  func launchdHomeDir() string {
   572  	currentUser, err := user.Current()
   573  	if err != nil {
   574  		panic(err)
   575  	}
   576  	return currentUser.HomeDir
   577  }
   578  
   579  // Check if plist matches plist at path
   580  func (p Plist) Check(path string) (bool, error) {
   581  	if p.binPath == "" {
   582  		return false, fmt.Errorf("Invalid ProgramArguments")
   583  	}
   584  
   585  	// If path doesn't exist, we don't match
   586  	if _, err := os.Stat(path); os.IsNotExist(err) {
   587  		return false, nil
   588  	}
   589  
   590  	buf, err := os.ReadFile(path)
   591  	if err != nil {
   592  		return false, err
   593  	}
   594  
   595  	plistXML := p.plistXML()
   596  	if string(buf) == plistXML {
   597  		return true, nil
   598  	}
   599  
   600  	return false, nil
   601  }
   602  
   603  func (p Plist) Env() []string {
   604  	var env []string
   605  	for _, envVar := range p.envVars {
   606  		env = append(env, fmt.Sprintf("%s=%s", envVar.key, envVar.value))
   607  	}
   608  	return env
   609  }
   610  
   611  func (p Plist) FallbackCommand() *exec.Cmd {
   612  	cmd := exec.Command(p.binPath, p.args...)
   613  	cmd.Env = append(os.Environ(), p.Env()...)
   614  	return cmd
   615  }
   616  
   617  // TODO Use go-plist library
   618  func (p Plist) plistXML() string {
   619  	encodeTag := func(name, val string) string {
   620  		return fmt.Sprintf("<%s>%s</%s>", name, val, name)
   621  	}
   622  
   623  	encodeBool := func(val bool) string {
   624  		sval := "false"
   625  		if val {
   626  			sval = "true"
   627  		}
   628  		return fmt.Sprintf("<%s/>", sval)
   629  	}
   630  
   631  	pargs := []string{}
   632  	// First arg is the executable
   633  	pargs = append(pargs, encodeTag("string", p.binPath))
   634  	for _, arg := range p.args {
   635  		pargs = append(pargs, encodeTag("string", arg))
   636  	}
   637  
   638  	envVars := []string{}
   639  	for _, envVar := range p.envVars {
   640  		envVars = append(envVars, encodeTag("key", envVar.key))
   641  		envVars = append(envVars, encodeTag("string", envVar.value))
   642  	}
   643  
   644  	options := []string{}
   645  	if p.keepAlive {
   646  		options = append(options, encodeTag("key", "KeepAlive"), encodeBool(true))
   647  	}
   648  	if p.runAtLoad {
   649  		options = append(options, encodeTag("key", "RunAtLoad"), encodeBool(true))
   650  	}
   651  
   652  	xml := `<?xml version="1.0" encoding="UTF-8"?>
   653  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
   654  <plist version="1.0">
   655  <dict>
   656    <key>Label</key>
   657    <string>` + p.label + `</string>
   658    <key>EnvironmentVariables</key>
   659    <dict>` + "\n    " + strings.Join(envVars, "\n    ") + `
   660    </dict>
   661    <key>ProgramArguments</key>
   662    <array>` + "\n    " + strings.Join(pargs, "\n    ") + `
   663    </array>` +
   664  		"\n  " + strings.Join(options, "\n  ") + `
   665    <key>StandardErrorPath</key>
   666    <string>` + p.logPath + `</string>
   667    <key>StandardOutPath</key>
   668    <string>` + p.logPath + `</string>
   669    <key>WorkingDirectory</key>
   670    <string>/tmp</string>
   671  </dict>
   672  </plist>
   673  `
   674  
   675  	if p.comment != "" {
   676  		xml = fmt.Sprintf("<!-- %s -->\n%s", p.comment, xml)
   677  	}
   678  
   679  	return xml
   680  }
   681  
   682  // Log is the logging interface for this package
   683  type Log interface {
   684  	Debug(s string, args ...interface{})
   685  	Info(s string, args ...interface{})
   686  	Errorf(s string, args ...interface{})
   687  }
   688  
   689  type emptyLog struct{}
   690  
   691  func (l emptyLog) Debug(s string, args ...interface{})  {}
   692  func (l emptyLog) Info(s string, args ...interface{})   {}
   693  func (l emptyLog) Errorf(s string, args ...interface{}) {}
   694  
   695  func writable(path string) bool {
   696  	return unix.Access(path, unix.W_OK) == nil
   697  }
   698  
   699  func otherWritable(path string) bool {
   700  	fi, err := os.Stat(path)
   701  	if err != nil {
   702  		return false
   703  	}
   704  	return (fi.Mode() & 0002) != 0
   705  }