Added client isolation settings. Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s

This commit is contained in:
Ultradesu
2026-07-30 23:03:18 +01:00
parent e8873f61a5
commit 9614ff8a60
8 changed files with 545 additions and 44 deletions
+69
View File
@@ -87,11 +87,13 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
struct VpnClientsResponse {
role: String,
clients: Vec<vpn::VpnClientView>,
groups: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct CreateVpnClientRequest {
name: String,
group_name: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -106,6 +108,11 @@ struct SetEnabledRequest {
enabled: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SetGroupRequest {
group_name: Option<String>,
}
#[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::<Vec<_>>();
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<ClientPath>,
Json(request): Json<SetGroupRequest>,
) -> cot::Result<cot::response::Response> {
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),
+8
View File
@@ -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" , "активен";
+184 -3
View File
@@ -31,6 +31,7 @@ pub struct VpnClient {
address: LimitedString<64>,
public_key: LimitedString<128>,
private_key: LimitedString<128>,
group_name: Option<String>,
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<String>,
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<Self> {
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<String, String> {
parse_ipv4_cidr(cidr)?;
let mut groups = BTreeMap::<(i64, String), Vec<Ipv4Addr>>::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::<Ipv4Addr>().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<LimitedString<255>> {
let name = if name.is_empty() {
"Amnezia client"
@@ -221,6 +291,16 @@ fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
.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<Option<String>> {
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<SecretSyncResult, String> {
let client = Client::try_default()
.await
@@ -439,13 +520,18 @@ pub async fn sync_clients_secret(
let api: Api<Secret> = 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<bool, String> {
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(),