diff --git a/Cargo.lock b/Cargo.lock index 815ad96..18e0f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "amnezia-fellow" -version = "0.1.8" +version = "1.0.1" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 68889f8..5a80905 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "amnezia-fellow" -version = "1.0.0" +version = "1.0.1" edition = "2024" description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync" diff --git a/README.md b/README.md index 799e815..0b84ec7 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,13 @@ The database stores all client data needed to restore configs: - assigned IPv4 address - public key - private key +- optional owner-scoped connectivity group - enabled flag - created/updated timestamps -The Kubernetes Secret is derived from the database. Active clients are rendered into one configured Secret key, `peers.conf` by default. +The Kubernetes Secret is derived from the database. Active clients are rendered +into `peers.conf`; exact same-owner/same-group IPv4 pairs are rendered into +`policy.conf`. Clients without a group are isolated from other VPN clients. ## Kubernetes Sync @@ -169,6 +172,7 @@ The JSON API is session-authenticated unless noted: - `POST /api/telegram-link/unlink` - `POST /api/vpn-clients` - `POST /api/vpn-clients/{id}/enabled` +- `POST /api/vpn-clients/{id}/group` changes the group and rotates the client key pair - `DELETE /api/vpn-clients/{id}` - `GET /api/vpn-clients/{id}/config` returns `servers[]` with one raw AWG config and one Amnezia `vpn://` import link per registered endpoint - `POST /api/vpn-clients/sync` diff --git a/src/api/mod.rs b/src/api/mod.rs index e6d35a6..c6fa0a2 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -87,11 +87,13 @@ async fn me_handler(session: Session, db: Database) -> cot::Result, + groups: Vec, } #[derive(Debug, Deserialize, JsonSchema)] struct CreateVpnClientRequest { name: String, + group_name: Option, } #[derive(Debug, Serialize, JsonSchema)] @@ -106,6 +108,11 @@ struct SetEnabledRequest { enabled: bool, } +#[derive(Debug, Deserialize, JsonSchema)] +struct SetGroupRequest { + group_name: Option, +} + #[derive(Debug, Serialize, JsonSchema)] struct DeleteVpnClientResponse { sync: vpn::SecretSyncResult, @@ -189,6 +196,14 @@ async fn vpn_clients_handler( .await .map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?; let owner_map = owner_view_map(&db, &clients).await?; + let mut groups = clients + .iter() + .filter(|client| client.owner_user_id() == user.id) + .filter_map(vpn::VpnClient::group_name_str) + .map(str::to_owned) + .collect::>(); + groups.sort(); + groups.dedup(); let clients = clients .into_iter() .map(|client| client_view_with_owner(client, &owner_map)) @@ -197,6 +212,7 @@ async fn vpn_clients_handler( Json(VpnClientsResponse { role: user.role.code().to_owned(), clients, + groups, }) .into_response() } @@ -467,6 +483,7 @@ async fn create_vpn_client_handler( &db, user.id, &request.name, + request.group_name.as_deref(), &config.vpn_client_cidr, ) .await @@ -493,6 +510,53 @@ async fn create_vpn_client_handler( .into_response() } +async fn set_vpn_client_group_handler( + session: Session, + db: Database, + Path(path): Path, + Json(request): Json, +) -> cot::Result { + let user = match auth::require_user_or_redirect(&session, &db).await { + Ok(user) => user, + Err(_) => { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + } + }; + + let Some(mut client) = vpn::VpnClient::get_visible(&db, &user, path.id) + .await + .map_err(|e| cot::Error::internal(format!("failed to load client: {e}")))? + else { + return Ok(json_error(cot::http::StatusCode::NOT_FOUND, "not found")); + }; + + if let Err(e) = client + .set_group_and_rotate_keys(&db, request.group_name.as_deref()) + .await + { + return Ok(json_error_typed( + cot::http::StatusCode::BAD_REQUEST, + "client_group_update_failed", + "Could not change group", + "The client group was not changed.", + &e.to_string(), + )); + } + let (config, _) = AppConfig::load_with_db(&db).await; + let (sync, notice) = sync_after_client_mutation(&db, &config).await; + let owner_map = owner_view_map(&db, std::slice::from_ref(&client)).await?; + + Json(MutateVpnClientResponse { + client: client_view_with_owner(client, &owner_map), + sync, + notice, + }) + .into_response() +} + async fn set_vpn_client_enabled_handler( session: Session, db: Database, @@ -931,6 +995,11 @@ impl App for ApiApp { api_post(set_vpn_client_enabled_handler), "api_vpn_client_enabled", ), + Route::with_api_handler_and_name( + "/vpn-clients/{id}/group", + api_post(set_vpn_client_group_handler), + "api_vpn_client_group", + ), Route::with_api_handler_and_name( "/vpn-clients/{id}", api_delete(delete_vpn_client_handler), diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 3bcb498..b613435 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -143,6 +143,14 @@ translations! { configs_active_keys: "Active" , "Активные"; configs_total_keys: "Total" , "Всего"; configs_new_key: "New key" , "Новый ключ"; + configs_group: "Group" , "Группа"; + configs_group_isolated: "Isolated" , "Изолирован"; + configs_group_placeholder: "Leave empty for isolation" , "Оставьте пустым для изоляции"; + configs_group_hint: "Clients with the same group name can connect to each other on the same VPN server." , "Клиенты с одинаковой группой могут обращаться друг к другу на одном VPN-сервере."; + configs_change_group: "Change group" , "Сменить группу"; + configs_group_rotation_warning: "Changing the group rotates this client's keys. The old config will stop working; download or import a new one." , "При смене группы ключи клиента будут заменены. Старый конфиг перестанет работать — скачайте или импортируйте новый."; + configs_group_rotation_confirm: "Change the group and invalidate the old client config?" , "Сменить группу и сделать старый конфиг клиента недействительным?"; + configs_group_changed: "Group changed. Update the client config." , "Группа изменена. Обновите конфиг клиента."; configs_empty_title: "Create your first key" , "Создайте первый ключ"; configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь."; configs_enabled_state: "active" , "активен"; diff --git a/src/vpn.rs b/src/vpn.rs index 09db459..8f5929c 100644 --- a/src/vpn.rs +++ b/src/vpn.rs @@ -31,6 +31,7 @@ pub struct VpnClient { address: LimitedString<64>, public_key: LimitedString<128>, private_key: LimitedString<128>, + group_name: Option, enabled: bool, created_at: LimitedString<64>, updated_at: LimitedString<64>, @@ -45,6 +46,7 @@ pub struct VpnClientView { pub name: String, pub address: String, pub public_key: String, + pub group_name: Option, pub enabled: bool, pub created_at: String, pub updated_at: String, @@ -88,6 +90,7 @@ impl VpnClient { db: &Database, owner_user_id: i64, name: &str, + group_name: Option<&str>, cidr: &str, ) -> cot::db::Result { let keypair = generate_keypair().map_err(db_custom_error)?; @@ -103,6 +106,7 @@ impl VpnClient { address: LimitedString::new(address.as_str()).unwrap(), public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(), private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(), + group_name: group_name_for_storage(group_name)?, enabled: true, created_at: LimitedString::new(now.as_str()).unwrap(), updated_at: LimitedString::new(now.as_str()).unwrap(), @@ -118,6 +122,25 @@ impl VpnClient { self.save(db).await } + pub async fn set_group_and_rotate_keys( + &mut self, + db: &Database, + group_name: Option<&str>, + ) -> cot::db::Result<()> { + let group_name = group_name_for_storage(group_name)?; + if self.group_name == group_name { + return Ok(()); + } + + let keypair = generate_keypair().map_err(db_custom_error)?; + self.public_key = LimitedString::new(keypair.public_key).unwrap(); + self.private_key = LimitedString::new(keypair.private_key).unwrap(); + self.group_name = group_name; + let now = now_timestamp(); + self.updated_at = LimitedString::new(now).unwrap(); + self.save(db).await + } + pub async fn delete_by_id(db: &Database, client_id: i64) -> cot::db::Result<()> { cot::db::query!(VpnClient, $id == Auto::Fixed(client_id)) .delete(db) @@ -134,6 +157,7 @@ impl VpnClient { name: self.name.to_string(), address: self.address.to_string(), public_key: self.public_key.to_string(), + group_name: self.group_name.as_ref().map(ToString::to_string), enabled: self.enabled, created_at: self.created_at.to_string(), updated_at: self.updated_at.to_string(), @@ -167,6 +191,10 @@ impl VpnClient { pub fn private_key_str(&self) -> &str { &self.private_key } + + pub fn group_name_str(&self) -> Option<&str> { + self.group_name.as_deref() + } } // --------------------------------------------------------------------------- @@ -211,6 +239,48 @@ pub fn render_peer_secret(clients: &[VpnClient]) -> String { out } +pub fn render_client_policy(clients: &[VpnClient], cidr: &str) -> Result { + parse_ipv4_cidr(cidr)?; + + let mut groups = BTreeMap::<(i64, String), Vec>::new(); + for client in clients.iter().filter(|client| client.enabled()) { + let Some(group_name) = client.group_name_str() else { + continue; + }; + let address = client.address_str().parse::().map_err(|e| { + format!( + "client {} has invalid IPv4 address {:?}: {e}", + client.id_val(), + client.address_str() + ) + })?; + if !ipv4_is_in_cidr(address, cidr)? { + return Err(format!( + "client {} address {} is outside VPN CIDR {cidr}", + client.id_val(), + address + )); + } + groups + .entry((client.owner_user_id(), group_name.to_owned())) + .or_default() + .push(address); + } + + let mut out = + format!("# Generated by amnezia-fellow. Exact allowed awg0-to-awg0 pairs for {cidr}.\n"); + for addresses in groups.values_mut() { + addresses.sort_unstable(); + addresses.dedup(); + for source in addresses.iter() { + for destination in addresses.iter().filter(|address| *address != source) { + out.push_str(&format!("{source}/32 {destination}/32\n")); + } + } + } + Ok(out) +} + fn client_name_for_storage(name: &str) -> cot::db::Result> { let name = if name.is_empty() { "Amnezia client" @@ -221,6 +291,16 @@ fn client_name_for_storage(name: &str) -> cot::db::Result> { .map_err(|e| db_custom_error(format!("client name is too long: {e}"))) } +fn group_name_for_storage(group_name: Option<&str>) -> cot::db::Result> { + let Some(group_name) = group_name.map(str::trim).filter(|name| !name.is_empty()) else { + return Ok(None); + }; + if group_name.len() > 255 { + return Err(db_custom_error("group name is too long".to_owned())); + } + Ok(Some(group_name.to_owned())) +} + fn escaped_peer_comment_value(value: &str) -> String { let mut escaped = String::with_capacity(value.len() + 2); escaped.push('"'); @@ -432,6 +512,7 @@ pub struct SecretSyncResult { pub async fn sync_clients_secret( config: &AppConfig, rendered_peers: String, + rendered_policy: String, ) -> Result { let client = Client::try_default() .await @@ -439,13 +520,18 @@ pub async fn sync_clients_secret( let api: Api = Api::namespaced(client, &config.k8s_namespace); let desired = rendered_peers.into_bytes(); + let desired_policy = rendered_policy.into_bytes(); let name = &config.k8s_clients_secret; let key = &config.k8s_clients_secret_key; + let policy_key = "policy.conf"; match api.get_opt(name).await { Ok(Some(mut secret)) => { let mut data = secret.data.take().unwrap_or_default(); - if data.get(key).map(|value| value.0.as_slice()) == Some(desired.as_slice()) { + if data.get(key).map(|value| value.0.as_slice()) == Some(desired.as_slice()) + && data.get(policy_key).map(|value| value.0.as_slice()) + == Some(desired_policy.as_slice()) + { return Ok(SecretSyncResult { changed: false, message: "client Secret is already up to date".to_owned(), @@ -453,6 +539,10 @@ pub async fn sync_clients_secret( } data.insert(key.clone(), k8s_openapi::ByteString(desired)); + data.insert( + policy_key.to_owned(), + k8s_openapi::ByteString(desired_policy), + ); secret.data = Some(data); mark_client_secret_updated(&mut secret); api.replace(name, &PostParams::default(), &secret) @@ -466,6 +556,10 @@ pub async fn sync_clients_secret( Ok(None) => { let mut data = BTreeMap::new(); data.insert(key.clone(), k8s_openapi::ByteString(desired)); + data.insert( + policy_key.to_owned(), + k8s_openapi::ByteString(desired_policy), + ); let secret = Secret { metadata: ObjectMeta { name: Some(name.clone()), @@ -498,7 +592,8 @@ pub async fn sync_from_database( let clients = VpnClient::list_all(db) .await .map_err(|e| format!("failed to list VPN clients: {e}"))?; - sync_clients_secret(config, render_peer_secret(&clients)).await + let policy = render_client_policy(&clients, &config.vpn_client_cidr)?; + sync_clients_secret(config, render_peer_secret(&clients), policy).await } fn mark_client_secret_updated(secret: &mut Secret) { @@ -920,6 +1015,16 @@ fn parse_ipv4_cidr(cidr: &str) -> Result<(Ipv4Addr, u32), String> { Ok((u32_to_ipv4(ipv4_to_u32(ip) & mask), prefix)) } +fn ipv4_is_in_cidr(address: Ipv4Addr, cidr: &str) -> Result { + let (network, prefix) = parse_ipv4_cidr(cidr)?; + let mask = if prefix == 0 { + 0 + } else { + u32::MAX << (32 - prefix) + }; + Ok(ipv4_to_u32(address) & mask == ipv4_to_u32(network)) +} + fn ipv4_to_u32(ip: Ipv4Addr) -> u32 { u32::from_be_bytes(ip.octets()) } @@ -1073,14 +1178,48 @@ pub mod db_migrations { "m_0008_user_telegram_link_code", ), ]; + const OPERATIONS: &'static [Operation] = &[Operation::custom(normalize_bigint_ids).build()]; + } + + #[cot::db::migrations::migration_op] + async fn add_vpn_client_group_name( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "ALTER TABLE amnezia_fellow__vpn_client \ + ADD COLUMN group_name VARCHAR(255)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX idx_amnezia_fellow_vpn_client_owner_group \ + ON amnezia_fellow__vpn_client (owner_user_id, group_name)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0010VpnClientGroupName; + + impl migrations::Migration for M0010VpnClientGroupName { + const APP_NAME: &'static str = "amnezia_fellow"; + const MIGRATION_NAME: &'static str = "m_0010_vpn_client_group_name"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = + &[migrations::MigrationDependency::migration( + "amnezia_fellow", + "m_0009_normalize_bigint_ids", + )]; const OPERATIONS: &'static [Operation] = - &[Operation::custom(normalize_bigint_ids).build()]; + &[Operation::custom(add_vpn_client_group_name).build()]; } pub const MIGRATIONS: &[&SyncDynMigration] = &[ &M0005CreateVpnClient, &M0006VpnClientIndexes, &M0009NormalizeBigintIds, + &M0010VpnClientGroupName, ]; } @@ -1088,6 +1227,21 @@ pub mod db_migrations { mod tests { use super::*; + fn test_client(id: i64, owner: i64, address: &str, group_name: Option<&str>) -> VpnClient { + VpnClient { + id: Auto::Fixed(id), + owner_user_id: owner, + name: LimitedString::new(format!("client-{id}")).unwrap(), + address: LimitedString::new(address).unwrap(), + public_key: LimitedString::new(format!("public-{id}")).unwrap(), + private_key: LimitedString::new(format!("private-{id}")).unwrap(), + group_name: group_name.map(str::to_owned), + enabled: true, + created_at: LimitedString::new("0").unwrap(), + updated_at: LimitedString::new("0").unwrap(), + } + } + #[test] fn cidr_parser_normalizes_network() { let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap(); @@ -1102,6 +1256,31 @@ mod tests { assert_eq!(keypair.public_key.len(), 44); } + #[test] + fn client_policy_allows_only_exact_pairs_with_same_owner_and_group() { + let clients = vec![ + test_client(1, 10, "10.8.0.2", Some("home")), + test_client(2, 10, "10.8.0.3", Some("home")), + test_client(3, 10, "10.8.0.4", Some("other")), + test_client(4, 11, "10.8.0.5", Some("home")), + test_client(5, 10, "10.8.0.6", None), + ]; + + let policy = render_client_policy(&clients, "10.8.0.0/16").unwrap(); + assert!(policy.contains("10.8.0.2/32 10.8.0.3/32\n")); + assert!(policy.contains("10.8.0.3/32 10.8.0.2/32\n")); + assert_eq!( + policy.lines().filter(|line| !line.starts_with('#')).count(), + 2 + ); + } + + #[test] + fn client_policy_rejects_address_outside_vpn_cidr() { + let clients = vec![test_client(1, 10, "192.0.2.2", Some("home"))]; + assert!(render_client_policy(&clients, "10.8.0.0/16").is_err()); + } + #[test] fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() { assert_eq!( @@ -1145,6 +1324,7 @@ mod tests { address: LimitedString::new("10.8.0.2").unwrap(), public_key: LimitedString::new("client-public-key").unwrap(), private_key: LimitedString::new("client-private-key").unwrap(), + group_name: None, enabled: true, created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), @@ -1210,6 +1390,7 @@ mod tests { address: LimitedString::new("10.8.0.2").unwrap(), public_key: LimitedString::new("client-public-key").unwrap(), private_key: LimitedString::new("client-private-key").unwrap(), + group_name: None, enabled: true, created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), diff --git a/templates/client_portal.html b/templates/client_portal.html index 59790ee..6878058 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -9,13 +9,13 @@ * { box-sizing: border-box; } [x-cloak] { display: none !important; } body { margin: 0; min-height: 100vh; background: #edf3ef; color: #18232d; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } - button, input { font: inherit; } + button, input, select { font: inherit; } button { min-height: 40px; border: 1px solid #18342f; border-radius: 8px; padding: .55rem .85rem; background: #18342f; color: #fff; cursor: pointer; font-weight: 750; } button.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; } button.ghost { background: transparent; color: #42515d; border-color: transparent; } button.danger { background: #a8312d; border-color: #a8312d; color: #fff; } button:disabled { opacity: .48; cursor: default; } - input { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; } + input, select { width: 100%; min-height: 42px; border: 1px solid #c7d4ce; border-radius: 8px; padding: .55rem .7rem; color: #18232d; background: #fff; } code { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 6px; background: #f1f4f2; color: #26313a; padding: .18rem .4rem; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .82rem; } .portal { min-height: 100vh; display: flex; flex-direction: column; } .topbar { position: sticky; top: 0; z-index: 10; background: rgba(255,255,255,.94); border-bottom: 1px solid #d6dfda; backdrop-filter: blur(12px); } @@ -62,7 +62,8 @@ .server-status-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #18232d; font-size: .86rem; font-weight: 850; } .server-status-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #69777f; font-size: .74rem; font-weight: 750; } .server-status-muted { color: #69777f; font-size: .82rem; } - .create { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .65rem; padding: 0 1rem 1rem; align-items: end; } + .create { display: flex; justify-content: flex-end; padding: 0 1rem 1rem; } + .create-plus { width: 44px; min-width: 44px; height: 44px; padding: 0; font-size: 1.55rem; line-height: 1; } .field { display: grid; gap: .32rem; min-width: 0; } .field label { color: #34424b; font-size: .83rem; font-weight: 800; } .notice { border: 1px solid #d6dfda; border-left-width: 4px; border-radius: 8px; background: #fff; padding: .75rem .85rem; display: grid; gap: .25rem; box-shadow: 0 8px 24px rgba(24, 52, 47, .06); } @@ -87,7 +88,7 @@ .meta-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; } .meta-item { min-width: 0; display: grid; gap: .24rem; } .meta-label { color: #69777f; font-size: .77rem; font-weight: 800; } - .key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: .5rem; align-items: center; } + .key-actions { display: grid; grid-template-columns: minmax(0, 1fr) auto auto auto; gap: .5rem; align-items: center; } .key-actions .server-button { justify-self: stretch; } .key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; } .backdrop { position: fixed; inset: 0; z-index: 30; display: grid; place-items: center; padding: 1rem; background: rgba(16, 28, 36, .42); } @@ -115,12 +116,15 @@ .modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; } .telegram-sheet { width: min(480px, 100%); } .telegram-copy { color: #53616c; line-height: 1.45; margin: 0; } + .form-stack { display: grid; gap: .8rem; } + .field-hint { margin: 0; color: #69777f; font-size: .82rem; line-height: 1.4; } + .warning-box { border: 1px solid #e5c76c; border-radius: 8px; background: #fff8df; color: #5f4911; padding: .7rem; font-size: .86rem; line-height: 1.4; } .guide-list { margin: 0; padding-left: 1.2rem; color: #34424b; display: grid; gap: .45rem; line-height: 1.4; } .guide-list a { color: #18342f; font-weight: 850; } .guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; } .secret-box { display: grid; gap: .35rem; border: 1px solid #d6dfda; border-radius: 8px; background: #f7faf8; padding: .7rem; } .secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; } - @media (max-width: 720px) { + @media (max-width: 900px) { .topbar-inner { align-items: flex-start; flex-direction: column; } .top-actions { width: 100%; justify-content: space-between; } .user-pill { max-width: 100%; } @@ -219,11 +223,7 @@
-
- - -
- +
@@ -265,10 +265,15 @@ {{ t.configs_public_key }} +
+ {{ t.configs_group }} + +
+
@@ -280,6 +285,45 @@ + + + +
+ +
+ +