I'm trying to invoke a 3rd party command line tool from a launch agent to connect to a server on my LAN. It seems impossible.
I have a little shell script that does what I need, and it works fine invoked in Terminal.app. The first time I run it that way I get permission prompts and I agree to them all. Subsequent invocations work.
Now I put a launch agent in ~/Library/Launch Agents
. It does nothing more than invoke my shell script at some specific time daily. launchd launches it, but it fails to access the LAN, with a 'no route to host' error message.
The command line tool I'm trying to use is not a macOS-provided one, but one from MacPorts/HomeBrew (I tried both). It doesn't even matter which tool I'm using, I tried a very simple case of just using nc/netcat. If I use the macOS-provided nc
, then I can access my LAN. If I install nc
from MacPorts /HomeBrew, that nc
cannot access my LAN.
This I've reproed on a literally brand new Mac, then updated to newest Sequoia (15.3.2), then done all I've described above.
I've ruled out DNS by working with raw IP addresses.
I've disabled gatekeeper with sudo spctl --master-disable
.
I've tried using cron instead of launch agents, same results.
I've tried codesigning with codesign -dvvv /opt/homebrew/bin/nc
, no help.
I've read TN3179 Understanding local network privacy.
In summary:
- Terminal.app -> script -> macOS/brew nc -> internet/LAN = works
- launchagent -> script -> macOS nc -> internet = works
- launchagent -> script -> macOS nc -> LAN = works
- launchagent -> script -> brew nc -> internet = works
- launchagent -> script -> brew nc -> LAN = fails
How can I make that last case work?
There are a bunch of factors that could lead to this problem but, after doing a bunch of testing here in my office, I suspect that this is a bug in local network privacy )-:
To start, I’m testing on macOS 15.3.2 (24D81), in a VM, restoring from a clean snapshot between each test.
I have a test tool that connects either via Network framework or BSD Sockets. The code is pasted in at the end of this email.
Note This relies on the helpers in Calling BSD Sockets from Swift.
The tool is signed with an Apple Development signing identity:
% codesign -d -vv Test778457 … Authority=Apple Development: Quinn Quinn (7XFU7D52S4) …
Note This is different from your case, because Homebrew uses ad-hoc signing. I was hoping that signing the code might help. It does not.
I have a launchd
property list that runs my shell script:
% plutil -p com.example.Test778457.plist { "Label" => "com.example.Test778457" "ProgramArguments" => [ 0 => "/Users/quinn/Test778457.sh" ] }
And a shell script that runs my tool:
% cat Test778457.sh #! /bin/sh set -e log emit --subsystem "com.example.Test778457" --category "script" "start" /Users/quinn/Test778457 bsd 192.168.1.39 80 log emit --subsystem "com.example.Test778457" --category "script" "end"
Note I used a shell script because that’s what you’re doing. Normally I recommend that folks don’t use shell scripts when user-controlled privileges, like Local Network, are involve. I actually tested this without a shell script just to see what’d happen. I saw some weird behaviour that I struggled to reproduce. However, in none of my tests did I see this completely fix the issue.
I load the launchd
property list as an agent:
% launchctl load com.example.Test778457.plist
And run a test by starting it:
% launchctl start com.example.Test778457
I monitor the results in Console.
Here’s what I saw:
-
I started the job as described above, with it configured to run the BSD Sockets code.
-
This did not display a local network alert and the connection failed with
EHOSTUNREACH
. So the BSD Sockets code can’t connect. -
I edited the script to change
bsd
tonw
. -
I started the job again. This time it presented the local network alert and successfully connected. So the Network framework code can connect.
-
I reverted the edits from step 3.
-
I started the job again. This time the BSD Sockets code was able to connect.
So, something weird is happening here. AFAIK local network privacy shouldn’t change behaviour based on which networking API you’re using, but clearly it does. Using a shell script is an added complication, but there’s nothing fundamentally wrong about that. And eliminating the shell script didn’t get this working anyway.
I recommend that you file a bug about this. Once you’re done, post back here with the bug number and I’ll add my owns findings to your bug.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
import Foundation import Network import os.log import System let log = Logger(subsystem: "com.example.Test778457", category: "tool") func runNetworkFramework(_ host: String, _ port: UInt16) -> Never { log.log("will connect NW, host: \("\(host)", privacy: .public), port: \("\(port)", privacy: .public)") let connection = NWConnection(host: .init(host), port: .init(rawValue: port)!, using: .tcp) connection.stateUpdateHandler = { newState in switch newState { case .ready: log.log("did connect") exit(0) case .failed(let error): log.log("did not connect, failed, error: \(error, privacy: .public)") exit(1) case .cancelled: log.log("did not connect, cancelled") exit(1) case .setup, .waiting(_), .preparing: fallthrough @unknown default: return } } DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { log.log("did not connect, timeout") exit(1) } connection.start(queue: .main) dispatchMain() } func runBSDSockets(_ host: String, _ port: UInt16) -> Never { log.log("will connect BSD, host: \(host, privacy: .public), port: \(port)") do { let s = try FileDescriptor.socket(AF_INET, SOCK_STREAM, 0) try s.connect(host, port) log.log("did connect") exit(0) } catch { log.log("did not connect, failed, error: \(error, privacy: .public)") exit(1) } } func main() { let arg1 = CommandLine.arguments.dropFirst(1).first.map { String($0) } let arg2 = CommandLine.arguments.dropFirst(2).first.map { String($0) } let arg3 = CommandLine.arguments.dropFirst(3).first.map { String($0) } let arg4 = CommandLine.arguments.dropFirst(4).first.map { String($0) } enum API: String { case nw case bsd } guard let apiStr = arg1, let api = API(rawValue: apiStr), let host = arg2, let port = arg3.flatMap( { UInt16($0) } ), arg4 == nil else { exit(1) } switch api { case .nw: runNetworkFramework(host, port) case .bsd: runBSDSockets(host, port) } } main()