Skip to content

LLT-6855: Separation of daemon lifecycle from VPN connection state management - #1909

Open
tomasz-kumor wants to merge 4 commits into
mainfrom
kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite
Open

tomasz-kumor wants to merge 4 commits into
mainfrom
kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite

Conversation

@tomasz-kumor

@tomasz-kumor tomasz-kumor commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Problem

Currently the VPN connection state is tightly coupled with the daemon lifecycle and there is not way to connect/disconnect VPN exit node without changing the NordVPN Lite daemon state.

Solution

  • Separate nordvpnlite daemon lifecycle management from VPN connection state management.
    • Introduced nordvpnlite start --do-not-connect flag to allow separate launching the daemon from establishing VPN connection.
    • Introduced dedicated CLI connect/disconnect commands that allows to control the VPN connection state

Testing

  1. Run daemon without establishing VPN connection
# ./nordvpnlite start --do-not-connect -c config.json

Check that daemon is running but VPN connection is not established

# ./nordvpnlite status
{
  "telio_is_running": true,
  "ip_address": null,
  "exit_node": null
}

Connect to the VPN exit node & check that VPN connection is established

# ./nordvpnlite connect
Command executed successfully
# /nordvpnlite status
{
  "telio_is_running": true,
  "ip_address": null,
  "exit_node": {
    "identifier": "2432424-234536f34f3-4235f34-323",
    "public_key": "cnwurvevke4747tb43y3v74659g45gh45vd5e",
    "hostname": "lt.nordvpn.com",
    "endpoint": "181.88.88.88:51320",
    "state": "connected"
  }
}

Disconnect from the VPN exit node

# ./nordvpnlite disconnect
Command executed successfully
# ./nordvpnlite status
{
  "telio_is_running": true,
  "ip_address": null,
  "exit_node": null
}
  1. Check legacy behavior
# ./nordvpnlite stop
# ./nordvpnlite start -c config.json
...
# /nordvpnlite status
{
  "telio_is_running": true,
  "ip_address": null,
  "exit_node": {
    "identifier": "2432424-234536f34f3-4235f34-323",
    "public_key": "cnwurvevke4747tb43y3v74659g45gh45vd5e",
    "hostname": "lt.nordvpn.com",
    "endpoint": "181.88.88.88:51320",
    "state": "connected"
  }
}

☑️ Definition of Done checklist

  • Commit history is clean (requirements)
  • README.md is updated
  • Functionality is covered by unit or integration tests

@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from f57a61c to e00827b Compare July 24, 2026 09:19
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from e00827b to 2558ede Compare July 24, 2026 11:05
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 958e427 to 0c1644b Compare July 30, 2026 09:52
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 0c1644b to 26f42ca Compare July 30, 2026 15:09
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 26f42ca to 3111f18 Compare July 31, 2026 09:56
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 3111f18 to be8d6ac Compare July 31, 2026 14:23
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from be8d6ac to 9401f81 Compare July 31, 2026 14:25
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 9401f81 to e16efbb Compare July 31, 2026 14:28
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from e16efbb to 59fd4e2 Compare August 3, 2026 10:05
@tomasz-kumor tomasz-kumor changed the title [DRAFT] kumor/llt 6855 only one way of stopping nordvpnlite LLT-6855: start/stop command currently is confusing when used manually - can't stop daemon Aug 3, 2026
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 59fd4e2 to 09d954f Compare August 3, 2026 13:08
@tomasz-kumor
tomasz-kumor marked this pull request as ready for review August 3, 2026 13:21
@tomasz-kumor
tomasz-kumor requested a review from a team as a code owner August 3, 2026 13:21
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 09d954f to 507311f Compare August 5, 2026 11:40
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 507311f to d34ae16 Compare August 6, 2026 09:10
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from d34ae16 to fa5151f Compare August 6, 2026 13:41
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 3d885ab to 2549ddd Compare August 25, 2026 12:24
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 586fcf1 to 111bb52 Compare August 27, 2026 09:42
@tomasz-kumor
tomasz-kumor requested a review from lcruz99 August 27, 2026 12:38
error!("Connect: receiver dropped before result could be sent");
}
Ok(TelioTaskOutcome::Continue)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this look like? can you share a snippet of the logs?

/// Sender to send commands back to the telio task loop
telio_tx: mpsc::Sender<TelioTaskCmd>,
/// Flag to track if a connect is pending to avoid double-connect race
pending_connect: bool,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps this should be a state machine, updated periodically or/and whenever getstatus is called

"dev",
&self.interface_name,
]))?;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not affected by this PR, but I think we should log something here. eg: "Unsupported".

Comment on lines +95 to +100
#[error("Already connected to exit node")]
AlreadyConnected,
#[error("Connection to exit node already in progress")]
ConnectionPending,
#[error("Not connected to exit node")]
NotConnected,

@lcruz99 lcruz99 Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Abstract or reuse errors, these errors all have the same base, e.g.: CommandFailed.

I mean, these are error details not errors themselves.

mut rx_channel: mpsc::Receiver<TelioTaskCmd>,
) -> Result<(), NordVpnLiteError> {
) -> Result<bool, NordVpnLiteError> {
let mut was_connected = false;

@lcruz99 lcruz99 Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is was_connected added? Also find it a bit confusing/non-intuitive that fn start_listening_commands() returns it.

external_nodes.iter().find(|node| node.is_exit).map(|node| {
ExitNodeStatus::from_node(node, exit_node.endpoint.hostname)
TelioTaskCmd::Disconnect(response_tx) => {
let result = (|| {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why the closure?

Ok(()) => Ok(CommandResponse::Ok),
Err(e) => Ok(CommandResponse::Err(e)),
})
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason for blocking here and not on the upper ClientCmd::Connect arm?

ClientCmd::GetStatus
| ClientCmd::Reload
| ClientCmd::Connect
| ClientCmd::Disconnect => CommandResponse::DaemonInitializing,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

at this point we should consider replacing these commands with _

@lcruz99

lcruz99 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

OpenWrt tests at https://github.com/NordSecurity/libtelio/blob/kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite/nat-lab/tests/test_openwrt.py should be adapted, they are still using start/stopping the daemon on every reconnect. It's critical that these new changes are tested on that platform.

To avoid missing them, maybe is a good idea to put them in the same dir or couple them smw

nat-lab
├──tests
         ├──nordvpnlite
                 ├── test_nordvpnlite.py
                 └── test_openwrt.py

/// Sends `command` to a fake daemon that replies with `error_msg`
/// and asserts that the error is propagated to the client.
async fn test_command_error_helper(command: ClientCmd, error_msg: &str) {
let path = make_socket_path();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is not a test but a helper, remove the test_ prefix.

response.unwrap(),
CommandResponse::Err(error_msg.to_string())
);
assert_eq!(cmd.unwrap(), command);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assertions should be made on tests, not on helpers.

trace!("TelioTask got command {:?}", cmd);

if matches!(cmd, TelioTaskCmd::Quit(_)) {
was_connected = self.is_connected()?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why return if not connected?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// Sends `command` to a fake daemon that replies with `error_msg`
/// and asserts that the error is propagated to the client.
async fn test_command_error_helper(command: ClientCmd, error_msg: &str) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no point of using a string literal for error_msg, use a owned type instead ie: String

}

#[tokio::test]
async fn test_command_disconnect_error() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

where's the assertions?

}

#[tokio::test]
async fn test_command_connect_returns_ok_immediately() {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is this test doing? is literally the same as test_command_connect()

});

// Has to be after cleanup to avoid issues with missing device
ctx.telio.stop();

@lcruz99 lcruz99 Sep 2, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a workaround for a faulty cleanup. If the device is missing it shouldn't fail cleanup but instead handle that failure. It should be resilient.

Currently, starting the nordvpnlite daemon is strictly coupled with establishing
a VPN connection. This commit introduces a new flag that allows the daemon
startup to be separated from establishing a VPN connection, without breaking
the legacy behaviour.
Without explicitly deleting the IPv6 default route, the route
persisted after the interface was brought down, causing stale
routing entries and potential IPv6 traffic leaks through the
old interface.
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from 111bb52 to a919b29 Compare September 11, 2026 08:30
After reload, the daemon re-evaluated --do-not-connect instead
of the VPN last state and as a result after the reload VPN connection
state could be different than the pre-reload state.
Currently, establishing a VPN connection is tightly coupled with
the NordVPN Lite daemon start/stop lifecycle.
This commit decouples VPN connection management from daemon state management,
establishing clear separation of concerns between the two functionalities.
@tomasz-kumor
tomasz-kumor force-pushed the kumor/LLT-6855_only_one_way_of_stopping_nordvpnlite branch from a919b29 to 3beaa3d Compare September 11, 2026 11:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants