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 }