I am creating a Swift package that has an executable that will run commands via Process. Some of these commands will require user input, such as sudo.
How can I ensure that the commands output is shown to the user, and also allow them to interact with the commands, such as typing in their password for sudo?
I have a few ways of running the command, which all fail in different ways:
public func run(_ command: [String]) throws {
try Process.run(URL(fileURLWithPath: "/usr/bin/env"), arguments: command) { process in
print("Process terminated", process)
}
}
Fails without user input:
Password:
sudo: unable to read password: Input/output error
I've also tried:
public func run(_ command: [String]) throws {
let process = Process()
process.launchPath = "/usr/bin/env"
process.arguments = command
let standardError = Pipe()
process.standardError = standardError
print("Running \(command.joined(separator: " "))")
try process.run()
process.waitUntilExit()
if process.terminationStatus != 0 {
let errorData = standardError.fileHandleForReading.readDataToEndOfFile()
let error = String(data: errorData, encoding: .utf8)!
throw CommandError(message: error, exitCode: process.terminationStatus)
}
}
which will output Running sudo [command...] and stall until ctrl+C is pressed, at which point the process exits and Password: is output:
Running sudo [command...]
^CPassword:
Not setting process.standardError does the same, but outputs:
Running sudo [command...]
^CPassword:
sudo: unable to read password: Input/output error
I've come across similar questions asking about running commands with sudo via a GUI application, but I am trying to do this via an executable.
I'm assuming I need to pass stdin somehow, but the documentation for standardInput on Process states If this method isn’t used, the standard input is inherited from the process that created the receiver so I'm not sure why it's not working.
I just figured this out in kotlin so i hope this helps someone and translates to swift. So i wanted users to be able to enter a terminal command and i'd run the process on their behalf. If they entered a sudo command i'd need to prompt for a password and then continue with their command providing the password to sudo. The trick was piping it in with the -k and -S switched:
when {
input.startsWith("sudo ") -> {
val password = "12345" //just like my luggage!
val cmd = arrayOf("/bin/bash", "-c", "echo '$password' | ${input.replace("sudo", "sudo -k -S")}")
val proc = rt.exec(commands, emptyArray())
val stdInput = BufferedReader(InputStreamReader(proc.inputStream))
}
}
Related
I built a Vapor 4 app that is currently deployed on a local Ubuntu 18 server VM, running behind NGINX and serving users without any issues.
Now I would like one of my web server routes to react to specific POSTs by executing a Bash command via Process (this, in order to send messages to a dedicated Slack channel via slack-cli, a tool I already use for other purposes and that is already configured and working both on my development machine and on the Ubuntu server).
With the following code, everything's working as desired when I run my Vapor app on my local machine (i.e.: immediately after the POST to the route the expected message appears in the Slack channel):
// What follows is placed inside my dedicated app.post route, after checking the response is valid...
let slackCLIPath = "/home/linuxbrew/.linuxbrew/bin/" // This is the slack-cli path on the Linux VM; I swap it with "/opt/homebrew/bin/" when running the app on my local Mac
_ = runShellScript("\(slackCLIPath)slack chat send '\(myMessageComingFromThePOST)' '#myChannel'")
// ...
// runShellScript() called above is the dedicated function (coming from [this SO answer](https://stackoverflow.com/a/43014767/3765705) that executes the Shell process, and its code follows:
func runShellScript(_ cmd: String) -> String? {
let pipe = Pipe()
let process = Process()
process.launchPath = "/bin/sh"
process.arguments = ["-c", String(format:"%#", cmd)]
process.standardOutput = pipe
let fileHandle = pipe.fileHandleForReading
process.launch()
return String(data: fileHandle.readDataToEndOfFile(), encoding: .utf8)
}
My issue is that, when I deploy my app on the Ubuntu server, both in debug and production, the execution of the Shell process does not happen like it did on my Mac: I have no errors logged by Vapor when I POST to that route, but no messages appear in Slack, even if I wait a while!
But here's the tricky part: as soon as I stop my Vapor app on the server, all messages are sent to Slack at once.
After a lot of testing (which obviously included confirming that from the server was possible to post without delays to Slack by using the exact same command passed to NSTask's Process class in my Vapor app), it appears like the Bash command is not executed until my Vapor app quits.
Clearly I'm missing something on how to make Process work "in realtime" with Vapor, and I'll be grateful for all the help I can get.
You need to wait until the task has finished. Looks like you're deadlocking yourself. This is how I run stuff on Linux:
// MARK: - Functions
#discardableResult
func shell(_ args: String..., returnStdOut: Bool = false, stdIn: Pipe? = nil) throws -> (Int32, Pipe) {
return try shell(args, returnStdOut: returnStdOut, stdIn: stdIn)
}
#discardableResult
func shell(_ args: [String], returnStdOut: Bool = false, stdIn: Pipe? = nil) throws -> (Int32, Pipe) {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/env")
task.arguments = args
let pipe = Pipe()
if returnStdOut {
task.standardOutput = pipe
}
if let stdIn = stdIn {
task.standardInput = stdIn
}
try task.run()
task.waitUntilExit()
return (task.terminationStatus, pipe)
}
extension Pipe {
func string() -> String? {
let data = self.fileHandleForReading.readDataToEndOfFile()
let result: String?
if let string = String(data: data, encoding: String.Encoding.utf8) {
result = string
} else {
result = nil
}
return result
}
}
Important lines being starting it with try task.run() and waiting with task.waitUntilExit()
I have problems running a Terminal command from a Cocoa Application.
The input for the Terminal is real easy: /Users/.../Csvmidi </Users/.../test.csv> /Users/.../Melody.mid
These are three inputs- three actual paths - are just written in a row and seperated by a spac: the first "Csvmidi" runs a Unix Application which converts the test.csv to an actual hearable MIDI file. Through the terminal it works perfectly...
I just don't get it to work via a Cocoa Application.
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh/")
process.arguments = [lblCsvmidi.stringValue,"<"+lblURL.stringValue+">",lblMidi.stringValue]
//I saved the URL of the UNIX program, test.csv and Melody.mid in a lable just to be sure.
//lblCsvmidi --> URL of Csvmidi UNIX program
//lblURL --> URL of test.csv
//lblMidi --> URL of Melody.mid
print(lblCsvmidi.stringValue,"<" + lblURL.stringValue + ">",lblMidi.stringValue)
// this print command was only made to check the arguments in the terminal if they would work --> they do
process.terminationHandler = { (process) in
print("\ndidFinish: \(!process.isRunning)")
}
do {
try process.run()
} catch {}
When I run the code it gives me either the error of an 75: unmatched", but actually there isn't a quotation mark in the command - or I get "permission denied" errors.
I tried several bin folders like ( I really tried almost every possible):
- /bin/zsh
- /bin/csh
- /bin/ksh
- ...
What am i doing wrong --> I haven't found information in other Questions here and the Process, NSTask and Bundle informations from Apple haven't helped me so far.
Thanks!!
The following runs without error on my system:
import Cocoa
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh/")
var args : [String]!
args = []
args.append("-c")
args.append("open '/Users/xxxx/Desktop/myApp.app/Contents/MacOS/myApp'")
process.arguments = args
process.terminationHandler = { (process) in
print("\ndidFinish: \(!process.isRunning)")
}
do {
try process.run()
} catch {}
let app = NSApplication.shared
app.setActivationPolicy(.regular)
app.activate(ignoringOtherApps:true)
app.run()
The strings after "-c" would depend on what you are trying to accomplish.
Okay so I'm trying to run a Mosquitto publish command in bash from a Swift application for MacOS. Here is my code:
#IBAction func buttonClicked(_ sender: Any) {
let mosquittoCommand = "mosquitto_pub --cert blahblah.pem --key blahblah.key --cafile blahblah.pem -h 'blah.blah.com' -p 443 -t 'blah/blah/blah/blah' -m '{\"msg\": \"blahblahblah\", \"time\": \"2019-08-07T15:12:00Z\", \"id\": \"blah-blah-blah\", \"localpwd\": \"blahblahblah\"}' --tls-alpn x-amzn-mqtt-ca -i 'blahblahblah'"
print(shell("cd /Users/Me/Desktop/certs && " + mosquittoCommand))
}
func shell(_ command: String) -> String {
let task = Process()
task.launchPath = "/usr/bin/env"
task.arguments = ["-c", command]
let pipe = Pipe()
task.standardOutput = pipe
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output: String = NSString(data: data, encoding: String.Encoding.utf8.rawValue)! as String
return output
}
I'm getting the following error:
/usr/bin/env: illegal option -- c
usage: env [-iv] [-P utilpath] [-S string] [-u name]
[name=value ...] [utility [argument ...]]
You'll have to trust me that running the command in a terminal window directly works as expected. The only difference is the escape characters in the mosquitto command to prevent the quotations messing up the command. Perhaps the escape chars are causing the problems?
I have no idea what the error is trying to tell me. Any advice would be greatly appreciated. Thanks.
EDIT - I've made sure chaining some basic commands (pwd, cd... etc) from Swift works. So it's definitely set up correctly to be able to run commands like this, I just don't know why it can't run the Mosquitto publish command.
The immediate reason for the error message is that /usr/bin/env does not have a -c option, apparently you mixed that up with /bin/bash -c "command ...". Also the env command can only start a single executable, not multiple commands chained together with &&.
The other problem is that the mosquitto_pub binary is not found when running your app from the Finder. As it turned out in the discussion, this program is installed (via Homebrew) in /usr/local/bin. That directory is usually in the search path of a Terminal shell, but not when an application is started from the Finder.
Using an absolute path for the program is one option:
let mosquittoCommand = "/usr/local/bin/mosquitto_pub --cert blahblah.pem ..."
As already said in the comments, it is easier to set the launch path (as an absolute path), the working directory where the command should be executed, and the command arguments as an array:
let task = Process()
task.launchPath = "/usr/local/bin/mosquitto_pub"
task.arguments = [
"--cert",
"blahblah.pem",
// ...
"-i",
"blahblahblah"
]
task.currentDirectoryPath = "/Users/Me/Desktop/certs"
This makes quoting the arguments and calling via the shell unnecessary.
Alternatively you can start the program via env (so that it is found at multiple possible locations) but then you have add "/usr/local/bin" to the search path:
// Add "/usr/local/bin" to search path:
var env = task.environment ?? [:]
if let path = env["PATH"] {
env["PATH"] = "/usr/local/bin:" + path
} else {
env["PATH"] = "/usr/local/bin"
}
task.environment = env
task.launchPath = "/usr/bin/env"
task.arguments = [
"mosquitto_pub",
"--cert",
"blahblah.pem",
// ...
"-i",
"blahblahblah"
]
task.currentDirectoryPath = "/Users/Me/Desktop/certs"
Finally, you can use
task.currentDirectoryURL = FileManager.default
.homeDirectoryForCurrentUser
.appendingPathComponent("certs")
to make the working directory work with any user name.
What I'm Trying to do
Call a bash script from my native osx app that syncs a local directory on my mac to a remote server.
The bash script uses the following libraries:
fswatch
rsync
The Error messages on the app's console:
watch.sh: line 4: /usr/local/bin/rsync: Operation not permitted
watch.sh: line 6: /usr/local/bin/fswatch: Operation not permitted
What I have done
I have run the script outside the app in the terminal and it works so problem.
I have tried disabling and enabling System Integrity Protection with no effect on the errors.
I tried calling the Bash script from a python script, and it worked without any errors.
Code
OSX App Code
//////////////////////////////////////////
// FUNCTION - VIEW DID LOAD
override func viewDidLoad() {
super.viewDidLoad()
command(args: "sh","watch.sh")
} // END - FUNCTION
////////////////////////////////////////////
// FUNCTION - COMMAND
func command(args: String...) {
// GET SCRIPTS PATH
let scriptsDir = Bundle.main.resourceURL!.appendingPathComponent("scripts").path
// CREATE A PROCESS INSTANCE
let process = Process()
// SET THE PROCESS PARAMETERS
process.launchPath = "/usr/bin/env"
process.currentDirectoryPath = scriptsDir
process.arguments = args
// CREATE A PIPE AND MAKE THE PROCESS
// PUT ALL THE OUTPUT THERE
let pipe = Pipe()
process.standardOutput = pipe
// LAUNCH THE PROCESS
process.launch()
// GET THE DATA
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)
print(output!)
} // END - FUNCTION
Bash Script
#!/bin/bash
/usr/local/bin/rsync --rsh="/usr/local/bin/sshpass -p ************* ssh -l username" -azP --delete "/path/to/local" username#111.111.111.111:/path/to/remote
/usr/local/bin/fswatch -o "/path/to/local" | while read f; do
/usr/local/bin/rsync --rsh="/usr/local/bin/sshpass -p ************* ssh -l username" -azP --delete "/path/to/local" username#111.111.111.111:/path/to/remote
done
I am trying to make my program connect to another computer using SSH. It would be generic (the user would provide the hostname, IP, and password), so keys can't be used. This is the code I have so far:
func terminalSSH(host:String, password:String, IP:String) {
let pipe = Pipe()
let args = ["-p", password, "ssh", "\(host)#\(IP)"]
let task = Process()
task.launchPath = "/usr/local/bin/sshpass"
task.arguments = args
task.standardOutput = pipe
task.launch()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let output = NSString(data: data, encoding: String.Encoding.utf8.rawValue)
print(output)
}
My issue is that I can't see if the connection was successful using sshpass. I want the user to be notified the moment the connection succeeded or failed. Also, it seems like sshpass is the only command I can use to SSH because Terminal forces the user to input the password. I know this code works to establish a connection because the system.log on my test target computer displays it. Thanks.
Firstly, you should not be using sshpass and a plain text password, but private keys.
To get the tasks exit code, you can use the following:
task.waitUntilExit()
let status = task.terminationStatus
Do know however, that waitUntilExit will block the return of this method until the session has ended.
Whether the session ends in 10 minutes, or immediately due to an error, you can evaluate the terminationStatus to see how the program exited.
If the return code is 0, everything exited nicely; if there is a non-zero value, you can take different actions based on the reason for failure.
task.waitUntilExit()
let status = task.terminationStatus
if status == 0 {
print("Graceful exit.")
} else {
print("Failed with code " + status)
}
Detecting and responding to a successful connection will be more difficult here, as the waitUntilExit call may block for as long as the ssh session is open.
You also have the option of using a loop, and checking task.isRunning.