Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "flag" |
| 5 | "fmt" |
Suharsh Sivakumar | 23ea535 | 2014-11-18 18:10:00 -0800 | [diff] [blame] | 6 | "io" |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 7 | "os" |
| 8 | "os/exec" |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 9 | "os/signal" |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 10 | "strconv" |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 11 | "syscall" |
Bogdan Caprita | 7f49167 | 2014-11-13 14:51:08 -0800 | [diff] [blame] | 12 | |
Cosmos Nicolaou | d412cb2 | 2014-12-15 22:06:32 -0800 | [diff] [blame] | 13 | "golang.org/x/crypto/ssh/terminal" |
Bogdan Caprita | 7f49167 | 2014-11-13 14:51:08 -0800 | [diff] [blame] | 14 | |
Jiri Simsa | ffceefa | 2015-02-28 11:03:34 -0800 | [diff] [blame] | 15 | "v.io/x/ref/lib/flags/consts" |
| 16 | vsignals "v.io/x/ref/lib/signals" |
| 17 | _ "v.io/x/ref/profiles" |
| 18 | vsecurity "v.io/x/ref/security" |
| 19 | "v.io/x/ref/security/agent" |
| 20 | "v.io/x/ref/security/agent/server" |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 21 | |
Jiri Simsa | 6ac9522 | 2015-02-23 16:11:49 -0800 | [diff] [blame] | 22 | "v.io/v23" |
| 23 | "v.io/v23/security" |
Jiri Simsa | 337af23 | 2015-02-27 14:36:46 -0800 | [diff] [blame] | 24 | "v.io/x/lib/vlog" |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 25 | ) |
| 26 | |
Bogdan Caprita | c98a8b5 | 2014-12-01 10:08:47 -0800 | [diff] [blame] | 27 | var ( |
| 28 | keypath = flag.String("additional_principals", "", "If non-empty, allow for the creation of new principals and save them in this directory.") |
| 29 | noPassphrase = flag.Bool("no_passphrase", false, "If true, user will not be prompted for principal encryption passphrase.") |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 30 | |
| 31 | // TODO(caprita): We use the exit code of the child to determine if the |
| 32 | // agent should restart it. Consider changing this to use the unix |
| 33 | // socket for this purpose. |
| 34 | restartExitCode = flag.String("restart_exit_code", "", "If non-empty, will restart the command when it exits, provided that the command's exit code matches the value of this flag. The value must be an integer, or an integer preceded by '!' (in which case all exit codes except the flag will trigger a restart.") |
Bogdan Caprita | c98a8b5 | 2014-12-01 10:08:47 -0800 | [diff] [blame] | 35 | ) |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 36 | |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 37 | func main() { |
Robin Thellend | dd5f9e0 | 2015-02-02 14:45:46 -0800 | [diff] [blame] | 38 | os.Exit(Main()) |
| 39 | } |
| 40 | |
| 41 | func Main() int { |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 42 | flag.Usage = func() { |
| 43 | fmt.Fprintf(os.Stderr, `Usage: %s [agent options] command command_args... |
| 44 | |
Bogdan Caprita | 7f49167 | 2014-11-13 14:51:08 -0800 | [diff] [blame] | 45 | Loads the private key specified in privatekey.pem in %v into memory, then |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 46 | starts the specified command with access to the private key via the |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 47 | agent protocol instead of directly reading from disk. |
| 48 | |
Asim Shankar | 95910b6 | 2014-10-31 22:02:29 -0700 | [diff] [blame] | 49 | `, os.Args[0], consts.VeyronCredentials) |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 50 | flag.PrintDefaults() |
| 51 | } |
Bogdan Caprita | 7f49167 | 2014-11-13 14:51:08 -0800 | [diff] [blame] | 52 | flag.Parse() |
| 53 | if len(flag.Args()) < 1 { |
| 54 | fmt.Fprintln(os.Stderr, "Need at least one argument.") |
| 55 | flag.Usage() |
Robin Thellend | dd5f9e0 | 2015-02-02 14:45:46 -0800 | [diff] [blame] | 56 | return 1 |
Bogdan Caprita | 7f49167 | 2014-11-13 14:51:08 -0800 | [diff] [blame] | 57 | } |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 58 | var restartOpts restartOptions |
| 59 | if err := restartOpts.parse(); err != nil { |
| 60 | fmt.Fprintln(os.Stderr, err) |
| 61 | flag.Usage() |
Robin Thellend | dd5f9e0 | 2015-02-02 14:45:46 -0800 | [diff] [blame] | 62 | return 1 |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 63 | } |
| 64 | |
Ryan Brown | 10a4ade | 2015-02-10 13:17:18 -0800 | [diff] [blame] | 65 | // This is a bit tricky. We're trying to share the runtime's |
| 66 | // veyron.credentials flag. However we need to parse it before |
| 67 | // creating the runtime. We depend on the profile's init() function |
| 68 | // calling flags.CreateAndRegister(flag.CommandLine, flags.Runtime) |
| 69 | // This will read the VEYRON_CREDENTIALS env var, then our call to |
| 70 | // flag.Parse() will take any override passed on the command line. |
Asim Shankar | 4476cc6 | 2015-02-02 15:15:33 -0800 | [diff] [blame] | 71 | var dir string |
| 72 | if f := flag.Lookup("veyron.credentials").Value; true { |
| 73 | dir = f.String() |
Jiri Simsa | 6ac9522 | 2015-02-23 16:11:49 -0800 | [diff] [blame] | 74 | // Clear out the flag value to prevent v23.Init from |
Asim Shankar | 4476cc6 | 2015-02-02 15:15:33 -0800 | [diff] [blame] | 75 | // trying to load this password protected principal. |
| 76 | f.Set("") |
| 77 | } |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 78 | if len(dir) == 0 { |
Cosmos Nicolaou | 1fcb6a3 | 2015-02-17 07:46:02 -0800 | [diff] [blame] | 79 | vlog.Fatalf("The %v environment variable must be set to a directory: %q", consts.VeyronCredentials, os.Getenv(consts.VeyronCredentials)) |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 80 | } |
| 81 | |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 82 | p, passphrase, err := newPrincipalFromDir(dir) |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 83 | if err != nil { |
| 84 | vlog.Fatalf("failed to create new principal from dir(%s): %v", dir, err) |
| 85 | } |
| 86 | |
Jiri Simsa | 6ac9522 | 2015-02-23 16:11:49 -0800 | [diff] [blame] | 87 | // Clear out the environment variable before v23.Init. |
Asim Shankar | 4476cc6 | 2015-02-02 15:15:33 -0800 | [diff] [blame] | 88 | if err = os.Setenv(consts.VeyronCredentials, ""); err != nil { |
| 89 | vlog.Fatalf("setenv: %v", err) |
| 90 | } |
Jiri Simsa | 6ac9522 | 2015-02-23 16:11:49 -0800 | [diff] [blame] | 91 | ctx, shutdown := v23.Init() |
Suharsh Sivakumar | 19fbf99 | 2015-01-23 11:02:27 -0800 | [diff] [blame] | 92 | defer shutdown() |
Matt Rosencrantz | 3df8584 | 2014-12-04 16:10:45 -0800 | [diff] [blame] | 93 | |
Jiri Simsa | 6ac9522 | 2015-02-23 16:11:49 -0800 | [diff] [blame] | 94 | if ctx, err = v23.SetPrincipal(ctx, p); err != nil { |
Suharsh Sivakumar | 19fbf99 | 2015-01-23 11:02:27 -0800 | [diff] [blame] | 95 | vlog.Panic("failed to set principal for ctx: %v", err) |
| 96 | } |
Suharsh Sivakumar | 946f64d | 2015-01-08 10:48:13 -0800 | [diff] [blame] | 97 | |
Ryan Brown | 50b473a | 2014-09-23 14:23:00 -0700 | [diff] [blame] | 98 | if err = os.Setenv(agent.FdVarName, "3"); err != nil { |
Matt Rosencrantz | 97d67a9 | 2015-01-27 21:03:12 -0800 | [diff] [blame] | 99 | vlog.Fatalf("setenv: %v", err) |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 100 | } |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 101 | |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 102 | if *keypath == "" && passphrase != nil { |
| 103 | // If we're done with the passphrase, zero it out so it doesn't stay in memory |
| 104 | for i := range passphrase { |
| 105 | passphrase[i] = 0 |
| 106 | } |
| 107 | passphrase = nil |
| 108 | } |
| 109 | |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 110 | // Start running our server. |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 111 | var sock, mgrSock *os.File |
Matt Rosencrantz | 6edab56 | 2015-01-12 11:07:55 -0800 | [diff] [blame] | 112 | if sock, err = server.RunAnonymousAgent(ctx, p); err != nil { |
Matt Rosencrantz | 97d67a9 | 2015-01-27 21:03:12 -0800 | [diff] [blame] | 113 | vlog.Fatalf("RunAnonymousAgent: %v", err) |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 114 | } |
| 115 | if *keypath != "" { |
Matt Rosencrantz | 6edab56 | 2015-01-12 11:07:55 -0800 | [diff] [blame] | 116 | if mgrSock, err = server.RunKeyManager(ctx, *keypath, passphrase); err != nil { |
Matt Rosencrantz | 97d67a9 | 2015-01-27 21:03:12 -0800 | [diff] [blame] | 117 | vlog.Fatalf("RunKeyManager: %v", err) |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 118 | } |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 119 | } |
| 120 | |
Robin Thellend | dd5f9e0 | 2015-02-02 14:45:46 -0800 | [diff] [blame] | 121 | exitCode := 0 |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 122 | for { |
| 123 | // Run the client and wait for it to finish. |
| 124 | cmd := exec.Command(flag.Args()[0], flag.Args()[1:]...) |
| 125 | cmd.Stdin = os.Stdin |
| 126 | cmd.Stdout = os.Stdout |
| 127 | cmd.Stderr = os.Stderr |
| 128 | cmd.ExtraFiles = []*os.File{sock} |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 129 | |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 130 | if mgrSock != nil { |
| 131 | cmd.ExtraFiles = append(cmd.ExtraFiles, mgrSock) |
Bogdan Caprita | b61c675 | 2014-12-03 11:35:11 -0800 | [diff] [blame] | 132 | } |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 133 | |
| 134 | err = cmd.Start() |
| 135 | if err != nil { |
Matt Rosencrantz | 97d67a9 | 2015-01-27 21:03:12 -0800 | [diff] [blame] | 136 | vlog.Fatalf("Error starting child: %v", err) |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 137 | } |
| 138 | shutdown := make(chan struct{}) |
| 139 | go func() { |
| 140 | select { |
Suharsh Sivakumar | 946f64d | 2015-01-08 10:48:13 -0800 | [diff] [blame] | 141 | case sig := <-vsignals.ShutdownOnSignals(ctx): |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 142 | // TODO(caprita): Should we also relay double |
| 143 | // signal to the child? That currently just |
| 144 | // force exits the current process. |
| 145 | if sig == vsignals.STOP { |
| 146 | sig = syscall.SIGTERM |
| 147 | } |
| 148 | cmd.Process.Signal(sig) |
| 149 | case <-shutdown: |
| 150 | } |
| 151 | }() |
| 152 | cmd.Wait() |
| 153 | close(shutdown) |
| 154 | exitCode = cmd.ProcessState.Sys().(syscall.WaitStatus).ExitStatus() |
| 155 | if !restartOpts.restart(exitCode) { |
| 156 | break |
| 157 | } |
| 158 | } |
| 159 | // TODO(caprita): If restartOpts.enabled is false, we could close these |
| 160 | // right after cmd.Start(). |
| 161 | sock.Close() |
| 162 | mgrSock.Close() |
Robin Thellend | dd5f9e0 | 2015-02-02 14:45:46 -0800 | [diff] [blame] | 163 | return exitCode |
Ryan Brown | fed691e | 2014-09-15 13:09:40 -0700 | [diff] [blame] | 164 | } |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 165 | |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 166 | func newPrincipalFromDir(dir string) (security.Principal, []byte, error) { |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 167 | p, err := vsecurity.LoadPersistentPrincipal(dir, nil) |
| 168 | if os.IsNotExist(err) { |
| 169 | return handleDoesNotExist(dir) |
| 170 | } |
Suharsh Sivakumar | 4684f4e | 2014-10-24 13:42:06 -0700 | [diff] [blame] | 171 | if err == vsecurity.PassphraseErr { |
| 172 | return handlePassphrase(dir) |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 173 | } |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 174 | return p, nil, err |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 175 | } |
| 176 | |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 177 | func handleDoesNotExist(dir string) (security.Principal, []byte, error) { |
Bogdan Caprita | c98a8b5 | 2014-12-01 10:08:47 -0800 | [diff] [blame] | 178 | fmt.Println("Private key file does not exist. Creating new private key...") |
| 179 | var pass []byte |
| 180 | if !*noPassphrase { |
| 181 | var err error |
| 182 | if pass, err = getPassword("Enter passphrase (entering nothing will store unencrypted): "); err != nil { |
| 183 | return nil, nil, fmt.Errorf("failed to read passphrase: %v", err) |
| 184 | } |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 185 | } |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 186 | p, err := vsecurity.CreatePersistentPrincipal(dir, pass) |
Suharsh Sivakumar | 8a7fba4 | 2014-10-27 12:40:48 -0700 | [diff] [blame] | 187 | if err != nil { |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 188 | return nil, pass, err |
Suharsh Sivakumar | 8a7fba4 | 2014-10-27 12:40:48 -0700 | [diff] [blame] | 189 | } |
| 190 | vsecurity.InitDefaultBlessings(p, "agent_principal") |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 191 | return p, pass, nil |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 192 | } |
| 193 | |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 194 | func handlePassphrase(dir string) (security.Principal, []byte, error) { |
Bogdan Caprita | c98a8b5 | 2014-12-01 10:08:47 -0800 | [diff] [blame] | 195 | if *noPassphrase { |
| 196 | return nil, nil, fmt.Errorf("Passphrase required for decrypting principal.") |
| 197 | } |
Suharsh Sivakumar | 23ea535 | 2014-11-18 18:10:00 -0800 | [diff] [blame] | 198 | pass, err := getPassword("Private key file is encrypted. Please enter passphrase.\nEnter passphrase: ") |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 199 | if err != nil { |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 200 | return nil, nil, fmt.Errorf("failed to read passphrase: %v", err) |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 201 | } |
Ryan Brown | 8178944 | 2014-10-30 13:23:53 -0700 | [diff] [blame] | 202 | p, err := vsecurity.LoadPersistentPrincipal(dir, pass) |
| 203 | return p, pass, err |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 204 | } |
| 205 | |
| 206 | func getPassword(prompt string) ([]byte, error) { |
Jiri Simsa | ab5f4ff | 2014-11-18 15:31:48 -0800 | [diff] [blame] | 207 | if !terminal.IsTerminal(int(os.Stdin.Fd())) { |
Suharsh Sivakumar | 23ea535 | 2014-11-18 18:10:00 -0800 | [diff] [blame] | 208 | // If the standard input is not a terminal, the password is obtained by reading a line from it. |
| 209 | return readPassword() |
Jiri Simsa | ab5f4ff | 2014-11-18 15:31:48 -0800 | [diff] [blame] | 210 | } |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 211 | fmt.Printf(prompt) |
| 212 | stop := make(chan bool) |
| 213 | defer close(stop) |
| 214 | state, err := terminal.GetState(int(os.Stdin.Fd())) |
| 215 | if err != nil { |
| 216 | return nil, err |
| 217 | } |
| 218 | go catchTerminationSignals(stop, state) |
Suharsh Sivakumar | 23ea535 | 2014-11-18 18:10:00 -0800 | [diff] [blame] | 219 | defer fmt.Printf("\n") |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 220 | return terminal.ReadPassword(int(os.Stdin.Fd())) |
| 221 | } |
| 222 | |
Suharsh Sivakumar | 23ea535 | 2014-11-18 18:10:00 -0800 | [diff] [blame] | 223 | // readPassword reads form Stdin until it sees '\n' or EOF. |
| 224 | func readPassword() ([]byte, error) { |
| 225 | var pass []byte |
| 226 | var total int |
| 227 | for { |
| 228 | b := make([]byte, 1) |
| 229 | count, err := os.Stdin.Read(b) |
| 230 | if err != nil && err != io.EOF { |
| 231 | return nil, err |
| 232 | } |
| 233 | if err == io.EOF || b[0] == '\n' { |
| 234 | return pass[:total], nil |
| 235 | } |
| 236 | total += count |
| 237 | pass = secureAppend(pass, b) |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | func secureAppend(s, t []byte) []byte { |
| 242 | res := append(s, t...) |
| 243 | if len(res) > cap(s) { |
| 244 | // When append needs to allocate a new array, clear out the old one. |
| 245 | for i := range s { |
| 246 | s[i] = '0' |
| 247 | } |
| 248 | } |
| 249 | // Clear out the second array. |
| 250 | for i := range t { |
| 251 | t[i] = '0' |
| 252 | } |
| 253 | return res |
| 254 | } |
| 255 | |
Suharsh Sivakumar | 30ee666 | 2014-10-29 18:10:07 -0700 | [diff] [blame] | 256 | // catchTerminationSignals catches signals to allow us to turn terminal echo back on. |
| 257 | func catchTerminationSignals(stop <-chan bool, state *terminal.State) { |
| 258 | var successErrno syscall.Errno |
| 259 | sig := make(chan os.Signal, 4) |
| 260 | // Catch the blockable termination signals. |
| 261 | signal.Notify(sig, syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGHUP) |
| 262 | select { |
| 263 | case <-sig: |
| 264 | // Start on new line in terminal. |
| 265 | fmt.Printf("\n") |
| 266 | if err := terminal.Restore(int(os.Stdin.Fd()), state); err != successErrno { |
| 267 | vlog.Errorf("Failed to restore terminal state (%v), you words may not show up when you type, enter 'stty echo' to fix this.", err) |
| 268 | } |
| 269 | os.Exit(-1) |
| 270 | case <-stop: |
| 271 | signal.Stop(sig) |
| 272 | } |
Suharsh Sivakumar | aca1c32 | 2014-10-21 11:27:32 -0700 | [diff] [blame] | 273 | } |
Bogdan Caprita | 67a6211 | 2014-12-23 17:35:55 -0800 | [diff] [blame] | 274 | |
| 275 | type restartOptions struct { |
| 276 | enabled, unless bool |
| 277 | code int |
| 278 | } |
| 279 | |
| 280 | func (opts *restartOptions) parse() error { |
| 281 | code := *restartExitCode |
| 282 | if code == "" { |
| 283 | return nil |
| 284 | } |
| 285 | opts.enabled = true |
| 286 | if code[0] == '!' { |
| 287 | opts.unless = true |
| 288 | code = code[1:] |
| 289 | } |
| 290 | var err error |
| 291 | if opts.code, err = strconv.Atoi(code); err != nil { |
| 292 | return fmt.Errorf("Failed to parse restart exit code: %v", err) |
| 293 | } |
| 294 | return nil |
| 295 | } |
| 296 | |
| 297 | func (opts *restartOptions) restart(exitCode int) bool { |
| 298 | return opts.enabled && opts.unless != (exitCode == opts.code) |
| 299 | } |