Added client isolation settings. Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user