Background:
I use pass
to manage my passwords, with the GPG key stored externally on a YubiKey smart card, and have a fair number of scripts which invoke pass
to automate various tasks requiring authentication. This arrangement works great for most things, but there is one specific use-case for which it hasn't so far and which, unfortunately, comes up every time I login to a new session: automatically logging in and connecting to my ProtonVPN account, using Proton's CLI tool protonvpn-cli
.
The problem:
The following is a summary of the manual workflow I'm trying to automate. Note that I've already succeeded in automating almost all of these steps using expect
; the only issue I have left is how to fill in the GTK dialog box spawned by gnome-keyring (in step 1).
- Login to ProtonVPN:
protonvpn-cli login $username
- This step triggers two authentication requests: one from
gnome-keyring
, which spawns a dialog box requesting my login password, and one fromprotonvpn-cli
itself, requesting my Proton account credentials. - I can handle the second request by
expect
ing the text ofprotonvpn-cli
's password prompt, and thensend
ing the output of the appropriatepass
command (details in the script, included below). - The first request, however, is something I'm not sure how to deal with. Automatically grabbing the login password is easy (it's stored in my
pass
vault), and I imagine one cansend
that text to the stdin of a GTK process just like any other, but I don't know how to write anexpect
command that triggers if and only if a GTK password prompt appears at a specified point in the program. For instance, the naive approach ofexpect
ing the prompt text doesn't work (since that text isn't written to stdout, or at least not the stdout of any processexpect
is aware of).
- This step triggers two authentication requests: one from
- Connect to the VPN:
protonvpn-cli connect -f
- This sends a request to NetworkManager's secret agent for the appropriate network credentials.
- By spawning the process
nmcli agent all
first, and then listening on its stdout for a password prompt, I've managed to fully automate this part of the process.
The attempt at a solution:
Here's the full script I referenced above. I hacked it out pretty quickly, so it may not be pretty, but it works--except that I don't know how to expect
something like a GTK popup, as explained above.
#!/usr/bin/expect -f
# Expect script to automatically read VPN credentials from `pass` and enter them when prompted by `protonvpn-cli` and `nmcli`.
proc connect {passwd} {
# Connect to ProtonVPN. Assumes user is already logged in.
# setup nmcli secret agent to handle password request
# job numbers and PIDs need to be saved separately
set agent_pid [spawn nmcli agent all]
set agent_id $spawn_id
expect "nmcli successfully registered as a polkit agent."
# start protonvpn-cli process, which will send a password request to nmcli
set proton_pid [spawn protonvpn-cli connect -f]
set proton_id $spawn_id
# Switch back to polkit agent for password entry
set spawn_id $agent_id
expect {
"*assword*:*" {
send "$passwd\r"
}
}
# Finally, switch to proton process to listen for success or failure
set spawn_id $proton_id
expect {
"Successfully connected to Proton VPN." {
exec kill $agent_pid $proton_pid
return 0
}
}
# Timeout
exec kill $agent_pid $proton_pid
return 2
}
proc login {username passwd} {
# Login to ProtonVPN. Note that this doesn't actually connect to the VPN itself; that requires a second authentication step.
set pid [spawn protonvpn-cli login $username]
expect {
"*assword:*" {
send "$passwd\r"
expect {
"Successful login." {
return 0
}
}
# Login failed
exec kill $pid
return 1
}
"You are already logged in." {
return 0
}
}
# Timeout
exec kill $pid
return 2
}
proc print_errormsg {code} {
switch $code {
1 {
puts "Issue: failed to authenticate"
}
2 {
puts "Issue: timeout"
}
}
}
proc read_passfile {address dictname} {
# Read output from a password file managed by `pass` and assign text to the keys of a dictionary based on my personal passfile format convention.
# The convention is:
# * first line of passfile is the actual password
# * subsequent lines are key-value pairs in the form "key: value" (e.g., "username: user123")
# Indirection (local_dict is a reference to dictname)
upvar $dictname local_dict
# This is pretty hacky, but it works
set rawtext [exec pass show $address | sed "1s/^/password: /"]
foreach line [split $rawtext "\n"] {
set words [split [string map {":" {}} $line] " "]
if { [llength $words] == 2 } {
eval dict set local_dict $words
}
}
return $local_dict
}
set timeout 30
# Get credentials from `pass`
set proton_info [dict create]
set network_info [dict create]
read_passfile personal/proton proton_info
read_passfile wifi/vpn/proton network_info
# Login to ProtonVPN. On the first run after logging into a new session, this spawns a GTK dialog box requesting my login password that I haven't figured out how to deal with in this script.
set login_result [login [dict get $proton_info username] [dict get $proton_info password]]
if { $login_result != 0 } {
puts "Login failed!"
print_errormsg $login_result
exit 1
}
puts "Login succeeded. Connecting to VPN network..."
# Connect to VPN. This part works fine.
set connect_result [connect [dict get $network_info password]]
if { $connect_result != 0 } {
puts "Failed to connect to VPN network!"
print_errormsg $connect_result
exit 2
}
puts "VPN connection succeeded."
exit 0