Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,30 @@ jobs:
- name: Run tests
run: cargo test --workspace --verbose

# User Story 2b: Run feature-gated integration test using mock-keyring
# This job runs the integration test that depends on the `mock-keyring` feature.
mock-keyring-test:
name: Test (mock-keyring integration)
runs-on: ubuntu-latest
strategy:
matrix:
rust: [stable]
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Install system dependencies
run: make deps

- name: Setup Rust toolchain
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
toolchain: ${{ matrix.rust }}

- name: Run mock-keyring integration test
# Run only the integration test that is gated by the `mock-keyring` feature
run: cargo test -p akon-core --test integration_keyring_tests --features mock-keyring -- --nocapture

# User Story 3: Build Verification
# Verifies successful compilation in release mode
build:
Expand Down
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resolver = "2"

[package]
name = "akon"
version = "1.2.1"
version = "1.2.2"
edition = "2021"
authors = ["vcwild"]
description = "A CLI tool for managing VPN connections with OpenConnect"
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,12 @@ protocol = "f5"
health_check_endpoint = "https://your-internal-server.example.com/"

# Optional: Customize retry behavior (defaults shown)
max_attempts = 5 # Maximum reconnection attempts
max_attempts = 3 # Maximum reconnection attempts (default)
base_interval_secs = 5 # Initial retry delay
backoff_multiplier = 2 # Exponential backoff multiplier
max_interval_secs = 60 # Maximum delay between attempts
consecutive_failures_threshold = 2 # Health check failures before reconnection
health_check_interval_secs = 60 # How often to check health
consecutive_failures_threshold = 1 # Health check failures before reconnection (default)
health_check_interval_secs = 10 # How often to check health (default)
```

## Why "akon"?
Expand Down Expand Up @@ -382,6 +382,35 @@ cargo tarpaulin --out Html

# View coverage report
open tarpaulin-report.html

## Testing and mock keyring

For tests that need a keyring implementation (CI or local), akon-core provides a lightweight
"mock keyring" implementation which stores credentials in-memory. This is useful for unit and
integration tests that must not interact with the system keyring.

The mock keyring and its test-only dependency (`lazy_static`) are behind a feature flag
so they are opt-in for consumers of `akon-core`:

- Feature name: `mock-keyring`
- Optional dependency: `lazy_static` (enabled only when `mock-keyring` is enabled)

Run tests that require the mock keyring with:

```bash
# Run a single integration test using the mock keyring
cargo test -p akon-core --test integration_keyring_tests --features mock-keyring -- --nocapture
```

Notes:

- `lazy_static` is declared as an optional dependency enabled by `mock-keyring` and also present
as a `dev-dependency` so developers can run tests locally without enabling the feature.
- This means the `lazy_static` crate is not linked into production binaries unless a consumer
enables `mock-keyring` explicitly.
- The mock keyring mirrors production retrieval behavior for PINs (the runtime truncates
retrieved PINs to 30 characters). Tests validate truncation and password assembly.

```

## Contributing
Expand Down
7 changes: 5 additions & 2 deletions akon-core/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
[package]
edition = "2021"
name = "akon-core"
version = "1.2.1"
version = "1.2.2"

[features]
default = []
mock-keyring = []
# Enable the mock keyring implementation and its test-only dependencies
mock-keyring = ["lazy_static"]

[lints.rust]
dead_code = "deny"
Expand All @@ -29,6 +30,8 @@ data-encoding = "2.9.0"
sha1 = "0.10.6"
regex = "1.10"
chrono = "0.4"
# lazy_static is optional and enabled via the `mock-keyring` feature
lazy_static = { version = "1.5", optional = true }

# Network interruption detection dependencies
zbus = "4.0"
Expand Down
11 changes: 4 additions & 7 deletions akon-core/src/auth/keyring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,12 @@
//! sensitive VPN credentials securely.

use crate::error::{AkonError, KeyringError};
use crate::types::{Pin, KEYRING_SERVICE_PIN};
use crate::types::{Pin, KEYRING_SERVICE_OTP, KEYRING_SERVICE_PIN};
use keyring::Entry;

/// Service name used for storing credentials in the keyring (legacy)
const SERVICE_NAME: &str = "akon-vpn";

/// Store an OTP secret in the system keyring
pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
let entry = Entry::new(SERVICE_NAME, username)
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;

entry
Expand All @@ -24,7 +21,7 @@ pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {

/// Retrieve an OTP secret from the system keyring
pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
let entry = Entry::new(SERVICE_NAME, username)
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;

entry
Expand All @@ -34,7 +31,7 @@ pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {

/// Check if an OTP secret exists in the keyring for the given username
pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {
let entry = Entry::new(SERVICE_NAME, username)
let entry = Entry::new(KEYRING_SERVICE_OTP, username)
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;

match entry.get_password() {
Expand Down
82 changes: 66 additions & 16 deletions akon-core/src/auth/keyring_mock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! system keyring access. Used in CI environments and for testing.

use crate::error::{AkonError, KeyringError};
use crate::types::Pin;
use crate::types::{Pin, KEYRING_SERVICE_OTP, KEYRING_SERVICE_PIN};
use std::collections::HashMap;
use std::sync::Mutex;

Expand All @@ -17,15 +17,9 @@ fn make_key(service: &str, username: &str) -> String {
format!("{}:{}", service, username)
}

/// Service name used for storing credentials in the keyring (legacy)
const SERVICE_NAME: &str = "akon-vpn";

/// Service name for PIN storage
const SERVICE_NAME_PIN: &str = "akon-vpn-pin";

/// Store an OTP secret in the mock keyring
pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {
let key = make_key(SERVICE_NAME, username);
let key = make_key(KEYRING_SERVICE_OTP, username);
let mut keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
Expand All @@ -35,7 +29,7 @@ pub fn store_otp_secret(username: &str, secret: &str) -> Result<(), AkonError> {

/// Retrieve an OTP secret from the mock keyring
pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {
let key = make_key(SERVICE_NAME, username);
let key = make_key(KEYRING_SERVICE_OTP, username);
let keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::RetrieveFailed))?;
Expand All @@ -47,7 +41,7 @@ pub fn retrieve_otp_secret(username: &str) -> Result<String, AkonError> {

/// Check if an OTP secret exists in the mock keyring for the given username
pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {
let key = make_key(SERVICE_NAME, username);
let key = make_key(KEYRING_SERVICE_OTP, username);
let keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
Expand All @@ -56,7 +50,7 @@ pub fn has_otp_secret(username: &str) -> Result<bool, AkonError> {

/// Delete an OTP secret from the mock keyring
pub fn delete_otp_secret(username: &str) -> Result<(), AkonError> {
let key = make_key(SERVICE_NAME, username);
let key = make_key(KEYRING_SERVICE_OTP, username);
let mut keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
Expand All @@ -66,7 +60,7 @@ pub fn delete_otp_secret(username: &str) -> Result<(), AkonError> {

/// Store a PIN in the mock keyring
pub fn store_pin(username: &str, pin: &Pin) -> Result<(), AkonError> {
let key = make_key(SERVICE_NAME_PIN, username);
let key = make_key(KEYRING_SERVICE_PIN, username);
let mut keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
Expand All @@ -76,20 +70,28 @@ pub fn store_pin(username: &str, pin: &Pin) -> Result<(), AkonError> {

/// Retrieve a PIN from the mock keyring
pub fn retrieve_pin(username: &str) -> Result<Pin, AkonError> {
let key = make_key(SERVICE_NAME_PIN, username);
let key = make_key(KEYRING_SERVICE_PIN, username);
let keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::PinNotFound))?;
let pin_str = keyring
.get(&key)
.cloned()
.ok_or(AkonError::Keyring(KeyringError::PinNotFound))?;
Pin::new(pin_str).map_err(AkonError::Otp)
// Mirror production retrieval behavior: enforce a 30-char internal limit
let pin_trimmed = pin_str.trim().to_string();
let stored = if pin_trimmed.chars().count() > 30 {
pin_trimmed.chars().take(30).collect::<String>()
} else {
pin_trimmed.clone()
};

Ok(Pin::from_unchecked(stored))
}

/// Check if a PIN exists in the mock keyring for the given username
pub fn has_pin(username: &str) -> Result<bool, AkonError> {
let key = make_key(SERVICE_NAME_PIN, username);
let key = make_key(KEYRING_SERVICE_PIN, username);
let keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::ServiceUnavailable))?;
Expand All @@ -98,7 +100,7 @@ pub fn has_pin(username: &str) -> Result<bool, AkonError> {

/// Delete a PIN from the mock keyring
pub fn delete_pin(username: &str) -> Result<(), AkonError> {
let key = make_key(SERVICE_NAME_PIN, username);
let key = make_key(KEYRING_SERVICE_PIN, username);
let mut keyring = MOCK_KEYRING
.lock()
.map_err(|_| AkonError::Keyring(KeyringError::StoreFailed))?;
Expand Down Expand Up @@ -151,4 +153,52 @@ mod tests {
delete_pin(username).expect("Failed to delete PIN");
assert!(!has_pin(username).expect("Failed to check PIN after delete"));
}

#[test]
fn test_long_pin_truncation_and_generate_password() {
use crate::auth::password::generate_password;

let username = "test_long_pin_user";

// Clean up first
let _ = delete_pin(username);
let _ = delete_otp_secret(username);

// Create a long PIN (>30 chars)
let long_pin = "012345678901234567890123456789012345".to_string(); // 36 chars
let pin = Pin::from_unchecked(long_pin.clone());

// Store long PIN and a valid OTP secret
store_pin(username, &pin).expect("Failed to store long PIN");
store_otp_secret(username, "JBSWY3DPEHPK3PXP").expect("Failed to store OTP secret");

// Now generate password using generate_password which should retrieve and truncate
let result = generate_password(username);
assert!(
result.is_ok(),
"generate_password failed: {:?}",
result.err()
);

let password = result.unwrap();
let pwd_str = password.expose();

// The stored PIN should be silently truncated to 30 chars
let expected_pin_prefix = long_pin.chars().take(30).collect::<String>();
assert!(
pwd_str.starts_with(&expected_pin_prefix),
"Password does not start with truncated PIN: {} vs {}",
pwd_str,
expected_pin_prefix
);

// OTP part should be 6 digits at the end
assert!(pwd_str.len() >= 6);
let otp_part = &pwd_str[pwd_str.len() - 6..];
assert!(otp_part.chars().all(|c| c.is_ascii_digit()));

// Clean up
delete_pin(username).expect("Failed to delete PIN");
delete_otp_secret(username).expect("Failed to delete OTP");
}
}
6 changes: 3 additions & 3 deletions akon-core/src/vpn/reconnection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ pub struct ReconnectionPolicy {
}

fn default_max_attempts() -> u32 {
5
3
}
fn default_base_interval() -> u32 {
5
Expand All @@ -51,10 +51,10 @@ fn default_max_interval() -> u32 {
60
}
fn default_consecutive_failures() -> u32 {
3
1
}
fn default_health_check_interval() -> u64 {
60
10
}

impl ReconnectionPolicy {
Expand Down
6 changes: 3 additions & 3 deletions akon-core/tests/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,12 +103,12 @@ mod reconnection_policy_tests {
let policy: ReconnectionPolicy = toml::from_str(toml_str).unwrap();

// Check defaults are applied
assert_eq!(policy.max_attempts, 5); // default
assert_eq!(policy.max_attempts, 3); // default (updated)
assert_eq!(policy.base_interval_secs, 5); // default
assert_eq!(policy.backoff_multiplier, 2); // default
assert_eq!(policy.max_interval_secs, 60); // default
assert_eq!(policy.consecutive_failures_threshold, 3); // default
assert_eq!(policy.health_check_interval_secs, 60); // default
assert_eq!(policy.consecutive_failures_threshold, 1); // default (updated)
assert_eq!(policy.health_check_interval_secs, 10); // default (updated)
assert_eq!(
policy.health_check_endpoint,
"https://vpn.example.com/health"
Expand Down
6 changes: 3 additions & 3 deletions akon-core/tests/fixtures/test_config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ username = "testuser"
[reconnection]
backoff_multiplier = 2
base_interval_secs = 5
consecutive_failures_threshold = 3
consecutive_failures_threshold = 1
health_check_endpoint = "https://vpn.example.com/healthz"
health_check_interval_secs = 60
max_attempts = 5
health_check_interval_secs = 10
max_attempts = 3
max_interval_secs = 60
Loading