From ae36818dc53ce8ad1ca8558f47c385be0acbf79e Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Tue, 24 Feb 2026 12:35:34 -0800 Subject: [PATCH 1/7] Adds basic `status` command to CLI tool --- proton/vpn/cli/__init__.py | 3 +- proton/vpn/cli/commands/server.py | 80 +++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/proton/vpn/cli/__init__.py b/proton/vpn/cli/__init__.py index 4d48a4f..594bb37 100644 --- a/proton/vpn/cli/__init__.py +++ b/proton/vpn/cli/__init__.py @@ -30,7 +30,7 @@ from proton.vpn.cli.commands.account import signin, signout, info -from proton.vpn.cli.commands.server import connect, disconnect +from proton.vpn.cli.commands.server import connect, disconnect, status from proton.vpn.cli.commands.location_discovery import countries, cities from proton.vpn.cli.commands.set import config from proton.vpn.cli.core.controller import Params @@ -119,6 +119,7 @@ def app(ctx, verbose): # server related functionality app.add_command(connect) +app.add_command(status) app.add_command(disconnect) # listing functionality diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index 44f4a3d..cf5d782 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -150,9 +150,89 @@ async def connect( "Try connecting to a different server or check your network settings." ) + CONNECT_COMMAND = connect.name +@click.command() +@click.pass_context +@click.option("-j", "--json", is_flag=True, help="Dump status as JSON") +@click.option("-s", "--simple", is_flag=True, help="Print simple status to stdout, useful for i3bar or polybar.") +@run_async +async def status(ctx, json: bool = False, simple: bool = False): + """Get status from Proton VPN""" + controller = await Controller.create(params=ctx.obj, click_ctx=ctx) + connector = await controller.get_vpn_connector() + connection = connector.current_connection + + # Bail early if disconnected. + if connection is None: + print("ProtonVPN: Disconnected") + return + + server = await controller.find_logical_server(connection.server_name) + state = connector.current_state + + # Bail early if disconnected. + if state is None: + print("ProtonVPN: Disconnected") + return + + # _Try_ to get connection details... sometimes it's not populated though??? + _details = state.context.event.context.connection_details + if _details is None: + details = {} + else: + details = { + "exit": { + "ipv4": _details.server_ipv4, + "ipv6": _details.server_ipv6, + }, + "device": { + "ip": _details.device_ip, + "country": _details.device_country + } + } + + status = { + "exit": { + "city": server.city, + "country": server.exit_country, + "name": connection.server_name, + **details.get("exit", {}), + }, + "entry": { + "country": server.entry_country, + "ip": connection.server_ip, + "name": connection.server_name, + "device": { + **details.get("device", {}) + } + }, + } + + if json: + import json + return json.dumps(status) + + elif simple: + _ip = status["exit"].get("ipv4", "") + click.echo( + f"ProtonVPN: {status["exit"]["name"]}" + f"{f" ({_ip})" if _ip else ""}" + ) + return status + + else: + click.echo( + f"Connected to {status["exit"]["name"]} " + f"in {_get_most_specific_server_location(server)}. " + f"Your new IP address is {status["exit"].get("ipv4", "Unknown")}." + ) + # print(status) + return status + + @click.command() @click.pass_context @run_async From 6c2d824d8585b634a5c8e4296f154fbe69def670 Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Fri, 27 Feb 2026 18:51:37 -0800 Subject: [PATCH 2/7] Adds a caching of the exit IP in /tmp and reads from that on subsequent `status` calls if necessary --- proton/vpn/cli/commands/server.py | 43 +++++++++++++++++-------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index cf5d782..588d12e 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -19,10 +19,12 @@ You should have received a copy of the GNU General Public License along with ProtonVPN. If not, see . """ -from asyncio import CancelledError -from typing import Optional import click +import json +from pathlib import Path +from asyncio import CancelledError +from typing import Optional from proton.vpn.cli.core.exceptions import \ AuthenticationRequiredError, \ @@ -36,6 +38,8 @@ from proton.vpn.session.servers.types import LogicalServer, ServerFeatureEnum from proton.vpn.cli.commands.account import SIGNIN_COMMAND +TMP_FILE = Path("/tmp/protonvpn-connectiondetails.json") + class FailedConnection(click.ClickException): """When attempting to establish a connection, it fails @@ -138,6 +142,11 @@ async def connect( # notify user of successful connection and server details current_connection = connection_state.context.connection server_ip = connection_state.context.event.context.connection_details.server_ipv4 + if server_ip: + TMP_FILE.write_text( + json.dumps(connection_state.context.event.context.connection_details), + encoding="utf-8" + ) click.echo( f"Connected to {current_connection.server_name} " f"in {_get_most_specific_server_location(server)}. " @@ -179,34 +188,30 @@ async def status(ctx, json: bool = False, simple: bool = False): return # _Try_ to get connection details... sometimes it's not populated though??? - _details = state.context.event.context.connection_details - if _details is None: - details = {} - else: - details = { - "exit": { - "ipv4": _details.server_ipv4, - "ipv6": _details.server_ipv6, - }, - "device": { - "ip": _details.device_ip, - "country": _details.device_country - } - } + connection_details = state.context.event.context.connection_details + + # If it's not populated, read from tmp file on disk, if we can + try: + stored_cd = json.loads(TMP_FILE.read_text(encoding="utf-8")) + connection_details = connection_details or stored_cd + except OSError: + pass status = { "exit": { "city": server.city, "country": server.exit_country, "name": connection.server_name, - **details.get("exit", {}), + "ipv4": getattr(connection_details, "server_ipv4", ""), + "ipv6": getattr(connection_details, "server_ipv6", "") }, "entry": { "country": server.entry_country, "ip": connection.server_ip, "name": connection.server_name, - "device": { - **details.get("device", {}) + "client": { + "ip": getattr(connection_details, "device_ip", ""), + "country": getattr(connection_details, "device_country", "") } }, } From 32250f800df25ac37b5e5cb24b104ae983816ab7 Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Fri, 27 Feb 2026 19:04:01 -0800 Subject: [PATCH 3/7] Converts the json decoded data from disk back to the same ConnectionDetails object we all know and love for consistent handling --- proton/vpn/cli/commands/server.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index 588d12e..e809898 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -37,6 +37,7 @@ from proton.vpn.session.exceptions import ServerNotFoundError from proton.vpn.session.servers.types import LogicalServer, ServerFeatureEnum from proton.vpn.cli.commands.account import SIGNIN_COMMAND +from proton.vpn.connection import events TMP_FILE = Path("/tmp/protonvpn-connectiondetails.json") @@ -192,9 +193,11 @@ async def status(ctx, json: bool = False, simple: bool = False): # If it's not populated, read from tmp file on disk, if we can try: - stored_cd = json.loads(TMP_FILE.read_text(encoding="utf-8")) - connection_details = connection_details or stored_cd - except OSError: + connection_details = connection_details or events.ConnectionDetails( + **json.loads(TMP_FILE.read_text(encoding="utf-8")) + ) + except OSError, TypeError: + # Either the file wasn't there, permissions were bad, or the json was not in an expected format / malformed pass status = { From 82d9636d27511d0d96cf91ff96eef5e5e3c5b12a Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Mon, 2 Mar 2026 22:48:24 -0800 Subject: [PATCH 4/7] Fix name collision issue --- proton/vpn/cli/commands/server.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index e809898..5d95c63 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -21,7 +21,7 @@ """ import click -import json +from json import loads, dumps from pathlib import Path from asyncio import CancelledError from typing import Optional @@ -145,7 +145,7 @@ async def connect( server_ip = connection_state.context.event.context.connection_details.server_ipv4 if server_ip: TMP_FILE.write_text( - json.dumps(connection_state.context.event.context.connection_details), + dumps(connection_state.context.event.context.connection_details), encoding="utf-8" ) click.echo( @@ -194,7 +194,7 @@ async def status(ctx, json: bool = False, simple: bool = False): # If it's not populated, read from tmp file on disk, if we can try: connection_details = connection_details or events.ConnectionDetails( - **json.loads(TMP_FILE.read_text(encoding="utf-8")) + **loads(TMP_FILE.read_text(encoding="utf-8")) ) except OSError, TypeError: # Either the file wasn't there, permissions were bad, or the json was not in an expected format / malformed @@ -220,8 +220,7 @@ async def status(ctx, json: bool = False, simple: bool = False): } if json: - import json - return json.dumps(status) + return dumps(status) elif simple: _ip = status["exit"].get("ipv4", "") From 740550193d9c6ca21d70542bf3387e644bf3c046 Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Mon, 2 Mar 2026 22:56:10 -0800 Subject: [PATCH 5/7] Fixing merge conflicts and creating code efficiencies --- proton/vpn/cli/commands/server.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index 33d206f..0904fdc 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -150,17 +150,16 @@ async def connect( current_connection = connection_state.context.connection connection_details = connection_state.context.event.context.connection_details server_ip = connection_details.server_ipv4 if connection_details else None - if server_ip: - TMP_FILE.write_text( - dumps(connection_state.context.event.context.connection_details), - encoding="utf-8" - ) click.echo( f"Connected to {current_connection.server_name} " f"in {_get_most_specific_server_location(server)}. " ) if server_ip: click.echo(f"Your new IP address is {server_ip}.") + TMP_FILE.write_text( + dumps(connection_state.context.event.context.connection_details), + encoding="utf-8" + ) protocol = (await controller.get_settings()).protocol _display_openvpn_warning_if_necessary(protocol) From 9727d60c702cc753e6890e9e94217e87a2ff939e Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Mon, 2 Mar 2026 23:04:34 -0800 Subject: [PATCH 6/7] Writes tmp file if already connected and somehow it wasn't written before but we have the connection details --- proton/vpn/cli/commands/server.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index 0904fdc..c842284 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -156,10 +156,7 @@ async def connect( ) if server_ip: click.echo(f"Your new IP address is {server_ip}.") - TMP_FILE.write_text( - dumps(connection_state.context.event.context.connection_details), - encoding="utf-8" - ) + TMP_FILE.write_text(dumps(connection_details), encoding="utf-8") protocol = (await controller.get_settings()).protocol _display_openvpn_warning_if_necessary(protocol) @@ -187,7 +184,7 @@ async def status(ctx, json: bool = False, simple: bool = False): # Bail early if disconnected. if connection is None: - print("ProtonVPN: Disconnected") + click.echo("ProtonVPN: Disconnected") return server = await controller.find_logical_server(connection.server_name) @@ -195,12 +192,15 @@ async def status(ctx, json: bool = False, simple: bool = False): # Bail early if disconnected. if state is None: - print("ProtonVPN: Disconnected") + click.echo("ProtonVPN: Disconnected") return # _Try_ to get connection details... sometimes it's not populated though??? connection_details = state.context.event.context.connection_details + if connection_details and not TMP_FILE.exists(): + TMP_FILE.write_text(dumps(connection_details), encoding="utf-8") + # If it's not populated, read from tmp file on disk, if we can try: connection_details = connection_details or events.ConnectionDetails( From b0b363fc0baf6d0a020ec23d9b4a6bc2d81ca75f Mon Sep 17 00:00:00 2001 From: Spencer Walden Date: Tue, 3 Mar 2026 00:00:43 -0800 Subject: [PATCH 7/7] Fixes an issue where ConnectionDetails isn't JSON serializable --- proton/vpn/cli/commands/server.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/proton/vpn/cli/commands/server.py b/proton/vpn/cli/commands/server.py index c842284..11eca4e 100644 --- a/proton/vpn/cli/commands/server.py +++ b/proton/vpn/cli/commands/server.py @@ -42,7 +42,7 @@ inform_that_expired_serverlist_will_be_updated_if_necessary -TMP_FILE = Path("/tmp/protonvpn-connectiondetails.json") +TMP_FILE = Path("/tmp/protonvpn-connection_details.json") class FailedConnection(click.ClickException): @@ -156,7 +156,11 @@ async def connect( ) if server_ip: click.echo(f"Your new IP address is {server_ip}.") - TMP_FILE.write_text(dumps(connection_details), encoding="utf-8") + TMP_FILE.write_text(dumps({ + key: getattr(connection_details, key, "") + for key in dir(connection_details) + if not key.startswith("_") + }), encoding="utf-8") protocol = (await controller.get_settings()).protocol _display_openvpn_warning_if_necessary(protocol) @@ -199,7 +203,11 @@ async def status(ctx, json: bool = False, simple: bool = False): connection_details = state.context.event.context.connection_details if connection_details and not TMP_FILE.exists(): - TMP_FILE.write_text(dumps(connection_details), encoding="utf-8") + TMP_FILE.write_text(dumps({ + key: getattr(connection_details, key, "") + for key in dir(connection_details) + if not key.startswith("_") + }), encoding="utf-8") # If it's not populated, read from tmp file on disk, if we can try: