3 Commits
Author SHA1 Message Date
Ultradesu e890d1c147 Fixed group scope
Build and Publish / Build and Publish Docker Image (push) Canceled after 2m2s
2026-07-30 23:27:34 +01:00
Ultradesu 9614ff8a60 Added client isolation settings. Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 6m6s
2026-07-30 23:03:18 +01:00
ab e8873f61a5 Fixed mobile UI
Build and Publish / Build and Publish Docker Image (push) Successful in 3m7s
2026-07-06 16:57:26 +03:00
8 changed files with 555 additions and 37 deletions
Generated
+1 -1
View File
@@ -61,7 +61,7 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]] [[package]]
name = "amnezia-fellow" name = "amnezia-fellow"
version = "0.1.7" version = "1.0.1"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"base64 0.22.1", "base64 0.22.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "amnezia-fellow" name = "amnezia-fellow"
version = "0.1.8" version = "1.0.1"
edition = "2024" edition = "2024"
description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync" description = "Amnezia VPN client manager with SSO, SQLite/PostgreSQL, and Kubernetes Secret sync"
+5 -1
View File
@@ -60,10 +60,13 @@ The database stores all client data needed to restore configs:
- assigned IPv4 address - assigned IPv4 address
- public key - public key
- private key - private key
- optional owner-scoped connectivity group
- enabled flag - enabled flag
- created/updated timestamps - 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-group IPv4 pairs are rendered into
`policy.conf`. Clients without a group are isolated from other VPN clients.
## Kubernetes Sync ## Kubernetes Sync
@@ -169,6 +172,7 @@ The JSON API is session-authenticated unless noted:
- `POST /api/telegram-link/unlink` - `POST /api/telegram-link/unlink`
- `POST /api/vpn-clients` - `POST /api/vpn-clients`
- `POST /api/vpn-clients/{id}/enabled` - `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}` - `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 - `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` - `POST /api/vpn-clients/sync`
+69
View File
@@ -87,11 +87,13 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
struct VpnClientsResponse { struct VpnClientsResponse {
role: String, role: String,
clients: Vec<vpn::VpnClientView>, clients: Vec<vpn::VpnClientView>,
groups: Vec<String>,
} }
#[derive(Debug, Deserialize, JsonSchema)] #[derive(Debug, Deserialize, JsonSchema)]
struct CreateVpnClientRequest { struct CreateVpnClientRequest {
name: String, name: String,
group_name: Option<String>,
} }
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
@@ -106,6 +108,11 @@ struct SetEnabledRequest {
enabled: bool, enabled: bool,
} }
#[derive(Debug, Deserialize, JsonSchema)]
struct SetGroupRequest {
group_name: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
struct DeleteVpnClientResponse { struct DeleteVpnClientResponse {
sync: vpn::SecretSyncResult, sync: vpn::SecretSyncResult,
@@ -189,6 +196,14 @@ async fn vpn_clients_handler(
.await .await
.map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?; .map_err(|e| cot::Error::internal(format!("failed to list clients: {e}")))?;
let owner_map = owner_view_map(&db, &clients).await?; 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 let clients = clients
.into_iter() .into_iter()
.map(|client| client_view_with_owner(client, &owner_map)) .map(|client| client_view_with_owner(client, &owner_map))
@@ -197,6 +212,7 @@ async fn vpn_clients_handler(
Json(VpnClientsResponse { Json(VpnClientsResponse {
role: user.role.code().to_owned(), role: user.role.code().to_owned(),
clients, clients,
groups,
}) })
.into_response() .into_response()
} }
@@ -467,6 +483,7 @@ async fn create_vpn_client_handler(
&db, &db,
user.id, user.id,
&request.name, &request.name,
request.group_name.as_deref(),
&config.vpn_client_cidr, &config.vpn_client_cidr,
) )
.await .await
@@ -493,6 +510,53 @@ async fn create_vpn_client_handler(
.into_response() .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( async fn set_vpn_client_enabled_handler(
session: Session, session: Session,
db: Database, db: Database,
@@ -931,6 +995,11 @@ impl App for ApiApp {
api_post(set_vpn_client_enabled_handler), api_post(set_vpn_client_enabled_handler),
"api_vpn_client_enabled", "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( Route::with_api_handler_and_name(
"/vpn-clients/{id}", "/vpn-clients/{id}",
api_delete(delete_vpn_client_handler), api_delete(delete_vpn_client_handler),
+8
View File
@@ -143,6 +143,14 @@ translations! {
configs_active_keys: "Active" , "Активные"; configs_active_keys: "Active" , "Активные";
configs_total_keys: "Total" , "Всего"; configs_total_keys: "Total" , "Всего";
configs_new_key: "New key" , "Новый ключ"; 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_title: "Create your first key" , "Создайте первый ключ";
configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь."; configs_empty_hint: "It will appear here after creation." , "После создания он появится здесь.";
configs_enabled_state: "active" , "активен"; configs_enabled_state: "active" , "активен";
+186 -3
View File
@@ -31,6 +31,7 @@ pub struct VpnClient {
address: LimitedString<64>, address: LimitedString<64>,
public_key: LimitedString<128>, public_key: LimitedString<128>,
private_key: LimitedString<128>, private_key: LimitedString<128>,
group_name: Option<String>,
enabled: bool, enabled: bool,
created_at: LimitedString<64>, created_at: LimitedString<64>,
updated_at: LimitedString<64>, updated_at: LimitedString<64>,
@@ -45,6 +46,7 @@ pub struct VpnClientView {
pub name: String, pub name: String,
pub address: String, pub address: String,
pub public_key: String, pub public_key: String,
pub group_name: Option<String>,
pub enabled: bool, pub enabled: bool,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
@@ -88,6 +90,7 @@ impl VpnClient {
db: &Database, db: &Database,
owner_user_id: i64, owner_user_id: i64,
name: &str, name: &str,
group_name: Option<&str>,
cidr: &str, cidr: &str,
) -> cot::db::Result<Self> { ) -> cot::db::Result<Self> {
let keypair = generate_keypair().map_err(db_custom_error)?; let keypair = generate_keypair().map_err(db_custom_error)?;
@@ -103,6 +106,7 @@ impl VpnClient {
address: LimitedString::new(address.as_str()).unwrap(), address: LimitedString::new(address.as_str()).unwrap(),
public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(), public_key: LimitedString::new(keypair.public_key.as_str()).unwrap(),
private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(), private_key: LimitedString::new(keypair.private_key.as_str()).unwrap(),
group_name: group_name_for_storage(group_name)?,
enabled: true, enabled: true,
created_at: LimitedString::new(now.as_str()).unwrap(), created_at: LimitedString::new(now.as_str()).unwrap(),
updated_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 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<()> { pub async fn delete_by_id(db: &Database, client_id: i64) -> cot::db::Result<()> {
cot::db::query!(VpnClient, $id == Auto::Fixed(client_id)) cot::db::query!(VpnClient, $id == Auto::Fixed(client_id))
.delete(db) .delete(db)
@@ -134,6 +157,7 @@ impl VpnClient {
name: self.name.to_string(), name: self.name.to_string(),
address: self.address.to_string(), address: self.address.to_string(),
public_key: self.public_key.to_string(), public_key: self.public_key.to_string(),
group_name: self.group_name.as_ref().map(ToString::to_string),
enabled: self.enabled, enabled: self.enabled,
created_at: self.created_at.to_string(), created_at: self.created_at.to_string(),
updated_at: self.updated_at.to_string(), updated_at: self.updated_at.to_string(),
@@ -167,6 +191,10 @@ impl VpnClient {
pub fn private_key_str(&self) -> &str { pub fn private_key_str(&self) -> &str {
&self.private_key &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 out
} }
pub fn render_client_policy(clients: &[VpnClient], cidr: &str) -> Result<String, String> {
parse_ipv4_cidr(cidr)?;
let mut groups = BTreeMap::<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(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>> { fn client_name_for_storage(name: &str) -> cot::db::Result<LimitedString<255>> {
let name = if name.is_empty() { let name = if name.is_empty() {
"Amnezia client" "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}"))) .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 { fn escaped_peer_comment_value(value: &str) -> String {
let mut escaped = String::with_capacity(value.len() + 2); let mut escaped = String::with_capacity(value.len() + 2);
escaped.push('"'); escaped.push('"');
@@ -432,6 +512,7 @@ pub struct SecretSyncResult {
pub async fn sync_clients_secret( pub async fn sync_clients_secret(
config: &AppConfig, config: &AppConfig,
rendered_peers: String, rendered_peers: String,
rendered_policy: String,
) -> Result<SecretSyncResult, String> { ) -> Result<SecretSyncResult, String> {
let client = Client::try_default() let client = Client::try_default()
.await .await
@@ -439,13 +520,18 @@ pub async fn sync_clients_secret(
let api: Api<Secret> = Api::namespaced(client, &config.k8s_namespace); let api: Api<Secret> = Api::namespaced(client, &config.k8s_namespace);
let desired = rendered_peers.into_bytes(); let desired = rendered_peers.into_bytes();
let desired_policy = rendered_policy.into_bytes();
let name = &config.k8s_clients_secret; let name = &config.k8s_clients_secret;
let key = &config.k8s_clients_secret_key; let key = &config.k8s_clients_secret_key;
let policy_key = "policy.conf";
match api.get_opt(name).await { match api.get_opt(name).await {
Ok(Some(mut secret)) => { Ok(Some(mut secret)) => {
let mut data = secret.data.take().unwrap_or_default(); 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 { return Ok(SecretSyncResult {
changed: false, changed: false,
message: "client Secret is already up to date".to_owned(), 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(key.clone(), k8s_openapi::ByteString(desired));
data.insert(
policy_key.to_owned(),
k8s_openapi::ByteString(desired_policy),
);
secret.data = Some(data); secret.data = Some(data);
mark_client_secret_updated(&mut secret); mark_client_secret_updated(&mut secret);
api.replace(name, &PostParams::default(), &secret) api.replace(name, &PostParams::default(), &secret)
@@ -466,6 +556,10 @@ pub async fn sync_clients_secret(
Ok(None) => { Ok(None) => {
let mut data = BTreeMap::new(); let mut data = BTreeMap::new();
data.insert(key.clone(), k8s_openapi::ByteString(desired)); data.insert(key.clone(), k8s_openapi::ByteString(desired));
data.insert(
policy_key.to_owned(),
k8s_openapi::ByteString(desired_policy),
);
let secret = Secret { let secret = Secret {
metadata: ObjectMeta { metadata: ObjectMeta {
name: Some(name.clone()), name: Some(name.clone()),
@@ -498,7 +592,8 @@ pub async fn sync_from_database(
let clients = VpnClient::list_all(db) let clients = VpnClient::list_all(db)
.await .await
.map_err(|e| format!("failed to list VPN clients: {e}"))?; .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) { 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)) 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 { fn ipv4_to_u32(ip: Ipv4Addr) -> u32 {
u32::from_be_bytes(ip.octets()) u32::from_be_bytes(ip.octets())
} }
@@ -1073,14 +1178,48 @@ pub mod db_migrations {
"m_0008_user_telegram_link_code", "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] = const OPERATIONS: &'static [Operation] =
&[Operation::custom(normalize_bigint_ids).build()]; &[Operation::custom(add_vpn_client_group_name).build()];
} }
pub const MIGRATIONS: &[&SyncDynMigration] = &[ pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0005CreateVpnClient, &M0005CreateVpnClient,
&M0006VpnClientIndexes, &M0006VpnClientIndexes,
&M0009NormalizeBigintIds, &M0009NormalizeBigintIds,
&M0010VpnClientGroupName,
]; ];
} }
@@ -1088,6 +1227,21 @@ pub mod db_migrations {
mod tests { mod tests {
use super::*; 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] #[test]
fn cidr_parser_normalizes_network() { fn cidr_parser_normalizes_network() {
let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap(); let (network, prefix) = parse_ipv4_cidr("10.8.42.7/16").unwrap();
@@ -1102,6 +1256,33 @@ mod tests {
assert_eq!(keypair.public_key.len(), 44); assert_eq!(keypair.public_key.len(), 44);
} }
#[test]
fn client_policy_allows_only_exact_pairs_with_same_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!(policy.contains("10.8.0.2/32 10.8.0.5/32\n"));
assert!(policy.contains("10.8.0.5/32 10.8.0.2/32\n"));
assert_eq!(
policy.lines().filter(|line| !line.starts_with('#')).count(),
6
);
}
#[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] #[test]
fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() { fn endpoint_parser_accepts_ipv4_and_bracketed_ipv6() {
assert_eq!( assert_eq!(
@@ -1145,6 +1326,7 @@ mod tests {
address: LimitedString::new("10.8.0.2").unwrap(), address: LimitedString::new("10.8.0.2").unwrap(),
public_key: LimitedString::new("client-public-key").unwrap(), public_key: LimitedString::new("client-public-key").unwrap(),
private_key: LimitedString::new("client-private-key").unwrap(), private_key: LimitedString::new("client-private-key").unwrap(),
group_name: None,
enabled: true, enabled: true,
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
@@ -1210,6 +1392,7 @@ mod tests {
address: LimitedString::new("10.8.0.2").unwrap(), address: LimitedString::new("10.8.0.2").unwrap(),
public_key: LimitedString::new("client-public-key").unwrap(), public_key: LimitedString::new("client-public-key").unwrap(),
private_key: LimitedString::new("client-private-key").unwrap(), private_key: LimitedString::new("client-private-key").unwrap(),
group_name: None,
enabled: true, enabled: true,
created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), created_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(), updated_at: LimitedString::new("2026-06-16T00:00:00Z").unwrap(),
+113 -13
View File
@@ -9,13 +9,13 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
[x-cloak] { display: none !important; } [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; } 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 { 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.secondary { background: #fff; color: #18342f; border-color: #c7d4ce; }
button.ghost { background: transparent; color: #42515d; border-color: transparent; } button.ghost { background: transparent; color: #42515d; border-color: transparent; }
button.danger { background: #a8312d; border-color: #a8312d; color: #fff; } button.danger { background: #a8312d; border-color: #a8312d; color: #fff; }
button:disabled { opacity: .48; cursor: default; } 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; } 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; } .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); } .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-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-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; } .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 { display: grid; gap: .32rem; min-width: 0; }
.field label { color: #34424b; font-size: .83rem; font-weight: 800; } .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); } .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-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.meta-item { min-width: 0; display: grid; gap: .24rem; } .meta-item { min-width: 0; display: grid; gap: .24rem; }
.meta-label { color: #69777f; font-size: .77rem; font-weight: 800; } .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-actions .server-button { justify-self: stretch; }
.key-updated { color: #69777f; font-size: .82rem; overflow-wrap: anywhere; } .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); } .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; } .modal-actions { display: flex; justify-content: flex-end; gap: .5rem; flex-wrap: wrap; }
.telegram-sheet { width: min(480px, 100%); } .telegram-sheet { width: min(480px, 100%); }
.telegram-copy { color: #53616c; line-height: 1.45; margin: 0; } .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 { 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 { color: #18342f; font-weight: 850; }
.guide-list a.disabled { color: #69777f; pointer-events: none; text-decoration: none; } .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 { 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; } .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; } .topbar-inner { align-items: flex-start; flex-direction: column; }
.top-actions { width: 100%; justify-content: space-between; } .top-actions { width: 100%; justify-content: space-between; }
.user-pill { max-width: 100%; } .user-pill { max-width: 100%; }
@@ -219,11 +223,7 @@
</template> </template>
</div> </div>
<div class="create"> <div class="create">
<div class="field"> <button type="button" class="create-plus" @click="openCreateClient()" :disabled="busy" title="{{ t.configs_new_key }}" aria-label="{{ t.configs_new_key }}">+</button>
<label for="client-name">{{ t.configs_new_key }}</label>
<input id="client-name" x-model="newName" @keydown.enter.prevent="createClient()" placeholder="{{ t.configs_name_placeholder }}">
</div>
<button type="button" @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
</div> </div>
</section> </section>
@@ -265,10 +265,15 @@
<span class="meta-label">{{ t.configs_public_key }}</span> <span class="meta-label">{{ t.configs_public_key }}</span>
<code x-text="shortKey(client.public_key)"></code> <code x-text="shortKey(client.public_key)"></code>
</div> </div>
<div class="meta-item">
<span class="meta-label">{{ t.configs_group }}</span>
<code x-text="client.group_name || '{{ t.configs_group_isolated }}'"></code>
</div>
</div> </div>
<div class="key-actions"> <div class="key-actions">
<button type="button" class="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button> <button type="button" class="server-button" @click="openServers(client)" :disabled="busy || !client.enabled">{{ t.configs_choose_server }}</button>
<button type="button" class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
<button type="button" class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button> <button type="button" class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button> <button type="button" class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div> </div>
@@ -280,6 +285,45 @@
</template> </template>
</main> </main>
<template x-if="clientModal">
<div class="backdrop" x-cloak @click.self="closeClientModal()">
<section class="sheet">
<div class="sheet-head">
<div class="sheet-title">
<h2 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h2>
<span x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
</div>
<button type="button" class="ghost" @click="closeClientModal()">{{ t.admin_close }}</button>
</div>
<div class="form-stack">
<template x-if="clientModal.mode === 'create'">
<div class="field">
<label for="client-modal-name">{{ t.configs_name }}</label>
<input id="client-modal-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
</div>
</template>
<div class="field">
<label for="client-modal-group">{{ t.configs_group }}</label>
<input id="client-modal-group" x-model="clientModal.groupName" list="vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
<datalist id="vpn-client-groups">
<template x-for="group in groups" :key="group">
<option :value="group"></option>
</template>
</datalist>
<p class="field-hint">{{ t.configs_group_hint }}</p>
</div>
<template x-if="clientModal.mode === 'group'">
<div class="warning-box">{{ t.configs_group_rotation_warning }}</div>
</template>
</div>
<div class="modal-actions">
<button type="button" class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
<button type="button" @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
</div>
</section>
</div>
</template>
<template x-if="serverModal"> <template x-if="serverModal">
<div class="backdrop" x-cloak @click.self="closeServers()"> <div class="backdrop" x-cloak @click.self="closeServers()">
<section class="sheet"> <section class="sheet">
@@ -433,7 +477,8 @@
function clientPortal() { function clientPortal() {
return { return {
clients: [], clients: [],
newName: '', groups: [],
clientModal: null,
busy: false, busy: false,
status: '', status: '',
error: '', error: '',
@@ -493,6 +538,7 @@ function clientPortal() {
try { try {
const data = await this.request('/api/vpn-clients'); const data = await this.request('/api/vpn-clients');
this.clients = data.clients || []; this.clients = data.clients || [];
this.groups = data.groups || [];
} catch (e) { } catch (e) {
this.showError(e); this.showError(e);
} finally { } finally {
@@ -680,17 +726,42 @@ function clientPortal() {
if (this.telegram.linked) return '{{ t.telegram_connected_message }}'; if (this.telegram.linked) return '{{ t.telegram_connected_message }}';
return '{{ t.telegram_not_connected_message }}'; return '{{ t.telegram_not_connected_message }}';
}, },
openCreateClient() {
this.clientModal = { mode: 'create', name: '', groupName: '', client: null };
},
openGroupEditor(client) {
this.clientModal = {
mode: 'group',
name: client.name || '',
groupName: client.group_name || '',
client,
};
},
closeClientModal() {
if (!this.busy) this.clientModal = null;
},
rememberGroup(groupName) {
const group = String(groupName || '').trim();
if (group && !this.groups.includes(group)) {
this.groups.push(group);
this.groups.sort((left, right) => left.localeCompare(right));
}
},
async createClient() { async createClient() {
if (!this.clientModal || this.clientModal.mode !== 'create') return;
this.clearNotice(); this.clearNotice();
this.busy = true; this.busy = true;
try { try {
const name = this.clientModal.name;
const groupName = this.clientModal.groupName.trim() || null;
const data = await this.request('/api/vpn-clients', { const data = await this.request('/api/vpn-clients', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name: this.newName }), body: JSON.stringify({ name, group_name: groupName }),
}); });
this.clients.push(data.client); this.clients.push(data.client);
delete this.serverConfigs[data.client.id]; delete this.serverConfigs[data.client.id];
this.newName = ''; this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.notice_create_success }}'); this.applyResponseNotice(data, '{{ t.notice_create_success }}');
} catch (e) { } catch (e) {
this.showError(e); this.showError(e);
@@ -698,6 +769,35 @@ function clientPortal() {
this.busy = false; this.busy = false;
} }
}, },
async changeClientGroup() {
if (!this.clientModal || this.clientModal.mode !== 'group') return;
const client = this.clientModal.client;
const groupName = this.clientModal.groupName.trim() || null;
if ((client.group_name || null) === groupName) {
this.clientModal = null;
return;
}
if (!confirm('{{ t.configs_group_rotation_confirm }}')) return;
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/group`, {
method: 'POST',
body: JSON.stringify({ group_name: groupName }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.configs_group_changed }}');
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) { async setEnabled(client, enabled) {
this.clearNotice(); this.clearNotice();
this.busy = true; this.busy = true;
+172 -18
View File
@@ -41,7 +41,22 @@
button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; } button.secondary { background: #fff; color: #17202a; border-color: #cbd3db; }
button.danger { background: #9d2323; border-color: #9d2323; } button.danger { background: #9d2323; border-color: #9d2323; }
button:disabled { opacity: .55; cursor: default; } button:disabled { opacity: .55; cursor: default; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; } .config-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #dde2e6; border-radius: 6px; overflow: hidden; }
.mobile-client-list { display: none; }
.mobile-owner-group { display: grid; gap: .55rem; }
.mobile-owner-head { display: flex; align-items: center; justify-content: space-between; gap: .5rem; color: #34414f; font-size: .86rem; font-weight: 750; }
.mobile-client-card { min-width: 0; border: 1px solid #dde2e6; border-radius: 8px; background: #fff; padding: .75rem; display: grid; gap: .65rem; box-shadow: 0 4px 16px rgba(23, 32, 42, .05); }
.mobile-client-head { min-width: 0; display: flex; align-items: flex-start; justify-content: space-between; gap: .55rem; }
.mobile-client-name { min-width: 0; font-weight: 800; overflow-wrap: anywhere; }
.mobile-client-state { flex: 0 0 auto; border-radius: 999px; padding: .15rem .45rem; background: #e8ecef; color: #53606d; font-size: .72rem; font-weight: 750; }
.mobile-client-state.enabled { background: #dcefe4; color: #175c31; }
.mobile-client-meta { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .55rem; }
.mobile-meta-item { min-width: 0; display: grid; gap: .18rem; }
.mobile-meta-item.full { grid-column: 1 / -1; }
.mobile-meta-label { color: #6a7682; font-size: .72rem; font-weight: 700; }
.mobile-meta-value { min-width: 0; overflow-wrap: anywhere; }
.mobile-client-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .4rem; }
.mobile-client-actions button { width: 100%; min-width: 0; padding-inline: .4rem; white-space: normal; }
th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; } th, td { text-align: left; padding: .65rem .75rem; border-bottom: 1px solid #edf0f2; font-size: .92rem; vertical-align: middle; }
th { background: #eef1f4; font-weight: 650; color: #34414f; } th { background: #eef1f4; font-weight: 650; color: #34414f; }
tr:last-child td { border-bottom: 0; } tr:last-child td { border-bottom: 0; }
@@ -120,17 +135,18 @@
.guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; } .guide-list a.disabled { color: #6a7682; pointer-events: none; text-decoration: none; }
.secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; } .secret-box { display: grid; gap: .35rem; border: 1px solid #dde2e6; border-radius: 6px; background: #f8fafb; padding: .7rem; }
.secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; } .secret-box code { display: block; padding: .55rem .65rem; font-size: .92rem; white-space: normal; overflow-wrap: anywhere; }
@media (max-width: 760px) { @media (max-width: 980px) {
.shell { grid-template-columns: 1fr; } .shell { grid-template-columns: 1fr; }
.sidebar { display: flex; align-items: center; gap: .75rem; overflow-x: auto; } .sidebar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; overflow-x: visible; }
.sidebar h1 { margin: 0; white-space: nowrap; } .sidebar h1 { margin: 0; white-space: nowrap; }
.sidebar a { margin: 0; white-space: nowrap; } .sidebar a { margin: 0; white-space: nowrap; }
.toolbar { align-items: stretch; flex-direction: column; } .toolbar { align-items: stretch; flex-direction: column; }
.actions { align-items: stretch; } .actions { align-items: stretch; }
.actions input, .actions button { width: 100%; } .actions input, .actions button { width: 100%; }
.main { padding: 1rem; } .main { padding: 1rem; }
table { display: block; overflow-x: auto; } .desktop-client-table { display: none; }
.row-actions { align-items: stretch; } .mobile-client-list { display: grid; gap: .9rem; min-width: 0; }
.mobile-client-card code { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.servers-modal { width: 100%; max-height: calc(100vh - 2rem); } .servers-modal { width: 100%; max-height: calc(100vh - 2rem); }
.server-card-actions { grid-template-columns: 1fr; } .server-card-actions { grid-template-columns: 1fr; }
.server-card-actions button { flex: 1 1 100%; } .server-card-actions button { flex: 1 1 100%; }
@@ -169,8 +185,7 @@
<div class="toolbar"> <div class="toolbar">
<h2>{{ t.configs_heading }}</h2> <h2>{{ t.configs_heading }}</h2>
<div class="actions"> <div class="actions">
<input x-model="newName" placeholder="{{ t.configs_name_placeholder }}"> <button @click="openCreateClient()" :disabled="busy">+ {{ t.configs_create }}</button>
<button @click="createClient()" :disabled="busy">{{ t.configs_create }}</button>
{% if is_admin %} {% if is_admin %}
<button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button> <button class="secondary" @click="sync()" :disabled="busy">{{ t.configs_sync }}</button>
{% endif %} {% endif %}
@@ -232,7 +247,7 @@
</template> </template>
<template x-if="clients.length > 0"> <template x-if="clients.length > 0">
<table> <table class="config-table desktop-client-table">
<thead> <thead>
<tr> <tr>
<th>{{ t.configs_name }}</th> <th>{{ t.configs_name }}</th>
@@ -241,6 +256,7 @@
{% endif %} {% endif %}
<th>{{ t.configs_address }}</th> <th>{{ t.configs_address }}</th>
<th>{{ t.configs_public_key }}</th> <th>{{ t.configs_public_key }}</th>
<th>{{ t.configs_group }}</th>
<th>{{ t.configs_enabled }}</th> <th>{{ t.configs_enabled }}</th>
<th>{{ t.users_actions }}</th> <th>{{ t.users_actions }}</th>
</tr> </tr>
@@ -249,7 +265,7 @@
<tbody> <tbody>
{% if is_admin %} {% if is_admin %}
<tr class="owner-row"> <tr class="owner-row">
<td colspan="6"> <td colspan="7">
<span x-text="group.label"></span> <span x-text="group.label"></span>
<span class="owner-meta" x-text="`(${group.clients.length})`"></span> <span class="owner-meta" x-text="`(${group.clients.length})`"></span>
</td> </td>
@@ -257,16 +273,18 @@
{% endif %} {% endif %}
<template x-for="client in group.clients" :key="client.id"> <template x-for="client in group.clients" :key="client.id">
<tr> <tr>
<td x-text="client.name"></td> <td class="client-name-cell" data-label="{{ t.configs_name }}" x-text="client.name"></td>
{% if is_admin %} {% if is_admin %}
<td x-text="ownerLabel(client)"></td> <td data-label="{{ t.configs_owner }}" x-text="ownerLabel(client)"></td>
{% endif %} {% endif %}
<td><code x-text="client.address + '/32'"></code></td> <td data-label="{{ t.configs_address }}"><code x-text="client.address + '/32'"></code></td>
<td><code x-text="shortKey(client.public_key)"></code></td> <td data-label="{{ t.configs_public_key }}"><code x-text="shortKey(client.public_key)"></code></td>
<td x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td> <td data-label="{{ t.configs_group }}" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></td>
<td> <td data-label="{{ t.configs_enabled }}" x-text="client.enabled ? '{{ t.configs_yes }}' : '{{ t.configs_no }}'"></td>
<td class="client-actions-cell" data-label="{{ t.users_actions }}">
<div class="row-actions"> <div class="row-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button> <button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button> <button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button> <button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div> </div>
@@ -276,10 +294,89 @@
</tbody> </tbody>
</template> </template>
</table> </table>
<div class="mobile-client-list">
<template x-for="ownerGroup in groupedClients()" :key="`mobile-${ownerGroup.key}`">
<section class="mobile-owner-group">
<div class="mobile-owner-head">
<span x-text="ownerGroup.label"></span>
<span x-text="ownerGroup.clients.length"></span>
</div>
<template x-for="client in ownerGroup.clients" :key="`mobile-client-${client.id}`">
<article class="mobile-client-card">
<div class="mobile-client-head">
<span class="mobile-client-name" x-text="client.name"></span>
<span class="mobile-client-state" :class="{ enabled: client.enabled }" x-text="client.enabled ? '{{ t.configs_enabled_state }}' : '{{ t.configs_disabled_state }}'"></span>
</div>
<div class="mobile-client-meta">
<div class="mobile-meta-item">
<span class="mobile-meta-label">{{ t.configs_address }}</span>
<code class="mobile-meta-value" x-text="client.address + '/32'"></code>
</div>
<div class="mobile-meta-item">
<span class="mobile-meta-label">{{ t.configs_group }}</span>
<span class="mobile-meta-value" x-text="client.group_name || '{{ t.configs_group_isolated }}'"></span>
</div>
<div class="mobile-meta-item full">
<span class="mobile-meta-label">{{ t.configs_public_key }}</span>
<code class="mobile-meta-value" x-text="shortKey(client.public_key)"></code>
</div>
</div>
<div class="mobile-client-actions">
<button class="secondary" @click="openServers(client)" :disabled="busy">{{ t.configs_servers }}</button>
<button class="secondary" @click="openGroupEditor(client)" :disabled="busy">{{ t.configs_change_group }}</button>
<button class="secondary" @click="setEnabled(client, !client.enabled)" :disabled="busy" x-text="client.enabled ? '{{ t.configs_disable }}' : '{{ t.configs_enable }}'"></button>
<button class="danger" @click="deleteClient(client)" :disabled="busy">{{ t.users_delete }}</button>
</div>
</article>
</template>
</section>
</template>
</div>
</template> </template>
</main> </main>
</div> </div>
<template x-if="clientModal">
<div class="modal-backdrop" @click.self="closeClientModal()">
<div class="modal">
<div class="modal-head">
<div class="modal-title">
<h3 x-text="clientModal.mode === 'create' ? '{{ t.configs_new_key }}' : '{{ t.configs_change_group }}'"></h3>
<span class="modal-subtitle" x-show="clientModal.client" x-text="clientModal.client ? clientModal.client.name : ''"></span>
</div>
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
</div>
<template x-if="clientModal.mode === 'create'">
<div class="detail-row">
<label class="detail-label" for="admin-client-name">{{ t.configs_name }}</label>
<input id="admin-client-name" x-model="clientModal.name" placeholder="{{ t.configs_name_placeholder }}">
</div>
</template>
<div class="detail-row">
<label class="detail-label" for="admin-client-group">{{ t.configs_group }}</label>
<div>
<input id="admin-client-group" x-model="clientModal.groupName" list="admin-vpn-client-groups" placeholder="{{ t.configs_group_placeholder }}">
<datalist id="admin-vpn-client-groups">
<template x-for="group in groups" :key="group">
<option :value="group"></option>
</template>
</datalist>
</div>
</div>
<template x-if="clientModal.mode === 'group'">
<div class="notice tone-warning">
<span>{{ t.configs_group_rotation_warning }}</span>
</div>
</template>
<div class="modal-actions">
<button class="secondary" @click="closeClientModal()">{{ t.admin_close }}</button>
<button @click="clientModal.mode === 'create' ? createClient() : changeClientGroup()" :disabled="busy">{{ t.settings_save }}</button>
</div>
</div>
</div>
</template>
<template x-if="rolloutModal"> <template x-if="rolloutModal">
<div class="modal-backdrop" @click.self="closeRollout()"> <div class="modal-backdrop" @click.self="closeRollout()">
<div class="modal servers-modal"> <div class="modal servers-modal">
@@ -451,7 +548,8 @@
function configsPage() { function configsPage() {
return { return {
clients: [], clients: [],
newName: '', groups: [],
clientModal: null,
busy: false, busy: false,
status: '', status: '',
error: '', error: '',
@@ -685,23 +783,49 @@ function configsPage() {
try { try {
const data = await this.request('/api/vpn-clients'); const data = await this.request('/api/vpn-clients');
this.clients = data.clients; this.clients = data.clients;
this.groups = data.groups || [];
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
} finally { } finally {
this.busy = false; this.busy = false;
} }
}, },
openCreateClient() {
this.clientModal = { mode: 'create', name: '', groupName: '', client: null };
},
openGroupEditor(client) {
this.clientModal = {
mode: 'group',
name: client.name || '',
groupName: client.group_name || '',
client,
};
},
closeClientModal() {
if (!this.busy) this.clientModal = null;
},
rememberGroup(groupName) {
const group = String(groupName || '').trim();
if (group && !this.groups.includes(group)) {
this.groups.push(group);
this.groups.sort((left, right) => left.localeCompare(right));
}
},
async createClient() { async createClient() {
if (!this.clientModal || this.clientModal.mode !== 'create') return;
this.clearNotice(); this.clearNotice();
this.busy = true; this.busy = true;
try { try {
const name = this.clientModal.name;
const groupName = this.clientModal.groupName.trim() || null;
const data = await this.request('/api/vpn-clients', { const data = await this.request('/api/vpn-clients', {
method: 'POST', method: 'POST',
body: JSON.stringify({ name: this.newName }), body: JSON.stringify({ name, group_name: groupName }),
}); });
this.clients.push(data.client); this.clients.push(data.client);
delete this.serverConfigs[data.client.id]; delete this.serverConfigs[data.client.id];
this.newName = ''; this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.notice_create_success }}'); this.applyResponseNotice(data, '{{ t.notice_create_success }}');
if (this.isAdmin) await this.loadRolloutStatus(); if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) { } catch (e) {
@@ -710,6 +834,36 @@ function configsPage() {
this.busy = false; this.busy = false;
} }
}, },
async changeClientGroup() {
if (!this.clientModal || this.clientModal.mode !== 'group') return;
const client = this.clientModal.client;
const groupName = this.clientModal.groupName.trim() || null;
if ((client.group_name || null) === groupName) {
this.clientModal = null;
return;
}
if (!confirm('{{ t.configs_group_rotation_confirm }}')) return;
this.clearNotice();
this.busy = true;
try {
const data = await this.request(`/api/vpn-clients/${client.id}/group`, {
method: 'POST',
body: JSON.stringify({ group_name: groupName }),
});
const index = this.clients.findIndex((item) => item.id === client.id);
if (index !== -1) this.clients[index] = data.client;
delete this.serverConfigs[client.id];
this.rememberGroup(data.client.group_name);
this.clientModal = null;
this.applyResponseNotice(data, '{{ t.configs_group_changed }}');
if (this.isAdmin) await this.loadRolloutStatus();
} catch (e) {
this.showError(e);
} finally {
this.busy = false;
}
},
async setEnabled(client, enabled) { async setEnabled(client, enabled) {
this.clearNotice(); this.clearNotice();
this.busy = true; this.busy = true;