diff --git a/.sqlx/query-09b6f2fc7ec101117a99f85a64314c32c219b73f3afa358f838cb833d5544842.json b/.sqlx/query-09b6f2fc7ec101117a99f85a64314c32c219b73f3afa358f838cb833d5544842.json new file mode 100644 index 0000000000..032ed79111 --- /dev/null +++ b/.sqlx/query-09b6f2fc7ec101117a99f85a64314c32c219b73f3afa358f838cb833d5544842.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO wireguard_network_device (device_id, wireguard_network_id, wireguard_ips, is_authorized, authorized_at, preshared_key) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT ON CONSTRAINT device_network DO UPDATE SET wireguard_ips = $3, is_authorized = $4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "InetArray", + "Bool", + "Timestamp", + "Text" + ] + }, + "nullable": [] + }, + "hash": "09b6f2fc7ec101117a99f85a64314c32c219b73f3afa358f838cb833d5544842" +} diff --git a/.sqlx/query-12d13e1b78576a8751ae2c219f865ee71d125c20933c6659b747cf5ccdc1d9a1.json b/.sqlx/query-12d13e1b78576a8751ae2c219f865ee71d125c20933c6659b747cf5ccdc1d9a1.json deleted file mode 100644 index c2f369db0b..0000000000 --- a/.sqlx/query-12d13e1b78576a8751ae2c219f865ee71d125c20933c6659b747cf5ccdc1d9a1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO wireguard_network_device (device_id, wireguard_network_id, wireguard_ip, is_authorized, authorized_at, preshared_key) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT ON CONSTRAINT device_network DO UPDATE SET wireguard_ip = $3, is_authorized = $4", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Inet", - "Bool", - "Timestamp", - "Text" - ] - }, - "nullable": [] - }, - "hash": "12d13e1b78576a8751ae2c219f865ee71d125c20933c6659b747cf5ccdc1d9a1" -} diff --git a/.sqlx/query-20efd0ac76bd8a6ca51dd31ff89125ce288466a847cf58b3e9be7659e7360933.json b/.sqlx/query-20efd0ac76bd8a6ca51dd31ff89125ce288466a847cf58b3e9be7659e7360933.json new file mode 100644 index 0000000000..f8ce8a0666 --- /dev/null +++ b/.sqlx/query-20efd0ac76bd8a6ca51dd31ff89125ce288466a847cf58b3e9be7659e7360933.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE wireguard_network_device SET wireguard_ips = $3, is_authorized = $4, authorized_at = $5, preshared_key = $6 WHERE device_id = $1 AND wireguard_network_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "InetArray", + "Bool", + "Timestamp", + "Text" + ] + }, + "nullable": [] + }, + "hash": "20efd0ac76bd8a6ca51dd31ff89125ce288466a847cf58b3e9be7659e7360933" +} diff --git a/.sqlx/query-fad6990b8d347099568fa0e867a30923a54812064de0b9331b2c71134e6ce29e.json b/.sqlx/query-469bf9a1de598cac208a943d0e6e32c9ebb2231dd6da663cdeaaf29b93afc8ac.json similarity index 73% rename from .sqlx/query-fad6990b8d347099568fa0e867a30923a54812064de0b9331b2c71134e6ce29e.json rename to .sqlx/query-469bf9a1de598cac208a943d0e6e32c9ebb2231dd6da663cdeaaf29b93afc8ac.json index 0852c267c0..0ba89d9c7a 100644 --- a/.sqlx/query-fad6990b8d347099568fa0e867a30923a54812064de0b9331b2c71134e6ce29e.json +++ b/.sqlx/query-469bf9a1de598cac208a943d0e6e32c9ebb2231dd6da663cdeaaf29b93afc8ac.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1", + "query": "SELECT device_id, wireguard_network_id, wireguard_ips \"wireguard_ips: Vec\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1 ORDER BY id LIMIT 1", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 3, @@ -48,5 +48,5 @@ true ] }, - "hash": "fad6990b8d347099568fa0e867a30923a54812064de0b9331b2c71134e6ce29e" + "hash": "469bf9a1de598cac208a943d0e6e32c9ebb2231dd6da663cdeaaf29b93afc8ac" } diff --git a/.sqlx/query-4d43391c1eda0e6e74187d3c7ade0a852264d7465295de0223e00cf1f69c98c1.json b/.sqlx/query-72d1ffa9d9b2c35c82c4c05d82aa7d8596d6499193933c58fa1d7ba0a8f445bd.json similarity index 68% rename from .sqlx/query-4d43391c1eda0e6e74187d3c7ade0a852264d7465295de0223e00cf1f69c98c1.json rename to .sqlx/query-72d1ffa9d9b2c35c82c4c05d82aa7d8596d6499193933c58fa1d7ba0a8f445bd.json index e63c11d965..5df6af55de 100644 --- a/.sqlx/query-4d43391c1eda0e6e74187d3c7ade0a852264d7465295de0223e00cf1f69c98c1.json +++ b/.sqlx/query-72d1ffa9d9b2c35c82c4c05d82aa7d8596d6499193933c58fa1d7ba0a8f445bd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT wireguard_network_id network_id, wireguard_ip \"device_wireguard_ip: IpAddr\", preshared_key, is_authorized FROM wireguard_network_device WHERE device_id = $1", + "query": "SELECT wireguard_network_id network_id, wireguard_ips \"device_wireguard_ips: Vec\", preshared_key, is_authorized FROM wireguard_network_device WHERE device_id = $1", "describe": { "columns": [ { @@ -10,8 +10,8 @@ }, { "ordinal": 1, - "name": "device_wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "device_wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 2, @@ -36,5 +36,5 @@ false ] }, - "hash": "4d43391c1eda0e6e74187d3c7ade0a852264d7465295de0223e00cf1f69c98c1" + "hash": "72d1ffa9d9b2c35c82c4c05d82aa7d8596d6499193933c58fa1d7ba0a8f445bd" } diff --git a/.sqlx/query-455cdf6e1284b773de2d44332b49b31030fe878f3001447d2c245b9502dff427.json b/.sqlx/query-748a71bef374acac63add73aca333a3ac6c360be2c949e5567afb8383ec92475.json similarity index 74% rename from .sqlx/query-455cdf6e1284b773de2d44332b49b31030fe878f3001447d2c245b9502dff427.json rename to .sqlx/query-748a71bef374acac63add73aca333a3ac6c360be2c949e5567afb8383ec92475.json index 59bb538b5f..a3db757922 100644 --- a/.sqlx/query-455cdf6e1284b773de2d44332b49b31030fe878f3001447d2c245b9502dff427.json +++ b/.sqlx/query-748a71bef374acac63add73aca333a3ac6c360be2c949e5567afb8383ec92475.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1 ORDER BY id LIMIT 1", + "query": "SELECT device_id, wireguard_network_id, wireguard_ips \"wireguard_ips: Vec\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE wireguard_network_id = $1", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 3, @@ -48,5 +48,5 @@ true ] }, - "hash": "455cdf6e1284b773de2d44332b49b31030fe878f3001447d2c245b9502dff427" + "hash": "748a71bef374acac63add73aca333a3ac6c360be2c949e5567afb8383ec92475" } diff --git a/.sqlx/query-751bb201b4bbf3477c87f81a2fd77089d0a6773cebb138f77700cc052d1229aa.json b/.sqlx/query-751bb201b4bbf3477c87f81a2fd77089d0a6773cebb138f77700cc052d1229aa.json new file mode 100644 index 0000000000..71b6a42369 --- /dev/null +++ b/.sqlx/query-751bb201b4bbf3477c87f81a2fd77089d0a6773cebb138f77700cc052d1229aa.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT d.wireguard_pubkey pubkey, preshared_key, -- TODO possible to not use ARRAY-unnest here?\n ARRAY(\n SELECT host(ip)\n FROM unnest(wnd.wireguard_ips) AS ip\n ) \"allowed_ips!: Vec\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id JOIN \"user\" u ON d.user_id = u.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) AND d.configured = true AND u.is_active = true ORDER BY d.id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pubkey", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "preshared_key", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "allowed_ips!: Vec", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Int8", + "Bool" + ] + }, + "nullable": [ + false, + true, + null + ] + }, + "hash": "751bb201b4bbf3477c87f81a2fd77089d0a6773cebb138f77700cc052d1229aa" +} diff --git a/.sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json b/.sqlx/query-7573a899178c8cb2ee743381b6445e337d121ee56509b42b6e4f57e2575c9bce.json similarity index 89% rename from .sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json rename to .sqlx/query-7573a899178c8cb2ee743381b6445e337d121ee56509b42b6e4f57e2575c9bce.json index 83a5d73a87..d798b5cc0d 100644 --- a/.sqlx/query-3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0.json +++ b/.sqlx/query-7573a899178c8cb2ee743381b6445e337d121ee56509b42b6e4f57e2575c9bce.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN wireguard_network_device wnd ON d.id = wnd.device_id WHERE wnd.wireguard_ip = $1 AND wnd.wireguard_network_id = $2", + "query": "SELECT d.id, d.name, d.wireguard_pubkey, d.user_id, d.created, d.description, d.device_type \"device_type: DeviceType\", configured FROM device d JOIN wireguard_network_device wnd ON d.id = wnd.device_id WHERE $1 = ANY(wnd.wireguard_ips) AND wnd.wireguard_network_id = $2", "describe": { "columns": [ { @@ -71,5 +71,5 @@ false ] }, - "hash": "3d3c0799696f9c213982abe7f4633908aec2ddb747ad6e77e347365b4e61a8b0" + "hash": "7573a899178c8cb2ee743381b6445e337d121ee56509b42b6e4f57e2575c9bce" } diff --git a/.sqlx/query-42ea85e353deb1555b4e442a2fcdf366eb24bf7b907011a01313b77bed572176.json b/.sqlx/query-812635247539785e93a0b0e78239cafa2c1e5161eef7c5f35a7705be21235087.json similarity index 68% rename from .sqlx/query-42ea85e353deb1555b4e442a2fcdf366eb24bf7b907011a01313b77bed572176.json rename to .sqlx/query-812635247539785e93a0b0e78239cafa2c1e5161eef7c5f35a7705be21235087.json index 2df04febfc..f56db2e413 100644 --- a/.sqlx/query-42ea85e353deb1555b4e442a2fcdf366eb24bf7b907011a01313b77bed572176.json +++ b/.sqlx/query-812635247539785e93a0b0e78239cafa2c1e5161eef7c5f35a7705be21235087.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE wireguard_network_id = $1 AND device_id IN (SELECT id FROM device WHERE user_id = $2 AND device_type = 'user'::device_type)", + "query": "SELECT device_id, wireguard_network_id, wireguard_ips \"wireguard_ips: Vec\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE wireguard_network_id = $1 AND device_id IN (SELECT id FROM device WHERE user_id = $2 AND device_type = 'user'::device_type)", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 3, @@ -49,5 +49,5 @@ true ] }, - "hash": "42ea85e353deb1555b4e442a2fcdf366eb24bf7b907011a01313b77bed572176" + "hash": "812635247539785e93a0b0e78239cafa2c1e5161eef7c5f35a7705be21235087" } diff --git a/.sqlx/query-9338acd23dbb4f6b14d15482d86eb14fbfe962f46ebf0b0e00cfad7c2955f598.json b/.sqlx/query-9338acd23dbb4f6b14d15482d86eb14fbfe962f46ebf0b0e00cfad7c2955f598.json new file mode 100644 index 0000000000..0474caf420 --- /dev/null +++ b/.sqlx/query-9338acd23dbb4f6b14d15482d86eb14fbfe962f46ebf0b0e00cfad7c2955f598.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wireguard_ips \"wireguard_ips: Vec\" FROM wireguard_network_device wnd JOIN device d ON d.id = wnd.device_id WHERE wnd.wireguard_network_id = $1 AND d.device_type = 'user'::device_type AND d.user_id = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9338acd23dbb4f6b14d15482d86eb14fbfe962f46ebf0b0e00cfad7c2955f598" +} diff --git a/.sqlx/query-55568f51eda479e3cdaeefd641802ccf6cdcebe76c12cde524b162552b002d89.json b/.sqlx/query-98d44b2f444407cdb93574eda9a29d6b74b3288be8d31dfcd32394685ba54e0f.json similarity index 74% rename from .sqlx/query-55568f51eda479e3cdaeefd641802ccf6cdcebe76c12cde524b162552b002d89.json rename to .sqlx/query-98d44b2f444407cdb93574eda9a29d6b74b3288be8d31dfcd32394685ba54e0f.json index 819a0e66e0..4341335409 100644 --- a/.sqlx/query-55568f51eda479e3cdaeefd641802ccf6cdcebe76c12cde524b162552b002d89.json +++ b/.sqlx/query-98d44b2f444407cdb93574eda9a29d6b74b3288be8d31dfcd32394685ba54e0f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE wireguard_network_id = $1", + "query": "SELECT device_id, wireguard_network_id, wireguard_ips \"wireguard_ips: Vec\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 3, @@ -48,5 +48,5 @@ true ] }, - "hash": "55568f51eda479e3cdaeefd641802ccf6cdcebe76c12cde524b162552b002d89" + "hash": "98d44b2f444407cdb93574eda9a29d6b74b3288be8d31dfcd32394685ba54e0f" } diff --git a/.sqlx/query-21fff77d228826b89bd78d27a5b5a0225ffe0171886e9e4c7b70346f1c37d42a.json b/.sqlx/query-a1ffe5a3d79b9fb9261b59067286a6bd455ce7059baf539d7b678996c2c92c8c.json similarity index 69% rename from .sqlx/query-21fff77d228826b89bd78d27a5b5a0225ffe0171886e9e4c7b70346f1c37d42a.json rename to .sqlx/query-a1ffe5a3d79b9fb9261b59067286a6bd455ce7059baf539d7b678996c2c92c8c.json index 1ac09e7027..b6b7ea5bec 100644 --- a/.sqlx/query-21fff77d228826b89bd78d27a5b5a0225ffe0171886e9e4c7b70346f1c37d42a.json +++ b/.sqlx/query-a1ffe5a3d79b9fb9261b59067286a6bd455ce7059baf539d7b678996c2c92c8c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH stats AS ( SELECT DISTINCT ON (network) network, endpoint, latest_handshake FROM wireguard_peer_stats WHERE device_id = $2 ORDER BY network, collected_at DESC ) SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ip \"device_wireguard_ip: IpAddr\", stats.endpoint device_endpoint, stats.latest_handshake \"latest_handshake?\", COALESCE((NOW() - stats.latest_handshake) < $1, FALSE) \"is_active!\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN stats ON n.id = stats.network WHERE wnd.device_id = $2", + "query": "WITH stats AS ( SELECT DISTINCT ON (network) network, endpoint, latest_handshake FROM wireguard_peer_stats WHERE device_id = $2 ORDER BY network, collected_at DESC ) SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", stats.endpoint device_endpoint, stats.latest_handshake \"latest_handshake?\", COALESCE((NOW() - stats.latest_handshake) < $1, FALSE) \"is_active!\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN stats ON n.id = stats.network WHERE wnd.device_id = $2", "describe": { "columns": [ { @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "device_wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "device_wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 4, @@ -55,5 +55,5 @@ null ] }, - "hash": "21fff77d228826b89bd78d27a5b5a0225ffe0171886e9e4c7b70346f1c37d42a" + "hash": "a1ffe5a3d79b9fb9261b59067286a6bd455ce7059baf539d7b678996c2c92c8c" } diff --git a/.sqlx/query-bbc08ad580dbb33fdafa20cdefda974dde20c858da0eb2773e59ed79697e49df.json b/.sqlx/query-bbc08ad580dbb33fdafa20cdefda974dde20c858da0eb2773e59ed79697e49df.json deleted file mode 100644 index ae31f19fa5..0000000000 --- a/.sqlx/query-bbc08ad580dbb33fdafa20cdefda974dde20c858da0eb2773e59ed79697e49df.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT wireguard_ip \"wireguard_ip: IpAddr\" FROM wireguard_network_device wnd JOIN device d ON d.id = wnd.device_id WHERE wnd.wireguard_network_id = $1 AND d.device_type = 'user'::device_type AND d.user_id = ANY($2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8Array" - ] - }, - "nullable": [ - false - ] - }, - "hash": "bbc08ad580dbb33fdafa20cdefda974dde20c858da0eb2773e59ed79697e49df" -} diff --git a/.sqlx/query-c9ccdf7be24dfe8c69f0f71242e4b37c022ebca568e9af80d65730f450664313.json b/.sqlx/query-c9ccdf7be24dfe8c69f0f71242e4b37c022ebca568e9af80d65730f450664313.json deleted file mode 100644 index 8a450dac11..0000000000 --- a/.sqlx/query-c9ccdf7be24dfe8c69f0f71242e4b37c022ebca568e9af80d65730f450664313.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT wireguard_ip \"wireguard_ip: IpAddr\" FROM wireguard_network_device wnd WHERE wnd.wireguard_network_id = $1 AND wnd.device_id = ANY($2)", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" - } - ], - "parameters": { - "Left": [ - "Int8", - "Int8Array" - ] - }, - "nullable": [ - false - ] - }, - "hash": "c9ccdf7be24dfe8c69f0f71242e4b37c022ebca568e9af80d65730f450664313" -} diff --git a/.sqlx/query-da7a1669b1ba89003f6c507b5b2b09f39004776d14c0b37fc9d7ac636b2b58ec.json b/.sqlx/query-da7a1669b1ba89003f6c507b5b2b09f39004776d14c0b37fc9d7ac636b2b58ec.json deleted file mode 100644 index 3ef5a4b8a6..0000000000 --- a/.sqlx/query-da7a1669b1ba89003f6c507b5b2b09f39004776d14c0b37fc9d7ac636b2b58ec.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE wireguard_network_device SET wireguard_ip = $3, is_authorized = $4, authorized_at = $5, preshared_key = $6 WHERE device_id = $1 AND wireguard_network_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8", - "Int8", - "Inet", - "Bool", - "Timestamp", - "Text" - ] - }, - "nullable": [] - }, - "hash": "da7a1669b1ba89003f6c507b5b2b09f39004776d14c0b37fc9d7ac636b2b58ec" -} diff --git a/.sqlx/query-dca1b36b0c60d9dd643e0f8b86b35c93e831d73c532d038fda7c831123600ce3.json b/.sqlx/query-dca1b36b0c60d9dd643e0f8b86b35c93e831d73c532d038fda7c831123600ce3.json new file mode 100644 index 0000000000..4827cb824d --- /dev/null +++ b/.sqlx/query-dca1b36b0c60d9dd643e0f8b86b35c93e831d73c532d038fda7c831123600ce3.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT wireguard_ips \"wireguard_ips: Vec\" FROM wireguard_network_device wnd WHERE wnd.wireguard_network_id = $1 AND wnd.device_id = ANY($2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "wireguard_ips: Vec", + "type_info": "InetArray" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "dca1b36b0c60d9dd643e0f8b86b35c93e831d73c532d038fda7c831123600ce3" +} diff --git a/.sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json b/.sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json deleted file mode 100644 index 8f86869ba5..0000000000 --- a/.sqlx/query-f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT d.wireguard_pubkey pubkey, preshared_key, array[host(wnd.wireguard_ip)] \"allowed_ips!: Vec\" FROM wireguard_network_device wnd JOIN device d ON wnd.device_id = d.id JOIN \"user\" u ON d.user_id = u.id WHERE wireguard_network_id = $1 AND (is_authorized = true OR NOT $2) AND d.configured = true AND u.is_active = true ORDER BY d.id ASC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "pubkey", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "preshared_key", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "allowed_ips!: Vec", - "type_info": "TextArray" - } - ], - "parameters": { - "Left": [ - "Int8", - "Bool" - ] - }, - "nullable": [ - false, - true, - null - ] - }, - "hash": "f6131bed08c9fb384b0c599e081085a8a4a9ca555dfbe3b7eef0f043dcc557b2" -} diff --git a/.sqlx/query-a74e2fda629b2b2916e3f034df84470cf7fb21a2f066559fad80a3d2396f9e21.json b/.sqlx/query-f7dcde071795cb2b14cd1d459259985e4f0d3d88810b317244ac40d2d976b6f3.json similarity index 73% rename from .sqlx/query-a74e2fda629b2b2916e3f034df84470cf7fb21a2f066559fad80a3d2396f9e21.json rename to .sqlx/query-f7dcde071795cb2b14cd1d459259985e4f0d3d88810b317244ac40d2d976b6f3.json index 6bc42a2bc4..aebfd05555 100644 --- a/.sqlx/query-a74e2fda629b2b2916e3f034df84470cf7fb21a2f066559fad80a3d2396f9e21.json +++ b/.sqlx/query-f7dcde071795cb2b14cd1d459259985e4f0d3d88810b317244ac40d2d976b6f3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1 AND wireguard_network_id = $2", + "query": "SELECT device_id, wireguard_network_id, wireguard_ips \"wireguard_ips: Vec\", preshared_key, is_authorized, authorized_at FROM wireguard_network_device WHERE device_id = $1 AND wireguard_network_id = $2", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "wireguard_ip: IpAddr", - "type_info": "Inet" + "name": "wireguard_ips: Vec", + "type_info": "InetArray" }, { "ordinal": 3, @@ -49,5 +49,5 @@ true ] }, - "hash": "a74e2fda629b2b2916e3f034df84470cf7fb21a2f066559fad80a3d2396f9e21" + "hash": "f7dcde071795cb2b14cd1d459259985e4f0d3d88810b317244ac40d2d976b6f3" } diff --git a/Cargo.lock b/Cargo.lock index ef9af4d6ce..aa1f0aa23e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,14 +63,14 @@ dependencies = [ [[package]] name = "ahash" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", "once_cell", "version_check", - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -205,7 +205,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "synstructure", ] @@ -217,7 +217,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -239,7 +239,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -250,7 +250,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -303,9 +303,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de45108900e1f9b9242f7f2e254aa3e2c029c921c258fe9e6b4217eeebd54288" +checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ "axum-core 0.5.2", "bytes", @@ -341,7 +341,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dff8ee1869817523c8f91c20bf17fd932707f66c2e7e0b0f811b29a227289562" dependencies = [ - "axum 0.8.3", + "axum 0.8.4", "forwarded-header-value", "serde", ] @@ -392,7 +392,7 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45bf463831f5131b7d3c756525b305d40f1185b688565648a92e1392ca35713d" dependencies = [ - "axum 0.8.3", + "axum 0.8.4", "axum-core 0.5.2", "bytes", "cookie", @@ -412,9 +412,9 @@ dependencies = [ [[package]] name = "backtrace" -version = "0.3.74" +version = "0.3.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" dependencies = [ "addr2line", "cfg-if", @@ -607,9 +607,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.20" +version = "1.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04da6a0d40b948dfc4fa8f5bbf402b0fc1a64a28dbf7d12ffd683550f2c1b63a" +checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" dependencies = [ "jobserver", "libc", @@ -639,9 +639,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.40" +version = "0.4.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a7964611d71df112cb1730f2ee67324fcf4d0fc6606acbbe9bfe06df124637c" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" dependencies = [ "android-tzdata", "iana-time-zone", @@ -702,9 +702,9 @@ checksum = "bba18ee93d577a8428902687bcc2b6b45a56b1981a1f6d779731c86cc4c5db18" [[package]] name = "clap" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", "clap_derive", @@ -712,9 +712,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstream", "anstyle", @@ -731,7 +731,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -891,9 +891,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69e6e4d7b33a94f0991c26729976b10ebde1d34c3ee82408fb536164fa10d636" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" dependencies = [ "crc-catalog", ] @@ -1015,7 +1015,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1039,7 +1039,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1050,7 +1050,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1074,7 +1074,7 @@ version = "1.3.0" dependencies = [ "anyhow", "argon2", - "axum 0.8.3", + "axum 0.8.4", "axum-client-ip", "axum-extra", "base32", @@ -1195,7 +1195,7 @@ checksum = "30542c1ad912e0e3d22a1935c290e12e8a29d704a420177a31faad4a601a0800" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1216,7 +1216,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1226,7 +1226,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1239,7 +1239,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1259,7 +1259,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "unicode-xid", ] @@ -1274,9 +1274,9 @@ dependencies = [ [[package]] name = "deunicode" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" +checksum = "abd57806937c9cc163efc8ea3910e00a62e2aeb0b8119f1793a978088f8f6b04" [[package]] name = "digest" @@ -1298,7 +1298,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1670,7 +1670,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -1738,9 +1738,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "js-sys", @@ -1768,9 +1768,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "git2" -version = "0.20.1" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5220b8ba44c68a9a7f7a7659e864dd73692e417ef0211bea133c7b74e031eeb9" +checksum = "2deb07a133b1520dc1a5690e9bd08950108873d7ed5de38dcc74d3b5ebffa110" dependencies = [ "bitflags 2.9.0", "libc", @@ -1816,9 +1816,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.9" +version = "0.4.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75249d144030531f8dee69fe9cea04d3edf809a017ae445e2abdff6629e86633" +checksum = "a9421a676d1b147b16b82c9225157dc629087ef8ec4d5e2960f9437a90dac0a5" dependencies = [ "atomic-waker", "bytes", @@ -1857,9 +1857,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" dependencies = [ "allocator-api2", "equivalent", @@ -1872,7 +1872,7 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.15.2", + "hashbrown 0.15.3", ] [[package]] @@ -2052,7 +2052,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 0.26.11", ] [[package]] @@ -2130,21 +2130,22 @@ dependencies = [ [[package]] name = "icu_collections" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" dependencies = [ "displaydoc", + "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] -name = "icu_locid" -version = "1.5.0" +name = "icu_locale_core" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" dependencies = [ "displaydoc", "litemap", @@ -2153,31 +2154,11 @@ dependencies = [ "zerovec", ] -[[package]] -name = "icu_locid_transform" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" -dependencies = [ - "displaydoc", - "icu_locid", - "icu_locid_transform_data", - "icu_provider", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locid_transform_data" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7515e6d781098bf9f7205ab3fc7e9709d34554ae0b21ddbcb5febfa4bc7df11d" - [[package]] name = "icu_normalizer" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" dependencies = [ "displaydoc", "icu_collections", @@ -2185,67 +2166,54 @@ dependencies = [ "icu_properties", "icu_provider", "smallvec", - "utf16_iter", - "utf8_iter", - "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e8338228bdc8ab83303f16b797e177953730f601a96c25d10cb3ab0daa0cb7" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" [[package]] name = "icu_properties" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +checksum = "2549ca8c7241c82f59c80ba2a6f415d931c5b58d24fb8412caa1a1f02c49139a" dependencies = [ "displaydoc", "icu_collections", - "icu_locid_transform", + "icu_locale_core", "icu_properties_data", "icu_provider", - "tinystr", + "potential_utf", + "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "1.5.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85fb8799753b75aee8d2a21d7c14d9f38921b54b3dbda10f5a3c7a7b82dba5e2" +checksum = "8197e866e47b68f8f7d95249e172903bec06004b18b2937f1095d40a0c57de04" [[package]] name = "icu_provider" -version = "1.5.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" dependencies = [ "displaydoc", - "icu_locid", - "icu_provider_macros", + "icu_locale_core", "stable_deref_trait", "tinystr", "writeable", "yoke", "zerofrom", + "zerotrie", "zerovec", ] -[[package]] -name = "icu_provider_macros" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", -] - [[package]] name = "idea" version = "0.5.1" @@ -2274,9 +2242,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", @@ -2316,7 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "serde", ] @@ -2386,7 +2354,7 @@ version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "libc", ] @@ -2500,9 +2468,9 @@ dependencies = [ [[package]] name = "lettre" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759bc2b8eabb6a30b235d6f716f7f36479f4b38cbe65b8747aefee51f89e8437" +checksum = "87ffd14fa289730e3ad68edefdc31f603d56fe716ec38f2076bb7410e09147c2" dependencies = [ "async-trait", "base64 0.22.1", @@ -2546,9 +2514,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9627da5196e5d8ed0b0495e61e518847578da83483c37288316d9b2e03a7f72" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" [[package]] name = "libsqlite3-sys" @@ -2580,9 +2548,9 @@ checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" [[package]] name = "litemap" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23fb14cb19457329c82206317a5663005a4d404783dc74f4252769b0d5f42856" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" [[package]] name = "litrs" @@ -2606,6 +2574,12 @@ version = "0.4.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "lzma-rs" version = "0.3.0" @@ -2726,14 +2700,14 @@ name = "model_derive" version = "0.2.0" dependencies = [ "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] name = "multimap" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "defc4c55412d89136f966bbb339008b474350e5e6e78d2714439c386b3137a03" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] name = "native-tls" @@ -2880,7 +2854,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3008,7 +2982,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3019,9 +2993,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-sys" -version = "0.9.107" +version = "0.9.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8288979acd84749c744a9014b4382d42b8f7b2592847b5afb2ed29e5d16ede07" +checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847" dependencies = [ "cc", "libc", @@ -3224,7 +3198,7 @@ dependencies = [ "pest_meta", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3370,7 +3344,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3424,6 +3398,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "potential_utf" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -3436,7 +3419,7 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ - "zerocopy 0.8.24", + "zerocopy", ] [[package]] @@ -3446,7 +3429,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "664ec5419c51e34154eec046ebcba56312d5a2fc3b09a06da188e1ad21afadf6" dependencies = [ "proc-macro2", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3502,7 +3485,7 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.100", + "syn 2.0.101", "tempfile", ] @@ -3516,7 +3499,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -3536,9 +3519,9 @@ checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" [[package]] name = "psm" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f58e5423e24c18cc840e1c98370b3993c6649cd1678b4d24318bcf0a083cbe88" +checksum = "6e944464ec8536cd1beb0bbfd96987eb5e3b72f2ecdafdc5c769a37f1fa2ae1f" dependencies = [ "cc", ] @@ -3574,9 +3557,9 @@ checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" [[package]] name = "quinn" -version = "0.11.7" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bd15a6f2967aef83887dcb9fec0014580467e33720d073560cf015a5683012" +checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" dependencies = [ "bytes", "cfg_aliases", @@ -3594,12 +3577,13 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbafbbdbb0f638fe3f35f3c56739f77a8a1d070cb25603226c83339b391472b" +checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" dependencies = [ "bytes", - "getrandom 0.3.2", + "getrandom 0.3.3", + "lru-slab", "rand 0.9.1", "ring", "rustc-hash", @@ -3614,9 +3598,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "541d0f57c6ec747a90738a52741d3221f7960e8ac2f0ff4b1a63680e033b4ab5" +checksum = "ee4e529991f949c5e25755532370b8af5d114acae52326361d68d47af64aa842" dependencies = [ "cfg_aliases", "libc", @@ -3709,14 +3693,14 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", ] [[package]] name = "redox_syscall" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f103c6d277498fbceb16e84d317e2a400f160f46904d5f5410848c829511a3" +checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ "bitflags 2.9.0", ] @@ -3815,7 +3799,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots", + "webpki-roots 0.26.11", "windows-registry", ] @@ -3875,9 +3859,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.7.0" +version = "8.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fbc0ee50fcb99af7cebb442e5df7b5b45e9460ffa3f8f549cd26b862bec49d" +checksum = "60e425e204264b144d4c929d126d0de524b40a961686414bab5040f7465c71be" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -3893,7 +3877,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.100", + "syn 2.0.101", "walkdir", ] @@ -3951,9 +3935,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.0.5" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" +checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ "bitflags 2.9.0", "errno", @@ -3964,9 +3948,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.26" +version = "0.23.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df51b5869f3a441595eac5e8ff14d486ff285f7b8c0df8770e49c3b56351f0f0" +checksum = "730944ca083c1c233a75c09f199e973ca499344a2b7ba9e755c457e86fb4a321" dependencies = [ "log", "once_cell", @@ -4000,18 +3984,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" dependencies = [ "web-time", + "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.1" +version = "0.103.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fef8b8769aaccf73098557a87cd1816b4f9c7c16811c9c77142aa695c16f2c03" +checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" dependencies = [ "ring", "rustls-pki-types", @@ -4166,7 +4151,7 @@ checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4250,7 +4235,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4301,9 +4286,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", @@ -4469,7 +4454,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.15.2", + "hashbrown 0.15.3", "hashlink", "indexmap 2.9.0", "ipnetwork", @@ -4500,7 +4485,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4523,7 +4508,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.100", + "syn 2.0.101", "tempfile", "tokio", "url", @@ -4688,9 +4673,9 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stacker" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601f9201feb9b09c00266478bf459952b9ef9a6b94edb2f21eba14ab681a60a9" +checksum = "cddb07e32ddb770749da91081d8d0ac3a16f1a569a18b20348cd371f5dead06b" dependencies = [ "cc", "cfg-if", @@ -4733,7 +4718,7 @@ checksum = "ac94fea04bf721f57ed7f421e64d3a04858e15708d00e8aa814cad7507427503" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4755,9 +4740,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.100" +version = "2.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b09a44accad81e1ba1cd74a32461ba89dee89095ba17b32f5d03683b1b1fc2a0" +checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" dependencies = [ "proc-macro2", "quote", @@ -4775,13 +4760,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4807,12 +4792,12 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.19.1" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7437ac7763b9b123ccf33c338a5cc1bac6f69b45a136c19bdd8a65e3916435bf" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" dependencies = [ "fastrand", - "getrandom 0.3.2", + "getrandom 0.3.3", "once_cell", "rustix", "windows-sys 0.59.0", @@ -4866,7 +4851,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4877,7 +4862,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -4934,9 +4919,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.7.6" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" dependencies = [ "displaydoc", "zerovec", @@ -4959,9 +4944,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.44.2" +version = "1.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b88822cbe49de4185e3a4cbf8321dd487cf5fe0c5c65695fef6346371e9c48" +checksum = "2513ca694ef9ede0fb23fe71a4ee4107cb102b9dc1930f6d0fd77aae068ae165" dependencies = [ "backtrace", "bytes", @@ -4982,7 +4967,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -5032,15 +5017,15 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.6.8" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "3da5db5a963e24bc68be8b17b6fa82814bb22ee8660f192bb182771d498f09a3" [[package]] name = "toml_edit" -version = "0.22.24" +version = "0.22.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17b4795ff5edd201c7cd6dca065ae59972ce77d1b80fa0a84d94950ece7d1474" +checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e" dependencies = [ "indexmap 2.9.0", "toml_datetime", @@ -5092,7 +5077,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -5158,9 +5143,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "403fa3b783d4b626a8ad51d766ab03cb6d2dbfc46b1c5d4448395e6628dc9697" +checksum = "0fdb0c213ca27a9f57ab69ddb290fd80d970922355b83ae380b395d3986b8a2e" dependencies = [ "bitflags 2.9.0", "bytes", @@ -5213,7 +5198,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -5263,7 +5248,7 @@ checksum = "70977707304198400eb4835a78f6a9f928bf41bba420deb8fdb175cd965d77a7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -5442,12 +5427,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf16_iter" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5481,7 +5460,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.100", + "syn 2.0.101", "uuid", ] @@ -5491,7 +5470,7 @@ version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "161166ec520c50144922a625d8bc4925cc801b2dda958ab69878527c0e5c5d61" dependencies = [ - "axum 0.8.3", + "axum 0.8.4", "base64 0.22.1", "mime_guess", "regex", @@ -5516,7 +5495,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "serde", ] @@ -5639,7 +5618,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "wasm-bindgen-shared", ] @@ -5674,7 +5653,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -5821,9 +5800,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2210b291f7ea53617fbafcc4939f10914214ec15aace5ba62293a668f322c5c9" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.0", +] + +[[package]] +name = "webpki-roots" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2853738d1cc4f2da3a225c18ec6c3721abb31961096e9dbf5ab35fa88b19cfdb" dependencies = [ "rustls-pki-types", ] @@ -5890,7 +5878,7 @@ checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -5901,7 +5889,7 @@ checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -6162,9 +6150,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" [[package]] name = "winnow" -version = "0.7.7" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb8234a863ea0e8cd7284fcdd4f145233eb00fee02bbdd9861aec44e6477bc5" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] @@ -6178,17 +6166,11 @@ dependencies = [ "bitflags 2.9.0", ] -[[package]] -name = "write16" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" - [[package]] name = "writeable" -version = "0.5.5" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" [[package]] name = "x25519-dalek" @@ -6250,9 +6232,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" dependencies = [ "serde", "stable_deref_trait", @@ -6262,54 +6244,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.7.5" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "synstructure", ] [[package]] name = "zerocopy" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +checksum = "a1702d9583232ddb9174e01bb7c15a2ab8fb1bc6f227aa1233858c351a3ba0cb" dependencies = [ - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" -dependencies = [ - "zerocopy-derive 0.8.24", + "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.7.35" +version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +checksum = "28a6e20d751156648aa063f3800b706ee209a32c0b4d9f24be3d980b01be55ef" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -6329,7 +6291,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", "synstructure", ] @@ -6350,14 +6312,25 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", +] + +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", ] [[package]] name = "zerovec" -version = "0.10.4" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" dependencies = [ "yoke", "zerofrom", @@ -6366,13 +6339,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.10.3" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.100", + "syn 2.0.101", ] [[package]] @@ -6390,7 +6363,7 @@ dependencies = [ "deflate64", "displaydoc", "flate2", - "getrandom 0.3.2", + "getrandom 0.3.3", "hmac", "indexmap 2.9.0", "lzma-rs", diff --git a/deny.toml b/deny.toml index 388c6a79cf..bd369c606f 100644 --- a/deny.toml +++ b/deny.toml @@ -99,6 +99,7 @@ allow = [ "CC0-1.0", "OpenSSL", "AGPL-3.0", + "CDLA-Permissive-2.0", ] # The confidence threshold for detecting a license from license text. # The higher the value, the more closely the license text must be to the diff --git a/e2e/types.ts b/e2e/types.ts index 369edb6301..00a10bf9d1 100644 --- a/e2e/types.ts +++ b/e2e/types.ts @@ -1,5 +1,5 @@ export type DeviceNetworkInfo = { - device_wireguard_ip: string; + device_wireguard_ips: string[]; is_active: boolean; network_gateway_ip: string; network_id: number; diff --git a/flake.lock b/flake.lock index 82c8920ded..194840136a 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1745391562, - "narHash": "sha256-sPwcCYuiEopaafePqlG826tBhctuJsLx/mhKKM5Fmjo=", + "lastModified": 1746904237, + "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "8a2f738d9d1f1d986b5a4cd2fd2061a7127237d7", + "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", "type": "github" }, "original": { @@ -48,11 +48,11 @@ ] }, "locked": { - "lastModified": 1745548521, - "narHash": "sha256-xyliq8oS5OnzXjHRGr92RtmrtYI/dflf2gSEo0wMFjc=", + "lastModified": 1747190175, + "narHash": "sha256-s33mQ2s5L/2nyllhRTywgECNZyCqyF4MJeM3vG/GaRo=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "eb0afb4ac0720d55c29e88eb29432103d73ae11d", + "rev": "58160be7abad81f6f8cb53120d5b88c16e01c06d", "type": "github" }, "original": { diff --git a/migrations/20250514000000_multiple_peer_addresses.down.sql b/migrations/20250514000000_multiple_peer_addresses.down.sql new file mode 100644 index 0000000000..0304d19e18 --- /dev/null +++ b/migrations/20250514000000_multiple_peer_addresses.down.sql @@ -0,0 +1,16 @@ +-- add old-type address column +ALTER TABLE wireguard_network_device +ADD COLUMN wireguard_ip inet; + +-- copy the first element of new column to old column +-- all further addresses will be lost +UPDATE wireguard_network_device +SET wireguard_ip = wireguard_ips[1]; + +-- add not-null modifier to old-type address column +ALTER TABLE wireguard_network_device +ALTER COLUMN wireguard_ip SET NOT NULL; + +-- drop the "new" column +ALTER TABLE wireguard_network_device +DROP COLUMN wireguard_ips; diff --git a/migrations/20250514000000_multiple_peer_addresses.up.sql b/migrations/20250514000000_multiple_peer_addresses.up.sql new file mode 100644 index 0000000000..1872dfd1fc --- /dev/null +++ b/migrations/20250514000000_multiple_peer_addresses.up.sql @@ -0,0 +1,11 @@ +-- add new address column +ALTER TABLE wireguard_network_device +ADD COLUMN wireguard_ips inet[] NOT NULL DEFAULT '{}'; + +-- copy and convert existing IPs into arrays +UPDATE wireguard_network_device +SET wireguard_ips = ARRAY[wireguard_ip]; + +-- drop the old column +ALTER TABLE wireguard_network_device +DROP COLUMN wireguard_ip; diff --git a/proto b/proto index 289f58ef42..d72ced8984 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 289f58ef42617bfbdd75d98a62ac9aec2ab8d1d6 +Subproject commit d72ced898411c4b8144bb13a9ad48f65e2f6a1ec diff --git a/src/db/models/device.rs b/src/db/models/device.rs index dfba37b299..4a09404bfb 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -21,11 +21,11 @@ use utoipa::ToSchema; use super::{ error::ModelError, - wireguard::{WireguardNetwork, WIREGUARD_MAX_HANDSHAKE}, + wireguard::{NetworkAddressError, WireguardNetwork, WIREGUARD_MAX_HANDSHAKE}, }; use crate::{ db::{Id, NoId, User}, - KEY_LENGTH, + AsCsv, KEY_LENGTH, }; #[derive(Serialize, ToSchema)] @@ -34,7 +34,7 @@ pub struct DeviceConfig { pub(crate) network_name: String, pub(crate) config: String, #[schema(value_type = String)] - pub(crate) address: IpAddr, + pub(crate) address: Vec, pub(crate) endpoint: String, #[schema(value_type = String)] pub(crate) allowed_ips: Vec, @@ -142,7 +142,7 @@ pub struct DeviceInfo { #[derive(Clone, Debug, Deserialize, Serialize)] pub struct DeviceNetworkInfo { pub network_id: Id, - pub device_wireguard_ip: IpAddr, + pub device_wireguard_ips: Vec, #[serde(skip_serializing)] pub preshared_key: Option, pub is_authorized: bool, @@ -159,8 +159,9 @@ impl DeviceInfo { debug!("Generating device info for {device}"); let network_info = query_as!( DeviceNetworkInfo, - "SELECT wireguard_network_id network_id, wireguard_ip \"device_wireguard_ip: IpAddr\", \ - preshared_key, is_authorized \ + "SELECT wireguard_network_id network_id, \ + wireguard_ips \"device_wireguard_ips: Vec\", \ + preshared_key, is_authorized \ FROM wireguard_network_device \ WHERE device_id = $1", device.id @@ -189,7 +190,7 @@ pub struct UserDeviceNetworkInfo { pub network_id: Id, pub network_name: String, pub network_gateway_ip: String, - pub device_wireguard_ip: String, + pub device_wireguard_ips: Vec, pub last_connected_ip: Option, pub last_connected_location: Option, pub last_connected_at: Option, @@ -207,7 +208,7 @@ impl UserDevice { ORDER BY network, collected_at DESC \ ) \ SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, \ - wnd.wireguard_ip \"device_wireguard_ip: IpAddr\", stats.endpoint device_endpoint, \ + wnd.wireguard_ips \"device_wireguard_ips: Vec\", stats.endpoint device_endpoint, \ stats.latest_handshake \"latest_handshake?\", \ COALESCE((NOW() - stats.latest_handshake) < $1, FALSE) \"is_active!\" \ FROM wireguard_network_device wnd \ @@ -237,7 +238,11 @@ impl UserDevice { network_id: r.network_id, network_name: r.network_name, network_gateway_ip: r.gateway_endpoint, - device_wireguard_ip: r.device_wireguard_ip.to_string(), + device_wireguard_ips: r + .device_wireguard_ips + .iter() + .map(IpAddr::to_string) + .collect(), last_connected_ip: device_ip, last_connected_location: None, last_connected_at: r.latest_handshake, @@ -256,7 +261,7 @@ impl UserDevice { #[derive(Clone, Debug, Deserialize, FromRow, Serialize)] pub struct WireguardNetworkDevice { pub wireguard_network_id: Id, - pub wireguard_ip: IpAddr, + pub wireguard_ips: Vec, pub device_id: Id, pub preshared_key: Option, pub is_authorized: bool, @@ -278,10 +283,13 @@ pub struct ModifyDevice { impl WireguardNetworkDevice { #[must_use] - pub(crate) fn new(network_id: Id, device_id: Id, wireguard_ip: IpAddr) -> Self { + pub(crate) fn new(network_id: Id, device_id: Id, wireguard_ips: I) -> Self + where + I: Into>, + { Self { wireguard_network_id: network_id, - wireguard_ip, + wireguard_ips: wireguard_ips.into(), device_id, preshared_key: None, is_authorized: false, @@ -289,20 +297,28 @@ impl WireguardNetworkDevice { } } + #[must_use] + pub(crate) fn ips_as_network(&self) -> Vec { + self.wireguard_ips + .iter() + .map(|ip| IpNetwork::from(*ip)) + .collect() + } + pub(crate) async fn insert<'e, E>(&self, executor: E) -> Result<(), SqlxError> where E: PgExecutor<'e>, { query!( "INSERT INTO wireguard_network_device \ - (device_id, wireguard_network_id, wireguard_ip, is_authorized, authorized_at, \ + (device_id, wireguard_network_id, wireguard_ips, is_authorized, authorized_at, \ preshared_key) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT ON CONSTRAINT device_network \ - DO UPDATE SET wireguard_ip = $3, is_authorized = $4", + DO UPDATE SET wireguard_ips = $3, is_authorized = $4", self.device_id, self.wireguard_network_id, - IpNetwork::from(self.wireguard_ip.clone()), + &self.ips_as_network(), self.is_authorized, self.authorized_at, self.preshared_key @@ -319,11 +335,11 @@ impl WireguardNetworkDevice { { query!( "UPDATE wireguard_network_device \ - SET wireguard_ip = $3, is_authorized = $4, authorized_at = $5, preshared_key = $6 \ + SET wireguard_ips = $3, is_authorized = $4, authorized_at = $5, preshared_key = $6 \ WHERE device_id = $1 AND wireguard_network_id = $2", self.device_id, self.wireguard_network_id, - IpNetwork::from(self.wireguard_ip.clone()), + &self.ips_as_network(), self.is_authorized, self.authorized_at, self.preshared_key, @@ -360,8 +376,9 @@ impl WireguardNetworkDevice { { let res = query_as!( Self, - "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", \ - preshared_key, is_authorized, authorized_at \ + "SELECT device_id, wireguard_network_id, \ + wireguard_ips \"wireguard_ips: Vec\", \ + preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device \ WHERE device_id = $1 AND wireguard_network_id = $2", device_id, @@ -384,8 +401,9 @@ impl WireguardNetworkDevice { { let res = query_as!( Self, - "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", \ - preshared_key, is_authorized, authorized_at \ + "SELECT device_id, wireguard_network_id, \ + wireguard_ips \"wireguard_ips: Vec\", \ + preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device \ WHERE device_id = $1 ORDER BY id LIMIT 1", device_id @@ -405,8 +423,9 @@ impl WireguardNetworkDevice { { let result = query_as!( Self, - "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", \ - preshared_key, is_authorized, authorized_at \ + "SELECT device_id, wireguard_network_id, \ + wireguard_ips \"wireguard_ips: Vec\", \ + preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device WHERE device_id = $1", device_id ) @@ -429,8 +448,9 @@ impl WireguardNetworkDevice { { let res = query_as!( Self, - "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", \ - preshared_key, is_authorized, authorized_at \ + "SELECT device_id, wireguard_network_id, \ + wireguard_ips \"wireguard_ips: Vec\", \ + preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device \ WHERE wireguard_network_id = $1", network_id @@ -454,8 +474,9 @@ impl WireguardNetworkDevice { { let res = query_as!( Self, - "SELECT device_id, wireguard_network_id, wireguard_ip \"wireguard_ip: IpAddr\", \ - preshared_key, is_authorized, authorized_at \ + "SELECT device_id, wireguard_network_id, \ + wireguard_ips \"wireguard_ips: Vec\", \ + preshared_key, is_authorized, authorized_at \ FROM wireguard_network_device \ WHERE wireguard_network_id = $1 AND device_id IN \ (SELECT id FROM device WHERE user_id = $2 AND device_type = 'user'::device_type)", @@ -494,8 +515,8 @@ pub enum DeviceError { PubkeyConflict(Device, String), #[error("Database error")] DatabaseError(#[from] sqlx::Error), - #[error("Model error")] - ModelError(#[from] ModelError), + #[error(transparent)] + NetworkIpAssignmentError(#[from] NetworkAddressError), #[error("Unexpected error: {0}")] Unexpected(String), } @@ -550,15 +571,7 @@ impl Device { let allowed_ips = if network.allowed_ips.is_empty() { String::new() } else { - format!( - "AllowedIPs = {}\n", - network - .allowed_ips - .iter() - .map(IpNetwork::to_string) - .collect::>() - .join(",") - ) + format!("AllowedIPs = {}\n", network.allowed_ips.as_csv()) }; format!( @@ -572,7 +585,10 @@ impl Device { {allowed_ips}\ Endpoint = {}:{}\n\ PersistentKeepalive = 300", - wireguard_network_device.wireguard_ip, network.pubkey, network.endpoint, network.port, + wireguard_network_device.wireguard_ips.as_csv(), + network.pubkey, + network.endpoint, + network.port, ) } @@ -590,7 +606,7 @@ impl Device { d.device_type \"device_type: DeviceType\", configured \ FROM device d \ JOIN wireguard_network_device wnd ON d.id = wnd.device_id \ - WHERE wnd.wireguard_ip = $1 AND wnd.wireguard_network_id = $2", + WHERE $1 = ANY(wnd.wireguard_ips) AND wnd.wireguard_network_id = $2", IpNetwork::from(ip), network_id ) @@ -661,7 +677,7 @@ impl Device { .ok_or_else(|| DeviceError::Unexpected("Device not found in network".into()))?; let device_network_info = DeviceNetworkInfo { network_id: network.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips.clone(), preshared_key: wireguard_network_device.preshared_key.clone(), is_authorized: wireguard_network_device.is_authorized, }; @@ -672,7 +688,7 @@ impl Device { network_name: network.name.clone(), config, endpoint: format!("{}:{}", network.endpoint, network.port), - address: wireguard_network_device.wireguard_ip, + address: wireguard_network_device.wireguard_ips, allowed_ips: network.allowed_ips.clone(), pubkey: network.pubkey.clone(), dns: network.dns.clone(), @@ -686,15 +702,15 @@ impl Device { pub(crate) async fn add_to_network( &self, network: &WireguardNetwork, - ip: IpAddr, + ip: &[IpAddr], transaction: &mut PgConnection, ) -> Result<(DeviceNetworkInfo, DeviceConfig), DeviceError> { let wireguard_network_device = self - .assign_network_ip(&mut *transaction, network, ip) + .assign_network_ips(&mut *transaction, network, ip) .await?; let device_network_info = DeviceNetworkInfo { network_id: network.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips.clone(), preshared_key: wireguard_network_device.preshared_key.clone(), is_authorized: wireguard_network_device.is_authorized, }; @@ -705,7 +721,7 @@ impl Device { network_name: network.name.clone(), config, endpoint: format!("{}:{}", network.endpoint, network.port), - address: wireguard_network_device.wireguard_ip, + address: wireguard_network_device.wireguard_ips, allowed_ips: network.allowed_ips.clone(), pubkey: network.pubkey.clone(), dns: network.dns.clone(), @@ -748,12 +764,14 @@ impl Device { .await { debug!( - "Assigned IP {} for device {} (user {}) in network {network}", - wireguard_network_device.wireguard_ip, self.name, self.user_id + "Assigned IPs {} for device {} (user {}) in network {network}", + wireguard_network_device.wireguard_ips.as_csv(), + self.name, + self.user_id ); let device_network_info = DeviceNetworkInfo { network_id: network.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips.clone(), preshared_key: wireguard_network_device.preshared_key.clone(), is_authorized: wireguard_network_device.is_authorized, }; @@ -765,7 +783,7 @@ impl Device { network_name: network.name, config, endpoint: format!("{}:{}", network.endpoint, network.port), - address: wireguard_network_device.wireguard_ip, + address: wireguard_network_device.wireguard_ips, allowed_ips: network.allowed_ips, pubkey: network.pubkey, dns: network.dns, @@ -777,68 +795,118 @@ impl Device { Ok((network_info, configs)) } - // Assign IP to the device in a given network + /// Assign the next available IP address in each subnet of the network to this device. + /// + /// For every CIDR block in `network.address`, this function: + /// 1. Iterates through the block's IPs in order. + /// 2. Skips any IP that: + /// - Fails the `can_assign_ips` validation (out of range, reserved, or already in use by another device), or + /// - Appears in the optional `reserved_ips`. + /// 3. Selects the first remaining IP and records it. + /// + /// If any subnet has no valid, unassigned IP, the method returns `ModelError::CannotCreate`. + /// + /// # Parameters + /// + /// - `transaction`: Active PostgreSQL connection to check and insert assignments. + /// - `network`: The `WireguardNetwork` whose subnets will be assigned. + /// - `reserved_ips`: Optional slice of IPs that must not be assigned, even if otherwise free. + /// + /// # Returns + /// + /// - `Ok(WireguardNetworkDevice)`: A new relation linking this device to its assigned IPs across all subnets. + /// - `Err(ModelError::CannotCreate)`: If any subnet lacks an available IP. pub(crate) async fn assign_next_network_ip( &self, transaction: &mut PgConnection, network: &WireguardNetwork, reserved_ips: Option<&[IpAddr]>, ) -> Result { - if let Some(address) = network.address.first() { - let net_ip = address.ip(); - let net_network = address.network(); - let net_broadcast = address.broadcast(); - for ip in address { - if ip == net_ip || ip == net_network || ip == net_broadcast { - continue; - } - if let Some(reserved_ips) = reserved_ips { - if reserved_ips.contains(&ip) { - continue; - } - } + debug!( + "Assiging IP addresses for device: {} in network {}", + self.name, network.name + ); + let mut ips = Vec::new(); + let reserved = reserved_ips.unwrap_or_default(); - // Break loop if IP is unassigned and return network device - if Self::find_by_ip(&mut *transaction, ip, network.id) - .await? - .is_none() + // Iterate over all network addresses and assign new IP for the device in each of them + for address in &network.address { + debug!( + "Assigning address to device {} in network {} {address}", + self.name, network.name, + ); + let mut picked = None; + for ip in address { + if network + .can_assign_ips(transaction, &[ip], Some(self.id)) + .await + .is_ok() + && !reserved.contains(&ip) { - info!("Assigned IP address {ip} for device: {}", self.name); - let wireguard_network_device = - WireguardNetworkDevice::new(network.id, self.id, ip); - wireguard_network_device.insert(&mut *transaction).await?; - return Ok(wireguard_network_device); + picked = Some(ip); + break; } } + + // Return error if no address can be assigned + let ip = picked.ok_or_else(|| { + error!( + "Failed to assign address for device {} in network {address:?}", + self.name, + ); + ModelError::CannotCreate + })?; + + // Otherwise, store the IP address + debug!( + "Found assignable address {ip} for device {} in network {} {address}", + self.name, network.name, + ); + ips.push(ip); } - Err(ModelError::CannotCreate) + + // Create relation record + let wireguard_network_device = + WireguardNetworkDevice::new(network.id, self.id, ips.clone()); + wireguard_network_device.insert(&mut *transaction).await?; + + info!( + "Assigned IP addresses {ips:?} for device: {} in network {}", + self.name, network.name + ); + Ok(wireguard_network_device) } - pub(crate) async fn assign_network_ip( + /// Assigns specific IP address to the device in specified [`WireguardNetwork`]. + /// This method is currently used only for network devices. For regular user + /// devices use [`assign_next_network_ip`] method. + pub(crate) async fn assign_network_ips( &self, transaction: &mut PgConnection, network: &WireguardNetwork, - ip: IpAddr, - ) -> Result { - if let Some(network_address) = network.address.first() { - let net_ip = network_address.ip(); - let net_network = network_address.network(); - let net_broadcast = network_address.broadcast(); - if ip == net_ip || ip == net_network || ip == net_broadcast { - return Err(ModelError::CannotCreate); - } - - if Self::find_by_ip(&mut *transaction, ip, network.id) - .await? - .is_none() - { - info!("Assigned IP: {ip} for device: {}", self.name); - let wireguard_network_device = WireguardNetworkDevice::new(network.id, self.id, ip); - wireguard_network_device.insert(&mut *transaction).await?; - return Ok(wireguard_network_device); - } - } - Err(ModelError::CannotCreate) + ips: &[IpAddr], + ) -> Result { + debug!( + "Assigning IPs: {ips:?} for device: {} in network {}", + self.name, network.name + ); + // ensure assignment is valid + network + .can_assign_ips(&mut *transaction, ips, Some(self.id)) + .await + .map_err(|err| { + error!("Invalid network IP assignment: {err}"); + err + })?; + + // insert relation record + let wireguard_network_device = WireguardNetworkDevice::new(network.id, self.id, ips); + wireguard_network_device.insert(&mut *transaction).await?; + info!( + "Assigned IPs: {ips:?} for device: {} in network {}", + self.name, network.name + ); + Ok(wireguard_network_device) } /// Gets the first network of the network device @@ -966,7 +1034,7 @@ mod test { info!("Created device: {}", device.name); debug!("For user: {}", device.user_id); let wireguard_network_device = - WireguardNetworkDevice::new(network.id, device.id, ip); + WireguardNetworkDevice::new(network.id, device.id, [ip]); wireguard_network_device.insert(pool).await?; info!( "Assigned IP: {ip} for device: {name} in network: {}", @@ -1003,10 +1071,7 @@ mod test { Device::new_with_ip(&pool, user.id, "dev1".into(), "key1".into(), &network) .await .unwrap(); - assert_eq!( - wireguard_network_device.wireguard_ip.to_string(), - "10.1.1.2" - ); + assert_eq!(wireguard_network_device.wireguard_ips.as_csv(), "10.1.1.2"); let device = Device::new_with_ip(&pool, 1, "dev4".into(), "key4".into(), &network).await; assert!(device.is_err()); @@ -1128,7 +1193,7 @@ mod test { WireguardNetworkDevice::new( network.id, device4.id, - IpAddr::from_str("10.1.1.10").unwrap(), + [IpAddr::from_str("10.1.1.10").unwrap()], ) .insert(&mut *transaction) .await diff --git a/src/db/models/settings.rs b/src/db/models/settings.rs index 7235e8aada..002e8c13fd 100644 --- a/src/db/models/settings.rs +++ b/src/db/models/settings.rs @@ -377,7 +377,7 @@ impl From for SettingsEssentials { mod defaults { pub static WELCOME_MESSAGE: &str = "Dear {{ first_name }} {{ last_name }}, -By completing the enrollment process, you now have now access to all company systems. +By completing the enrollment process, you now have access to all company systems. Your login to all systems is: {{ username }} diff --git a/src/db/models/wireguard.rs b/src/db/models/wireguard.rs index 5493309020..ec7eafc615 100644 --- a/src/db/models/wireguard.rs +++ b/src/db/models/wireguard.rs @@ -1,6 +1,7 @@ use std::{ collections::HashMap, fmt, + iter::zip, net::{IpAddr, Ipv4Addr}, }; @@ -36,6 +37,7 @@ use crate::{ GatewayState, }, wg_config::ImportedDevice, + AsCsv, }; pub const DEFAULT_KEEPALIVE_INTERVAL: i32 = 25; @@ -47,7 +49,7 @@ pub struct MappedDevice { pub user_id: Id, pub name: String, pub wireguard_pubkey: String, - pub wireguard_ip: IpAddr, + pub wireguard_ips: Vec, } pub const WIREGUARD_MAX_HANDSHAKE: TimeDelta = TimeDelta::minutes(8); @@ -169,6 +171,24 @@ pub enum WireguardNetworkError { FirewallError(#[from] FirewallError), } +#[derive(Debug, Error)] +pub enum NetworkAddressError { + #[error( + "Location {0} has no network that could contain IP address {1}, available networks: {2:?}" + )] + NoContainingNetwork(String, IpAddr, Vec), + #[error("IP address {1} is reserved for gateway in location {0}")] + ReservedForGateway(String, IpAddr), + #[error("IP address {1} is network broadcast address in location {0}")] + IsBroadcastAddress(String, IpAddr), + #[error("IP address {1} is network address in location {0}")] + IsNetworkAddress(String, IpAddr), + #[error("IP address {1} is already assigned in location {0}")] + AddressAlreadyAssigned(String, IpAddr), + #[error(transparent)] + DbError(#[from] sqlx::Error), +} + impl WireguardNetwork { pub fn new( name: String, @@ -349,7 +369,6 @@ impl WireguardNetwork { .await? } }; - Ok(devices) } @@ -444,19 +463,16 @@ impl WireguardNetwork { } } - pub async fn add_network_device_to_network( - &self, - transaction: &mut PgConnection, - device: &WireguardNetworkDevice, - ip: IpAddr, - ) -> Result { - info!( - "Adding network device {} with IP {ip} to network {self}", - device.device_id - ); - let wireguard_network_device = WireguardNetworkDevice::new(self.id, device.device_id, ip); - wireguard_network_device.insert(&mut *transaction).await?; - Ok(wireguard_network_device) + /// Checks if all device addresses are contained in at least one of the network addresses + pub fn contains_all(&self, addresses: &[IpAddr]) -> bool { + addresses + .iter() + .all(|addr| self.address.iter().any(|net| net.contains(*addr))) + } + + /// Finds [`IpNetwork`] containing given [`IpAddr`] + pub fn get_containing_network(&self, addr: IpAddr) -> Option { + self.address.iter().find(|net| net.contains(addr)).copied() } /// Works out which devices need to be added, removed, or readdressed @@ -468,14 +484,16 @@ impl WireguardNetwork { currently_configured_devices: Vec, reserved_ips: Option<&[IpAddr]>, ) -> Result, WireguardNetworkError> { - // loop through current device configurations; remove no longer allowed, readdress when necessary; remove processed entry from all devices list + // Loop through current device configurations; remove no longer allowed, readdress when necessary; remove processed entry from all devices list // initial list should now contain only devices to be added let mut events: Vec = Vec::new(); for device_network_config in currently_configured_devices { - // device is allowed and an IP was already assigned + // Device is allowed and an IP was already assigned if let Some(device) = allowed_devices.remove(&device_network_config.device_id) { - // network address changed and IP needs to be updated - if !self.address[0].contains(device_network_config.wireguard_ip) { + // Network address changed and IP addresses need to be updated + if !self.contains_all(&device_network_config.wireguard_ips) + || self.address.len() != device_network_config.wireguard_ips.len() + { let wireguard_network_device = device .assign_next_network_ip(&mut *transaction, self, reserved_ips) .await?; @@ -483,13 +501,13 @@ impl WireguardNetwork { device, network_info: vec![DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }], })); } - // device is no longer allowed + // Device is no longer allowed } else { debug!( "Device {} no longer allowed, removing network config for {self}", @@ -503,7 +521,7 @@ impl WireguardNetwork { device, network_info: vec![DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: device_network_config.wireguard_ip, + device_wireguard_ips: device_network_config.wireguard_ips, preshared_key: device_network_config.preshared_key, is_authorized: device_network_config.is_authorized, }], @@ -516,7 +534,7 @@ impl WireguardNetwork { } } - // add configs for new allowed devices + // Add configs for new allowed devices for device in allowed_devices.into_values() { let wireguard_network_device = device .assign_next_network_ip(&mut *transaction, self, reserved_ips) @@ -525,7 +543,7 @@ impl WireguardNetwork { device, network_info: vec![DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }], @@ -645,13 +663,13 @@ impl WireguardNetwork { match allowed_devices.get(&existing_device.id) { Some(_) => { info!( - "Device with pubkey {} exists already, assigning IP {} for new network: {self}", - existing_device.wireguard_pubkey, imported_device.wireguard_ip + "Device with pubkey {} exists already, assigning IPs {} for new network: {self}", + existing_device.wireguard_pubkey, imported_device.wireguard_ips.as_csv() ); let wireguard_network_device = WireguardNetworkDevice::new( self.id, existing_device.id, - imported_device.wireguard_ip, + imported_device.wireguard_ips, ); wireguard_network_device.insert(&mut *transaction).await?; // store ID of device with already generated config @@ -661,7 +679,7 @@ impl WireguardNetwork { device: existing_device, network_info: vec![DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }], @@ -733,12 +751,15 @@ impl WireguardNetwork { let mut network_info = Vec::new(); match &allowed_groups { None => { - let wireguard_network_device = - WireguardNetworkDevice::new(self.id, device.id, mapped_device.wireguard_ip); + let wireguard_network_device = WireguardNetworkDevice::new( + self.id, + device.id, + mapped_device.wireguard_ips.clone(), + ); wireguard_network_device.insert(&mut *transaction).await?; network_info.push(DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }); @@ -750,12 +771,12 @@ impl WireguardNetwork { let wireguard_network_device = WireguardNetworkDevice::new( self.id, device.id, - mapped_device.wireguard_ip, + mapped_device.wireguard_ips.clone(), ); wireguard_network_device.insert(&mut *transaction).await?; network_info.push(DeviceNetworkInfo { network_id: self.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }); @@ -916,13 +937,16 @@ impl WireguardNetwork { let mut result = Vec::new(); for device in devices { let latest_stats = WireguardPeerStats::fetch_latest(conn, device.id, self.id).await?; + let wireguard_ips = if let Some(stats) = &latest_stats { + stats.trim_allowed_ips() + } else { + Vec::new() + }; result.push(WireguardDeviceStatsRow { id: device.id, user_id: device.user_id, name: device.name.clone(), - wireguard_ip: latest_stats - .as_ref() - .and_then(WireguardPeerStats::trim_allowed_ips), + wireguard_ips, public_ip: latest_stats .as_ref() .and_then(WireguardPeerStats::endpoint_without_port), @@ -1113,6 +1137,83 @@ impl WireguardNetwork { .fetch_all(executor) .await } + + /// Determine if a set of IP addresses can be safely assigned on this network. + /// + /// This method runs three categories of checks in sequence: + /// 1. **Range validation** + /// Every address in `ip_addrs` must lie within one of the network's CIDR. + /// Fails with `NoContainingNetwork` if any IP falls outside. + /// + /// 2. **Reserved‐address checks** + /// - Rejects the network address itself (`IsNetworkAddress`). + /// - Rejects the broadcast address (`IsBroadcastAddress`). + /// - Rejects the gateway/reserved host address (`ReservedForGateway`). + /// + /// 3. **Conflict detection** + /// Queries the database to see if an IP is already claimed. + /// - If `device_id` is `Some(id)`, any IP already bound to that same device is exempt. + /// - Otherwise, or if bound to a different device, fails with `AddressAlreadyAssigned`. + /// + /// # Parameters + /// + /// - `transaction`: Open PostgreSQL transaction to check existing assignments. + /// - `ip_addrs`: Candidate `IpAddr`s to validate. + /// - `device_id`: If `Some(id)`, IPs already assigned to this device are treated as free; + /// if `None`, all existing assignments count as conflicts. + /// + /// # Returns + /// + /// - `Ok(())`: All addresses passed every check. + /// - `Err(NetworkIpAssignmentError)`: The first failing check. + pub(crate) async fn can_assign_ips( + &self, + transaction: &mut PgConnection, + ip_addrs: &[IpAddr], + device_id: Option, + ) -> Result<(), NetworkAddressError> { + // Ensure the network contains all provided IP addresses + let networks = ip_addrs + .iter() + .map(|ip| self.get_containing_network(*ip).ok_or(*ip)) + .collect::, IpAddr>>() + .map_err(|ip| { + NetworkAddressError::NoContainingNetwork( + self.name.clone(), + ip, + self.address.clone(), + ) + })?; + for (ip, network_address) in zip(ip_addrs, networks) { + if *ip == network_address.network() { + return Err(NetworkAddressError::IsNetworkAddress( + self.name.clone(), + *ip, + )); + } else if *ip == network_address.broadcast() { + return Err(NetworkAddressError::IsBroadcastAddress( + self.name.clone(), + *ip, + )); + } else if *ip == network_address.ip() { + return Err(NetworkAddressError::ReservedForGateway( + self.name.clone(), + *ip, + )); + } + + // Make sure the IP address is not assigned + let device = Device::find_by_ip(&mut *transaction, *ip, self.id).await?; + if device.is_some_and(|device| device_id != Some(device.id)) { + return Err(NetworkAddressError::AddressAlreadyAssigned( + self.name.clone(), + *ip, + )); + } + } + + Ok(()) + } } // [`IpNetwork`] does not implement [`Default`] @@ -1168,7 +1269,7 @@ pub struct WireguardDeviceStatsRow { pub stats: Vec, pub user_id: Id, pub name: String, - pub wireguard_ip: Option, + pub wireguard_ips: Vec, pub public_ip: Option, pub connected_at: Option, } @@ -1202,7 +1303,10 @@ pub struct WireguardNetworkStats { #[cfg(test)] mod test { + use std::str::FromStr; + use chrono::{SubsecRound, TimeDelta}; + use matches::assert_matches; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::*; @@ -1211,7 +1315,6 @@ mod test { #[sqlx::test] async fn test_connected_at_reconnection(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1276,7 +1379,6 @@ mod test { #[sqlx::test] async fn test_connected_at_always_connected(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1339,7 +1441,6 @@ mod test { #[sqlx::test] async fn test_get_allowed_devices_for_user(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1432,7 +1533,6 @@ mod test { options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1515,7 +1615,6 @@ mod test { #[sqlx::test] async fn test_sync_allowed_devices_for_user(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1628,7 +1727,6 @@ mod test { options: PgConnectOptions, ) { let pool = setup_pool(options).await; - let mut network = WireguardNetwork::default(); network.try_set_address("10.1.1.1/29").unwrap(); let network = network.save(&pool).await.unwrap(); @@ -1766,4 +1864,273 @@ mod test { transaction.commit().await.unwrap(); } + + #[sqlx::test] + async fn test_can_assign_ips(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::new( + "network".to_string(), + vec![IpNetwork::from_str("10.1.1.1/24").unwrap()], + 50051, + String::new(), + None, + vec![IpNetwork::from_str("10.1.1.0/24").unwrap()], + false, + 300, + 300, + false, + false, + ) + .unwrap() + .save(&pool) + .await + .unwrap(); + + // assign free address + let addrs = vec![IpAddr::from_str("10.1.1.2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Ok(()) + ); + + // assign multiple free addresses + let addrs = vec![ + IpAddr::from_str("10.1.1.2").unwrap(), + IpAddr::from_str("10.1.1.3").unwrap(), + ]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Ok(()) + ); + + // try to assign address from another network + let addrs = vec![IpAddr::from_str("10.2.1.2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::NoContainingNetwork(..)) + ); + + // try to assign already assigned address + let user = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".to_string(), + String::new(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + WireguardNetworkDevice::new( + network.id, + device.id, + vec![IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + let addrs = vec![IpAddr::from_str("10.1.1.2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::AddressAlreadyAssigned(..)) + ); + + // assign with exception for the device + let addrs = vec![IpAddr::from_str("10.1.1.2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, Some(device.id)) + .await, + Ok(()) + ); + + // try to assign gateway address + let addrs = vec![IpAddr::from_str("10.1.1.1").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::ReservedForGateway(..)) + ); + + // try to assign network address + let addrs = vec![IpAddr::from_str("10.1.1.0").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::IsNetworkAddress(..)) + ); + + // try to assign broadcast address + let addrs = vec![IpAddr::from_str("10.1.1.255").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::IsBroadcastAddress(..)) + ); + } + + #[sqlx::test] + async fn test_can_assign_ips_multiple_addresses(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::new( + "network".to_string(), + vec![ + IpNetwork::from_str("10.1.1.1/24").unwrap(), + IpNetwork::from_str("fc00::1/112").unwrap(), + ], + 50051, + String::new(), + None, + vec![IpNetwork::from_str("10.1.1.0/24").unwrap()], + false, + 300, + 300, + false, + false, + ) + .unwrap() + .save(&pool) + .await + .unwrap(); + + // assign free addresses + let addrs = vec![ + IpAddr::from_str("10.1.1.2").unwrap(), + IpAddr::from_str("fc00::2").unwrap(), + ]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Ok(()) + ); + + // assign multiple free addresses + let addrs = vec![ + IpAddr::from_str("10.1.1.2").unwrap(), + IpAddr::from_str("10.1.1.3").unwrap(), + IpAddr::from_str("fc00::2").unwrap(), + IpAddr::from_str("fc00::3").unwrap(), + ]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Ok(()) + ); + + // try to assign address from another network + let addrs = vec![IpAddr::from_str("fa::2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::NoContainingNetwork(..)) + ); + + // try to assign already assigned address + let user = User::new( + "hpotter", + Some("pass123"), + "Potter", + "Harry", + "h.potter@hogwart.edu.uk", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".to_string(), + String::new(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + WireguardNetworkDevice::new( + network.id, + device.id, + vec![ + IpAddr::from_str("10.1.1.2").unwrap(), + IpAddr::from_str("fc00::2").unwrap(), + ], + ) + .insert(&pool) + .await + .unwrap(); + let addrs = vec![IpAddr::from_str("fc00::2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::AddressAlreadyAssigned(..)) + ); + + // assign with exception for the device + let addrs = vec![IpAddr::from_str("fc00::2").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, Some(device.id)) + .await, + Ok(()) + ); + + // try to assign gateway address + let addrs = vec![IpAddr::from_str("fc00::1").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::ReservedForGateway(..)) + ); + + // try to assign network address + let addrs = vec![IpAddr::from_str("fc00::0").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::IsNetworkAddress(..)) + ); + + // try to assign broadcast address + let addrs = vec![IpAddr::from_str("fc00::ffff").unwrap()]; + assert_matches!( + network + .can_assign_ips(&mut pool.acquire().await.unwrap(), &addrs, None) + .await, + Err(NetworkAddressError::IsBroadcastAddress(..)) + ); + } } diff --git a/src/db/models/wireguard_peer_stats.rs b/src/db/models/wireguard_peer_stats.rs index 8c3878a663..b3b6fed199 100644 --- a/src/db/models/wireguard_peer_stats.rs +++ b/src/db/models/wireguard_peer_stats.rs @@ -2,6 +2,7 @@ use std::time::Duration; use chrono::{DateTime, NaiveDateTime, TimeDelta, Utc}; use humantime::format_duration; +use ipnetwork::IpNetwork; use model_derive::Model; use sqlx::{query, query_as, query_scalar, PgExecutor, PgPool}; @@ -144,10 +145,69 @@ impl WireguardPeerStats { }) } - /// Trim `allowed_ips` returning the first one without CIDR. - pub(crate) fn trim_allowed_ips(&self) -> Option { - self.allowed_ips - .as_ref() - .and_then(|ips| Some(ips.split_once('/')?.0.to_owned())) + /// Returns a `Vec` of `allowed_ips` without a CIDR mask. + /// Non-parsable addresses are omitted. + pub(crate) fn trim_allowed_ips(&self) -> Vec { + let Some(allowed_ips) = &self.allowed_ips else { + return Vec::new(); + }; + allowed_ips + .split(',') + .filter_map(|addr| Some(addr.trim().parse::().ok()?.ip().to_string())) + .collect() + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_trim_allowed_ips() { + let mut stats = WireguardPeerStats { + id: 1, + device_id: 1, + collected_at: Utc::now().naive_utc(), + network: 1, + endpoint: None, + upload: 100, + download: 100, + latest_handshake: Utc::now().naive_utc(), + allowed_ips: None, + }; + assert!(stats.trim_allowed_ips().is_empty()); + + stats.allowed_ips = Some("10.1.1.1".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1"]); + + stats.allowed_ips = Some("10.1.1.1/24".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1"]); + + stats.allowed_ips = Some("10.1.1.1/24, 10.1.1.2".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1", "10.1.1.2"]); + + stats.allowed_ips = Some("10.1.1.1/24, 10.1.1.2/24".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1", "10.1.1.2"]); + + stats.allowed_ips = Some("fc00::1".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["fc00::1"]); + + stats.allowed_ips = Some("fc00::1/112".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["fc00::1"]); + + stats.allowed_ips = Some("fc00::1/112,fc00::2".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["fc00::1", "fc00::2"]); + + stats.allowed_ips = Some("fc00::1/112,fc00::2/112".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["fc00::1", "fc00::2"]); + + stats.allowed_ips = Some("10.1.1.1, fc00::1".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1", "fc00::1"]); + + stats.allowed_ips = Some("10.1.1.1/24, fc00::1/112".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["10.1.1.1", "fc00::1"]); + + stats.allowed_ips = Some("nonparsable, fc00::1/112".to_string()); + assert_eq!(stats.trim_allowed_ips(), vec!["fc00::1"]); } } diff --git a/src/enterprise/firewall.rs b/src/enterprise/firewall.rs index 7ddc94cd5b..efb44b17b5 100644 --- a/src/enterprise/firewall.rs +++ b/src/enterprise/firewall.rs @@ -36,14 +36,18 @@ pub enum FirewallError { /// end. This way we can avoid conflicts when some ACLs are overlapping. pub async fn generate_firewall_rules_from_acls( location_id: Id, - ip_version: IpVersion, acl_rules: Vec>, conn: &mut PgConnection, ) -> Result, FirewallError> { - debug!("Generating firewall rules for location {location_id} with IP version {ip_version:?}"); + debug!("Generating firewall rules for location {location_id}"); // initialize empty rules Vec let mut allow_rules = Vec::new(); let mut deny_rules = Vec::new(); + let location = WireguardNetwork::find_by_id(&mut *conn, location_id) + .await? + .ok_or(ModelError::NotFound)?; + let has_ipv4_addresses = location.address.iter().any(|ip| ip.is_ipv4()); + let has_ipv6_addresses = location.address.iter().any(|ip| ip.is_ipv6()); // convert each ACL into a corresponding `FirewallRule`s for acl in acl_rules { @@ -59,6 +63,11 @@ pub async fn generate_firewall_rules_from_acls( // get network IPs for devices belonging to those users let user_device_ips = get_user_device_ips(&users, location_id, &mut *conn).await?; + // separate IPv4 and IPv6 user-device addresses + let user_device_ips = user_device_ips + .iter() + .flatten() + .partition(|ip| ip.is_ipv4()); // fetch allowed network devices let allowed_network_devices = acl.get_all_allowed_devices(&mut *conn, location_id).await?; @@ -71,9 +80,17 @@ pub async fn generate_firewall_rules_from_acls( get_source_network_devices(allowed_network_devices, denied_network_devices); let network_device_ips = get_network_device_ips(&network_devices, location_id, &mut *conn).await?; + // separate IPv4 and IPv6 network-device addresses + let network_device_ips = network_device_ips + .iter() + .flatten() + .partition(|ip| ip.is_ipv4()); // convert device IPs into source addresses for a firewall rule - let source_addrs = get_source_addrs(user_device_ips, network_device_ips, ip_version); + let ipv4_source_addrs = + get_source_addrs(user_device_ips.0, network_device_ips.0, IpVersion::Ipv4); + let ipv6_source_addrs = + get_source_addrs(user_device_ips.1, network_device_ips.1, IpVersion::Ipv6); // extract destination parameters from ACL rule let AclRuleInfo { @@ -112,49 +129,49 @@ pub async fn generate_firewall_rules_from_acls( } // prepare destination addresses - let destination_addrs = - process_destination_addrs(destination, destination_ranges, ip_version); + let destination_addrs = process_destination_addrs(destination, destination_ranges); // prepare destination ports let destination_ports = merge_port_ranges(ports); - // remove duplicates protocol entries + // remove duplicate protocol entries protocols.sort(); protocols.dedup(); - // check if source addrs list is empty - if source_addrs.is_empty() { - debug!("Source address list is empty. Skipping generating the ALLOW rule for this ACL"); - } else { - // prepare ALLOW rule for this ACL - let allow_rule = FirewallRule { - id: acl.id, - source_addrs: source_addrs.clone(), - destination_addrs: destination_addrs.clone(), - destination_ports, - protocols, - verdict: i32::from(FirewallPolicy::Allow), - comment: Some(format!("ACL {} - {} ALLOW", acl.id, acl.name)), - }; - debug!("ALLOW rule generated from ACL: {allow_rule:?}"); - allow_rules.push(allow_rule); - }; - - // prepare DENY rule for this ACL - // - // it should specify only the destination addrs to block all remaining traffic - let deny_rule = FirewallRule { - id: acl.id, - source_addrs: Vec::new(), - destination_addrs, - destination_ports: Vec::new(), - protocols: Vec::new(), - verdict: i32::from(FirewallPolicy::Deny), - comment: Some(format!("ACL {} - {} DENY", acl.id, acl.name)), - }; - debug!("DENY rule generated from ACL: {deny_rule:?}"); - deny_rules.push(deny_rule); + let comment = format!("ACL {} - {}", acl.id, acl.name); + if has_ipv4_addresses { + // create IPv4 rules + let ipv4_rules = create_rules( + acl.id, + IpVersion::Ipv4, + &ipv4_source_addrs, + &destination_addrs.0, + &destination_ports, + &protocols, + &comment, + ); + if let Some(rule) = ipv4_rules.0 { + allow_rules.push(rule) + } + deny_rules.push(ipv4_rules.1); + } + if has_ipv6_addresses { + // create IPv6 rules + let ipv6_rules = create_rules( + acl.id, + IpVersion::Ipv6, + &ipv6_source_addrs, + &destination_addrs.1, + &destination_ports, + &protocols, + &comment, + ); + if let Some(rule) = ipv6_rules.0 { + allow_rules.push(rule) + } + deny_rules.push(ipv6_rules.1); + } // process destination aliases by creating a dedicated set of rules for each of them if !destination_aliases.is_empty() { debug!( @@ -169,11 +186,8 @@ pub async fn generate_firewall_rules_from_acls( let alias_destination_ranges = alias.get_destination_ranges(&mut *conn).await?; // combine destination addrs - let destination_addrs = process_alias_destination_addrs( - alias.destination, - alias_destination_ranges, - ip_version, - ); + let destination_addrs = + process_alias_destination_addrs(alias.destination, alias_destination_ranges); // process alias ports let alias_ports = alias @@ -183,50 +197,48 @@ pub async fn generate_firewall_rules_from_acls( .collect::>(); let destination_ports = merge_port_ranges(alias_ports); - // remove duplicates protocol entries + // remove duplicate protocol entries let mut protocols = alias.protocols; protocols.sort(); protocols.dedup(); - if source_addrs.is_empty() { - debug!( - "Source address list is empty. Skipping generating the ALLOW rule for this alias" + let comment = format!( + "ACL {} - {}, ALIAS {} - {}", + acl.id, acl.name, alias.id, alias.name + ); + if has_ipv4_addresses { + // create IPv4 rules + let ipv4_rules = create_rules( + alias.id, + IpVersion::Ipv4, + &ipv4_source_addrs, + &destination_addrs.0, + &destination_ports, + &protocols, + &comment, ); - } else { - // prepare ALLOW rule for this alias - let alias_allow_rule = FirewallRule { - id: acl.id, - source_addrs: source_addrs.clone(), - destination_addrs: destination_addrs.clone(), - destination_ports, - protocols, - verdict: i32::from(FirewallPolicy::Allow), - comment: Some(format!( - "ACL {} - {}, ALIAS {} - {} ALLOW", - acl.id, acl.name, alias.id, alias.name - )), - }; - debug!("ALLOW rule generated from ACL: {alias_allow_rule:?}"); - allow_rules.push(alias_allow_rule); - }; + if let Some(rule) = ipv4_rules.0 { + allow_rules.push(rule) + } + deny_rules.push(ipv4_rules.1); + } - // prepare DENY rule for this ACL - // - // it should specify only the destination addrs to block all remaining traffic - let alias_deny_rule = FirewallRule { - id: acl.id, - source_addrs: Vec::new(), - destination_addrs, - destination_ports: Vec::new(), - protocols: Vec::new(), - verdict: i32::from(FirewallPolicy::Deny), - comment: Some(format!( - "ACL {} - {}, ALIAS {} - {} DENY", - acl.id, acl.name, alias.id, alias.name - )), - }; - debug!("DENY rule generated from ACL: {alias_deny_rule:?}"); - deny_rules.push(alias_deny_rule); + if has_ipv6_addresses { + // create IPv6 rules + let ipv6_rules = create_rules( + alias.id, + IpVersion::Ipv6, + &ipv6_source_addrs, + &destination_addrs.1, + &destination_ports, + &protocols, + &comment, + ); + if let Some(rule) = ipv6_rules.0 { + allow_rules.push(rule) + } + deny_rules.push(ipv6_rules.1); + } } } @@ -234,6 +246,57 @@ pub async fn generate_firewall_rules_from_acls( Ok(allow_rules.into_iter().chain(deny_rules).collect()) } +/// Creates ALLOW and DENY rules for given set of source, destination +/// addresses, ports and protocols. The DENY rule should block all +/// remaining traffic to the destination from sources other than specified. +/// +/// Returs a 2-tuple where the first field is an `Option` with the ALLOW +/// rule if it should be created and the second field is the DENY rule. +fn create_rules( + id: Id, + ip_version: IpVersion, + source_addrs: &[IpAddress], + destination_addrs: &[IpAddress], + destination_ports: &[Port], + protocols: &[i32], + comment: &str, +) -> (Option, FirewallRule) { + let ip_version = i32::from(ip_version); + let allow = if source_addrs.is_empty() { + debug!("Source address list is empty. Skipping generating the ALLOW rule for this ACL"); + None + } else { + // prepare ALLOW rule + let rule = FirewallRule { + id, + source_addrs: source_addrs.to_vec(), + destination_addrs: destination_addrs.to_vec(), + destination_ports: destination_ports.to_vec(), + protocols: protocols.to_vec(), + verdict: i32::from(FirewallPolicy::Allow), + comment: Some(format!("{comment} ALLOW")), + ip_version, + }; + debug!("ALLOW rule generated from ACL: {rule:?}"); + Some(rule) + }; + // prepare DENY rule + // it should specify only the destination addrs to block all remaining traffic + let deny = FirewallRule { + id, + source_addrs: Vec::new(), + destination_addrs: destination_addrs.to_vec(), + destination_ports: Vec::new(), + protocols: Vec::new(), + verdict: i32::from(FirewallPolicy::Deny), + comment: Some(format!("{comment} DENY")), + ip_version, + }; + debug!("DENY rule generated from ACL: {deny:?}"); + + (allow, deny) +} + /// Prepares a list of all relevant users whose device IPs we'll need to prepare /// source config for a firewall rule. /// @@ -248,18 +311,18 @@ fn get_source_users(allowed_users: Vec>, denied_users: Vec>) - } /// Fetches all IPs of devices belonging to specified users within a given location's VPN subnet. -// We specifically only fetch user devices since network devices are handled separately. +/// We specifically only fetch user devices since network devices are handled separately. async fn get_user_device_ips<'e, E: sqlx::PgExecutor<'e>>( users: &[User], location_id: Id, executor: E, -) -> Result, SqlxError> { - // prepeare a list of user IDs +) -> Result>, SqlxError> { + // prepare a list of user IDs let user_ids: Vec = users.iter().map(|user| user.id).collect(); // fetch network IPs query_scalar!( - "SELECT wireguard_ip \"wireguard_ip: IpAddr\" \ + "SELECT wireguard_ips \"wireguard_ips: Vec\" \ FROM wireguard_network_device wnd \ JOIN device d ON d.id = wnd.device_id \ WHERE wnd.wireguard_network_id = $1 AND d.device_type = 'user'::device_type AND d.user_id = ANY($2)", @@ -291,13 +354,13 @@ async fn get_network_device_ips( network_devices: &[Device], location_id: Id, conn: &mut PgConnection, -) -> Result, SqlxError> { +) -> Result>, SqlxError> { // prepare a list of IDs let network_device_ids: Vec = network_devices.iter().map(|device| device.id).collect(); // fetch network IPs query_scalar!( - "SELECT wireguard_ip \"wireguard_ip: IpAddr\" \ + "SELECT wireguard_ips \"wireguard_ips: Vec\" \ FROM wireguard_network_device wnd \ WHERE wnd.wireguard_network_id = $1 AND wnd.device_id = ANY($2)", location_id, @@ -346,25 +409,25 @@ fn get_source_addrs( /// into the correct format for a firewall rule. This includes: /// - combining all addr lists /// - converting to gRPC IpAddress struct -/// - filtering out incompatible IP version /// - merging into the smallest possible list of non-overlapping ranges, /// subnets and addresses +/// +/// Returs a 2-tuple of `Vec` with all IPv4 addresses in the +/// first field and IPv6 addresses in the second. fn process_destination_addrs( destination_ips: Vec, destination_ranges: Vec>, - ip_version: IpVersion, -) -> Vec { - // filter out destinations with incompatible IP version and convert to intermediate - // range representation for merging - let destination_iterator = destination_ips.iter().filter_map(|dst| match ip_version { - IpVersion::Ipv4 => { - if dst.is_ipv4() { - Some(ip_to_range(dst.network(), dst.broadcast())) - } else { - None - } - } - IpVersion::Ipv6 => { +) -> (Vec, Vec) { + // separate ipv4 and ipv6 addresses and convert to intermediate range + // representation for merging + let ipv4_destination_iterator = destination_ips + .iter() + .filter(|dst| dst.is_ipv4()) + .map(|dst| ip_to_range(dst.network(), dst.broadcast())); + let ipv6_destination_iterator = destination_ips + .iter() + .filter(|dst| dst.is_ipv6()) + .filter_map(|dst| { if let IpNetwork::V6(subnet) = dst { let range_start = subnet.network().into(); let range_end = get_last_ip_in_v6_subnet(subnet); @@ -372,103 +435,88 @@ fn process_destination_addrs( } else { None } - } - }); + }); - // filter out destination ranges with incompatible IP version and convert to intermediate - // range representation for merging - let destination_range_iterator = destination_ranges + // the same for ranges + let ipv4_destination_range_iterator = destination_ranges .iter() - .filter_map(|dst| match ip_version { - IpVersion::Ipv4 => { - if dst.start.is_ipv4() && dst.end.is_ipv4() { - Some(ip_to_range(dst.start, dst.end)) - } else { - None - } - } - IpVersion::Ipv6 => { - if dst.start.is_ipv6() && dst.end.is_ipv6() { - Some(ip_to_range(dst.start, dst.end)) - } else { - None - } - } - }); + .filter(|dst| dst.start.is_ipv4() && dst.end.is_ipv4()) + .map(|dst| (ip_to_range(dst.start, dst.end))); + let ipv6_destination_range_iterator = destination_ranges + .iter() + .filter(|dst| dst.start.is_ipv6() && dst.end.is_ipv6()) + .map(|dst| (ip_to_range(dst.start, dst.end))); - // combine both iterators to return a single list - let destination_addrs = destination_iterator - .chain(destination_range_iterator) + // combine iterators + let ipv4_destination_addrs = ipv4_destination_iterator + .chain(ipv4_destination_range_iterator) + .collect(); + let ipv6_destination_addrs = ipv6_destination_iterator + .chain(ipv6_destination_range_iterator) .collect(); // merge address ranges into non-overlapping elements - merge_addrs(destination_addrs) + ( + merge_addrs(ipv4_destination_addrs), + merge_addrs(ipv6_destination_addrs), + ) } /// Convert destination networks and ranges configured in an ACL alias /// into the correct format for a firewall rule. This includes: /// - combining all addr lists /// - converting to gRPC IpAddress struct -/// - filtering out incompatible IP version /// - merging into the smallest possible list of non-overlapping ranges, /// subnets and addresses +/// +/// Returs a 2-tuple of `Vec` with all IPv4 addresses in the +/// first field and IPv6 addresses in the second. fn process_alias_destination_addrs( alias_destination_ips: Vec, alias_destination_ranges: Vec>, - ip_version: IpVersion, -) -> Vec { - // filter out destinations with incompatible IP version and convert to intermediate - // range representation for merging - let destination_iterator = alias_destination_ips +) -> (Vec, Vec) { + // separate ipv4 and ipv6 addresses and convert to intermediate range + // representation for merging + let ipv4_destination_iterator = alias_destination_ips .iter() - .filter_map(|dst| match ip_version { - IpVersion::Ipv4 => { - if dst.is_ipv4() { - Some(ip_to_range(dst.network(), dst.broadcast())) - } else { - None - } - } - IpVersion::Ipv6 => { - if let IpNetwork::V6(subnet) = dst { - let range_start = subnet.network().into(); - let range_end = get_last_ip_in_v6_subnet(subnet); - Some(ip_to_range(range_start, range_end)) - } else { - None - } + .filter(|dst| dst.is_ipv4()) + .map(|dst| ip_to_range(dst.network(), dst.broadcast())); + let ipv6_destination_iterator = alias_destination_ips + .iter() + .filter(|dst| dst.is_ipv6()) + .filter_map(|dst| { + if let IpNetwork::V6(subnet) = dst { + let range_start = subnet.network().into(); + let range_end = get_last_ip_in_v6_subnet(subnet); + Some(ip_to_range(range_start, range_end)) + } else { + None } }); - // filter out destination ranges with incompatible IP version and convert to intermediate - // range representation for merging - let alias_destination_range_iterator = - alias_destination_ranges - .iter() - .filter_map(|dst| match ip_version { - IpVersion::Ipv4 => { - if dst.start.is_ipv4() && dst.end.is_ipv4() { - Some(ip_to_range(dst.start, dst.end)) - } else { - None - } - } - IpVersion::Ipv6 => { - if dst.start.is_ipv6() && dst.end.is_ipv6() { - Some(ip_to_range(dst.start, dst.end)) - } else { - None - } - } - }); + // the same for ranges + let ipv4_destination_range_iterator = alias_destination_ranges + .iter() + .filter(|dst| dst.start.is_ipv4() && dst.end.is_ipv4()) + .map(|dst| ip_to_range(dst.start, dst.end)); + let ipv6_destination_range_iterator = alias_destination_ranges + .iter() + .filter(|dst| dst.start.is_ipv6() && dst.end.is_ipv6()) + .map(|dst| ip_to_range(dst.start, dst.end)); - // combine both iterators to return a single list - let destination_addrs = destination_iterator - .chain(alias_destination_range_iterator) + // combine iterators + let ipv4_destination_addrs = ipv4_destination_iterator + .chain(ipv4_destination_range_iterator) + .collect(); + let ipv6_destination_addrs = ipv6_destination_iterator + .chain(ipv6_destination_range_iterator) .collect(); // merge address ranges into non-overlapping elements - merge_addrs(destination_addrs) + ( + merge_addrs(ipv4_destination_addrs), + merge_addrs(ipv6_destination_addrs), + ) } fn ip_to_range(first_ip: IpAddr, last_ip: IpAddr) -> Range { @@ -720,19 +768,13 @@ impl WireguardNetwork { // fetch all active ACLs for location let location_acls = self.get_active_acl_rules(&mut *conn).await?; - // determine IP version based on location subnet - let ip_version = self.get_ip_version(); - let default_policy = match self.acl_default_allow { true => FirewallPolicy::Allow, false => FirewallPolicy::Deny, }; let firewall_rules = - generate_firewall_rules_from_acls(self.id, ip_version, location_acls, &mut *conn) - .await?; - + generate_firewall_rules_from_acls(self.id, location_acls, &mut *conn).await?; let firewall_config = FirewallConfig { - ip_version: ip_version.into(), default_policy: default_policy.into(), rules: firewall_rules, }; @@ -740,31 +782,14 @@ impl WireguardNetwork { debug!("Firewall config generated for location {self}: {firewall_config:?}"); Ok(Some(firewall_config)) } - - fn get_ip_version(&self) -> IpVersion { - // get the subnet from which device IPs are being assigned - // by default only the first configured subnet is being used - let vpn_subnet = self - .address - .first() - .expect("WireguardNetwork must have an address"); - - let ip_version = match vpn_subnet { - IpNetwork::V4(_ipv4_network) => IpVersion::Ipv4, - IpNetwork::V6(_ipv6_network) => IpVersion::Ipv6, - }; - debug!("VPN subnet {vpn_subnet:?} for location {self} has IP version {ip_version:?}"); - - ip_version - } } #[cfg(test)] mod test { use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; - use chrono::NaiveDateTime; - use ipnetwork::Ipv6Network; + use chrono::{DateTime, NaiveDateTime}; + use ipnetwork::{IpNetwork, Ipv6Network}; use rand::{thread_rng, Rng}; use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, @@ -975,11 +1000,10 @@ mod test { }, ]; - let destination_addrs = - process_destination_addrs(destination_ips, destination_ranges, IpVersion::Ipv4); + let destination_addrs = process_destination_addrs(destination_ips, destination_ranges); assert_eq!( - destination_addrs, + destination_addrs.0, vec![ IpAddress { address: Some(Address::IpRange(IpRange { @@ -1003,16 +1027,12 @@ mod test { ); // Test with empty input - let empty_addrs = process_destination_addrs(vec![], vec![], IpVersion::Ipv4); - assert!(empty_addrs.is_empty()); + let empty_addrs = process_destination_addrs(Vec::new(), Vec::new()); + assert!(empty_addrs.0.is_empty()); // Test with only IPv6 addresses - should return empty result for IPv4 - let ipv6_only = process_destination_addrs( - vec!["2001:db8::/64".parse().unwrap()], - vec![], - IpVersion::Ipv4, - ); - assert!(ipv6_only.is_empty()); + let ipv6_only = process_destination_addrs(vec!["2001:db8::/64".parse().unwrap()], vec![]); + assert!(ipv6_only.0.is_empty()); } #[test] @@ -1038,11 +1058,10 @@ mod test { }, ]; - let destination_addrs = - process_destination_addrs(destination_ips, destination_ranges, IpVersion::Ipv6); + let destination_addrs = process_destination_addrs(destination_ips, destination_ranges); assert_eq!( - destination_addrs, + destination_addrs.1, vec![ IpAddress { address: Some(Address::IpRange(IpRange { @@ -1072,16 +1091,12 @@ mod test { ); // Test with empty input - let empty_addrs = process_destination_addrs(vec![], vec![], IpVersion::Ipv6); - assert!(empty_addrs.is_empty()); + let empty_addrs = process_destination_addrs(vec![], vec![]); + assert!(empty_addrs.1.is_empty()); // Test with only IPv4 addresses - should return empty result for IPv6 - let ipv4_only = process_destination_addrs( - vec!["192.168.1.0/24".parse().unwrap()], - vec![], - IpVersion::Ipv6, - ); - assert!(ipv4_only.is_empty()); + let ipv4_only = process_destination_addrs(vec!["192.168.1.0/24".parse().unwrap()], vec![]); + assert!(ipv4_only.1.is_empty()); } #[test] @@ -1541,7 +1556,7 @@ mod test { } #[sqlx::test] - async fn test_generate_firewall_rules(_: PgPoolOptions, options: PgConnectOptions) { + async fn test_generate_firewall_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; let mut rng = thread_rng(); @@ -1585,7 +1600,12 @@ mod test { let network_device = WireguardNetworkDevice { device_id: device.id, wireguard_network_id: location.id, - wireguard_ip: IpAddr::V4(Ipv4Addr::new(10, 0, user.id as u8, device_num as u8)), + wireguard_ips: vec![IpAddr::V4(Ipv4Addr::new( + 10, + 0, + user.id as u8, + device_num as u8, + ))], preshared_key: None, is_authorized: true, authorized_at: None, @@ -1686,7 +1706,7 @@ mod test { let network_device = WireguardNetworkDevice { device_id, wireguard_network_id: location.id, - wireguard_ip: ip, + wireguard_ips: vec![ip], preshared_key: None, is_authorized: true, authorized_at: None, @@ -1803,10 +1823,6 @@ mod test { generated_firewall_config.default_policy, i32::from(FirewallPolicy::Allow) ); - assert_eq!( - generated_firewall_config.ip_version, - i32::from(IpVersion::Ipv4) - ); let generated_firewall_rules = generated_firewall_config.rules; @@ -1955,103 +1971,1758 @@ mod test { } #[sqlx::test] - async fn test_expired_acl_rules(_: PgPoolOptions, options: PgConnectOptions) { + async fn test_generate_firewall_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; + let mut rng = thread_rng(); // Create test location let location = WireguardNetwork { id: NoId, - acl_enabled: true, + acl_enabled: false, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() }; - let location = location.save(&pool).await.unwrap(); + let mut location = location.save(&pool).await.unwrap(); - // create expired ACL rules - let mut acl_rule_1 = AclRule { + // Setup test users and their devices + let user_1: User = rng.gen(); + let user_1 = user_1.save(&pool).await.unwrap(); + let user_2: User = rng.gen(); + let user_2 = user_2.save(&pool).await.unwrap(); + let user_3: User = rng.gen(); + let user_3 = user_3.save(&pool).await.unwrap(); + let user_4: User = rng.gen(); + let user_4 = user_4.save(&pool).await.unwrap(); + let user_5: User = rng.gen(); + let user_5 = user_5.save(&pool).await.unwrap(); + + for user in [&user_1, &user_2, &user_3, &user_4, &user_5] { + // Create 2 devices per user + for device_num in 1..3 { + let device = Device { + id: NoId, + name: format!("device-{}-{}", user.id, device_num), + user_id: user.id, + device_type: DeviceType::User, + description: None, + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let device = device.save(&pool).await.unwrap(); + + // Add device to location's VPN network + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location.id, + wireguard_ips: vec![IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 0, + 0, + user.id as u16, + device_num as u16, + ))], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + } + + // Setup test groups + let group_1 = Group { id: NoId, - expires: Some(NaiveDateTime::UNIX_EPOCH), - enabled: true, - state: RuleState::Applied, + name: "group_1".into(), ..Default::default() - } - .save(&pool) - .await - .unwrap(); - let mut acl_rule_2 = AclRule { + }; + let group_1 = group_1.save(&pool).await.unwrap(); + let group_2 = Group { id: NoId, - expires: Some(NaiveDateTime::UNIX_EPOCH), - enabled: true, - state: RuleState::Applied, + name: "group_2".into(), ..Default::default() - } - .save(&pool) - .await - .unwrap(); - - // assign rules to location - for rule in [&acl_rule_1, &acl_rule_2] { - let obj = AclRuleNetwork { - id: NoId, - rule_id: rule.id, - network_id: location.id, - }; - obj.save(&pool).await.unwrap(); - } - - let mut conn = pool.acquire().await.unwrap(); - let generated_firewall_rules = location - .try_get_firewall_config(&mut conn) - .await - .unwrap() - .unwrap() - .rules; - - // both rules were expired - assert_eq!(generated_firewall_rules.len(), 0); - - // make both rules not expired - acl_rule_1.expires = None; - acl_rule_1.save(&pool).await.unwrap(); - - acl_rule_2.expires = Some(NaiveDateTime::MAX); - acl_rule_2.save(&pool).await.unwrap(); + }; + let group_2 = group_2.save(&pool).await.unwrap(); - let generated_firewall_rules = location - .try_get_firewall_config(&mut conn) - .await - .unwrap() - .unwrap() - .rules; - assert_eq!(generated_firewall_rules.len(), 2); - } + // Assign users to groups: + // Group 1: users 1,2 + // Group 2: users 3,4 + let group_assignments = vec![ + (&group_1, vec![&user_1, &user_2]), + (&group_2, vec![&user_3, &user_4]), + ]; - #[sqlx::test] - async fn test_disabled_acl_rules(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; + for (group, users) in group_assignments { + for user in users { + query!( + "INSERT INTO group_user (user_id, group_id) VALUES ($1, $2)", + user.id, + group.id + ) + .execute(&pool) + .await + .unwrap(); + } + } - // Create test location - let location = WireguardNetwork { + // Create some network devices + let network_device_1 = Device { id: NoId, - acl_enabled: true, - ..Default::default() + name: "network-device-1".into(), + user_id: user_1.id, // Owned by user 1 + device_type: DeviceType::Network, + description: Some("Test network device 1".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, }; - let location = location.save(&pool).await.unwrap(); + let network_device_1 = network_device_1.save(&pool).await.unwrap(); - // create disabled ACL rules - let mut acl_rule_1 = AclRule { + let network_device_2 = Device { id: NoId, - expires: None, - enabled: false, - state: RuleState::Applied, - ..Default::default() - } - .save(&pool) - .await + name: "network-device-2".into(), + user_id: user_2.id, // Owned by user 2 + device_type: DeviceType::Network, + description: Some("Test network device 2".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let network_device_2 = network_device_2.save(&pool).await.unwrap(); + + let network_device_3 = Device { + id: NoId, + name: "network-device-3".into(), + user_id: user_3.id, // Owned by user 3 + device_type: DeviceType::Network, + description: Some("Test network device 3".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let network_device_3 = network_device_3.save(&pool).await.unwrap(); + + // Add network devices to location's VPN network + let network_devices = vec![ + ( + network_device_1.id, + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 1)), + ), + ( + network_device_2.id, + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 2)), + ), + ( + network_device_3.id, + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 3)), + ), + ]; + + for (device_id, ip) in network_devices { + let network_device = WireguardNetworkDevice { + device_id, + wireguard_network_id: location.id, + wireguard_ips: vec![ip], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + + // Create first ACL rule - Web access + let acl_rule_1 = AclRule { + id: NoId, + name: "Web Access".into(), + all_networks: false, + expires: None, + allow_all_users: false, + deny_all_users: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + destination: vec!["fc00::0/112".parse().unwrap()], + ports: vec![ + PortRange::new(80, 80).into(), + PortRange::new(443, 443).into(), + ], + protocols: vec![Protocol::Tcp.into()], + enabled: true, + parent_id: None, + state: RuleState::Applied, + }; + let locations = vec![location.id]; + let allowed_users = vec![user_1.id, user_2.id]; // First two users can access web + let denied_users = vec![user_3.id]; // Third user explicitly denied + let allowed_groups = vec![group_1.id]; // First group allowed + let denied_groups = vec![]; + let allowed_devices = vec![network_device_1.id]; + let denied_devices = vec![network_device_2.id, network_device_3.id]; + let destination_ranges = vec![]; + let aliases = vec![]; + + let _acl_rule_1 = create_acl_rule( + &pool, + acl_rule_1, + locations, + allowed_users, + denied_users, + allowed_groups, + denied_groups, + allowed_devices, + denied_devices, + destination_ranges, + aliases, + ) + .await; + + // Create second ACL rule - DNS access + let acl_rule_2 = AclRule { + id: NoId, + name: "DNS Access".into(), + all_networks: false, + expires: None, + allow_all_users: true, // Allow all users + deny_all_users: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + destination: vec![], // Will use destination ranges instead + ports: vec![PortRange::new(53, 53).into()], + protocols: vec![Protocol::Udp.into(), Protocol::Tcp.into()], + enabled: true, + parent_id: None, + state: RuleState::Applied, + }; + let locations_2 = vec![location.id]; + let allowed_users_2 = vec![]; + let denied_users_2 = vec![user_5.id]; // Fifth user denied DNS + let allowed_groups_2 = vec![]; + let denied_groups_2 = vec![group_2.id]; + let allowed_devices_2 = vec![network_device_1.id, network_device_2.id]; // First two network devices allowed + let denied_devices_2 = vec![network_device_3.id]; // Third network device denied + let destination_ranges_2 = vec![ + ("fc00::1:13".parse().unwrap(), "fc00::1:43".parse().unwrap()), + ("fc00::1:52".parse().unwrap(), "fc00::2:43".parse().unwrap()), + ]; + let aliases_2 = vec![]; + + let _acl_rule_2 = create_acl_rule( + &pool, + acl_rule_2, + locations_2, + allowed_users_2, + denied_users_2, + allowed_groups_2, + denied_groups_2, + allowed_devices_2, + denied_devices_2, + destination_ranges_2, + aliases_2, + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + + // try to generate firewall config with ACL disabled + location.acl_enabled = false; + let generated_firewall_config = location.try_get_firewall_config(&mut conn).await.unwrap(); + assert!(generated_firewall_config.is_none()); + + // generate firewall config with default policy Allow + location.acl_enabled = true; + location.acl_default_allow = true; + let generated_firewall_config = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + generated_firewall_config.default_policy, + i32::from(FirewallPolicy::Allow) + ); + + let generated_firewall_rules = generated_firewall_config.rules; + + assert_eq!(generated_firewall_rules.len(), 4); + + // First ACL - Web Access ALLOW + let web_allow_rule = &generated_firewall_rules[0]; + assert_eq!(web_allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!(web_allow_rule.protocols, vec![i32::from(Protocol::Tcp)]); + assert_eq!( + web_allow_rule.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::".to_string(), + end: "fc00::ffff".to_string(), + })), + }] + ); + assert_eq!( + web_allow_rule.destination_ports, + vec![ + Port { + port: Some(PortInner::SinglePort(80)) + }, + Port { + port: Some(PortInner::SinglePort(443)) + } + ] + ); + // Source addresses should include devices of users 1,2 and network_device_1 + assert_eq!( + web_allow_rule.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::1:1".to_string(), + end: "ff00::1:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::2:1".to_string(), + end: "ff00::2:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::Ip("ff00::100:1".to_string())), + }, + ] + ); + + // First ACL - Web Access DENY + let web_deny_rule = &generated_firewall_rules[2]; + assert_eq!(web_deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(web_deny_rule.protocols.is_empty()); + assert!(web_deny_rule.destination_ports.is_empty()); + assert!(web_deny_rule.source_addrs.is_empty()); + assert_eq!( + web_deny_rule.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::".to_string(), + end: "fc00::ffff".to_string(), + })), + }] + ); + + // Second ACL - DNS Access ALLOW + let dns_allow_rule = &generated_firewall_rules[1]; + assert_eq!(dns_allow_rule.verdict, i32::from(FirewallPolicy::Allow)); + assert_eq!( + dns_allow_rule.protocols, + vec![i32::from(Protocol::Tcp), i32::from(Protocol::Udp)] + ); + assert_eq!( + dns_allow_rule.destination_ports, + vec![Port { + port: Some(PortInner::SinglePort(53)) + }] + ); + // Source addresses should include network_devices 1,2 + assert_eq!( + dns_allow_rule.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::1:1".to_string(), + end: "ff00::1:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::2:1".to_string(), + end: "ff00::2:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::100:1".to_string(), + end: "ff00::100:2".to_string(), + })), + }, + ] + ); + assert_eq!( + dns_allow_rule.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:13".to_string(), + end: "fc00::1:43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:52".to_string(), + end: "fc00::2:43".to_string(), + })), + } + ] + ); + + // Second ACL - DNS Access DENY + let dns_deny_rule = &generated_firewall_rules[3]; + assert_eq!(dns_deny_rule.verdict, i32::from(FirewallPolicy::Deny)); + assert!(dns_deny_rule.protocols.is_empty(),); + assert!(dns_deny_rule.destination_ports.is_empty(),); + assert!(dns_deny_rule.source_addrs.is_empty(),); + assert_eq!( + dns_deny_rule.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:13".to_string(), + end: "fc00::1:43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:52".to_string(), + end: "fc00::2:43".to_string(), + })), + } + ] + ); + } + + #[sqlx::test] + async fn test_generate_firewall_rules_ipv4_and_ipv6( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let mut rng = thread_rng(); + + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: false, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], + ..Default::default() + }; + let mut location = location.save(&pool).await.unwrap(); + + // Setup test users and their devices + let user_1: User = rng.gen(); + let user_1 = user_1.save(&pool).await.unwrap(); + let user_2: User = rng.gen(); + let user_2 = user_2.save(&pool).await.unwrap(); + let user_3: User = rng.gen(); + let user_3 = user_3.save(&pool).await.unwrap(); + let user_4: User = rng.gen(); + let user_4 = user_4.save(&pool).await.unwrap(); + let user_5: User = rng.gen(); + let user_5 = user_5.save(&pool).await.unwrap(); + + for user in [&user_1, &user_2, &user_3, &user_4, &user_5] { + // Create 2 devices per user + for device_num in 1..3 { + let device = Device { + id: NoId, + name: format!("device-{}-{}", user.id, device_num), + user_id: user.id, + device_type: DeviceType::User, + description: None, + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let device = device.save(&pool).await.unwrap(); + + // Add device to location's VPN network + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location.id, + wireguard_ips: vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, user.id as u8, device_num as u8)), + IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 0, + 0, + user.id as u16, + device_num as u16, + )), + ], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + } + + // Setup test groups + let group_1 = Group { + id: NoId, + name: "group_1".into(), + ..Default::default() + }; + let group_1 = group_1.save(&pool).await.unwrap(); + let group_2 = Group { + id: NoId, + name: "group_2".into(), + ..Default::default() + }; + let group_2 = group_2.save(&pool).await.unwrap(); + + // Assign users to groups: + // Group 1: users 1,2 + // Group 2: users 3,4 + let group_assignments = vec![ + (&group_1, vec![&user_1, &user_2]), + (&group_2, vec![&user_3, &user_4]), + ]; + + for (group, users) in group_assignments { + for user in users { + query!( + "INSERT INTO group_user (user_id, group_id) VALUES ($1, $2)", + user.id, + group.id + ) + .execute(&pool) + .await + .unwrap(); + } + } + + // Create some network devices + let network_device_1 = Device { + id: NoId, + name: "network-device-1".into(), + user_id: user_1.id, // Owned by user 1 + device_type: DeviceType::Network, + description: Some("Test network device 1".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let network_device_1 = network_device_1.save(&pool).await.unwrap(); + + let network_device_2 = Device { + id: NoId, + name: "network-device-2".into(), + user_id: user_2.id, // Owned by user 2 + device_type: DeviceType::Network, + description: Some("Test network device 2".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let network_device_2 = network_device_2.save(&pool).await.unwrap(); + + let network_device_3 = Device { + id: NoId, + name: "network-device-3".into(), + user_id: user_3.id, // Owned by user 3 + device_type: DeviceType::Network, + description: Some("Test network device 3".into()), + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let network_device_3 = network_device_3.save(&pool).await.unwrap(); + + // Add network devices to location's VPN network + let network_devices = vec![ + ( + network_device_1.id, + vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, 100, 1)), + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 1)), + ], + ), + ( + network_device_2.id, + vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, 100, 2)), + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 2)), + ], + ), + ( + network_device_3.id, + vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, 100, 3)), + IpAddr::V6(Ipv6Addr::new(0xff00, 0, 0, 0, 0, 0, 0x0100, 3)), + ], + ), + ]; + + for (device_id, ips) in network_devices { + let network_device = WireguardNetworkDevice { + device_id, + wireguard_network_id: location.id, + wireguard_ips: ips, + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + + // Create first ACL rule - Web access + let acl_rule_1 = AclRule { + id: NoId, + name: "Web Access".into(), + all_networks: false, + expires: None, + allow_all_users: false, + deny_all_users: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + destination: vec![ + "192.168.1.0/24".parse().unwrap(), + "fc00::0/112".parse().unwrap(), + ], + ports: vec![ + PortRange::new(80, 80).into(), + PortRange::new(443, 443).into(), + ], + protocols: vec![Protocol::Tcp.into()], + enabled: true, + parent_id: None, + state: RuleState::Applied, + }; + let locations = vec![location.id]; + let allowed_users = vec![user_1.id, user_2.id]; // First two users can access web + let denied_users = vec![user_3.id]; // Third user explicitly denied + let allowed_groups = vec![group_1.id]; // First group allowed + let denied_groups = vec![]; + let allowed_devices = vec![network_device_1.id]; + let denied_devices = vec![network_device_2.id, network_device_3.id]; + let destination_ranges = vec![]; + let aliases = vec![]; + + let _acl_rule_1 = create_acl_rule( + &pool, + acl_rule_1, + locations, + allowed_users, + denied_users, + allowed_groups, + denied_groups, + allowed_devices, + denied_devices, + destination_ranges, + aliases, + ) + .await; + + // Create second ACL rule - DNS access + let acl_rule_2 = AclRule { + id: NoId, + name: "DNS Access".into(), + all_networks: false, + expires: None, + allow_all_users: true, // Allow all users + deny_all_users: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + destination: vec![], // Will use destination ranges instead + ports: vec![PortRange::new(53, 53).into()], + protocols: vec![Protocol::Udp.into(), Protocol::Tcp.into()], + enabled: true, + parent_id: None, + state: RuleState::Applied, + }; + let locations_2 = vec![location.id]; + let allowed_users_2 = vec![]; + let denied_users_2 = vec![user_5.id]; // Fifth user denied DNS + let allowed_groups_2 = vec![]; + let denied_groups_2 = vec![group_2.id]; + let allowed_devices_2 = vec![network_device_1.id, network_device_2.id]; // First two network devices allowed + let denied_devices_2 = vec![network_device_3.id]; // Third network device denied + let destination_ranges_2 = vec![ + ("10.0.1.13".parse().unwrap(), "10.0.1.43".parse().unwrap()), + ("10.0.1.52".parse().unwrap(), "10.0.2.43".parse().unwrap()), + ("fc00::1:13".parse().unwrap(), "fc00::1:43".parse().unwrap()), + ("fc00::1:52".parse().unwrap(), "fc00::2:43".parse().unwrap()), + ]; + let aliases_2 = vec![]; + + let _acl_rule_2 = create_acl_rule( + &pool, + acl_rule_2, + locations_2, + allowed_users_2, + denied_users_2, + allowed_groups_2, + denied_groups_2, + allowed_devices_2, + denied_devices_2, + destination_ranges_2, + aliases_2, + ) + .await; + + let mut conn = pool.acquire().await.unwrap(); + + // try to generate firewall config with ACL disabled + location.acl_enabled = false; + let generated_firewall_config = location.try_get_firewall_config(&mut conn).await.unwrap(); + assert!(generated_firewall_config.is_none()); + + // generate firewall config with default policy Allow + location.acl_enabled = true; + location.acl_default_allow = true; + let generated_firewall_config = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap(); + assert_eq!( + generated_firewall_config.default_policy, + i32::from(FirewallPolicy::Allow) + ); + + let generated_firewall_rules = generated_firewall_config.rules; + + assert_eq!(generated_firewall_rules.len(), 8); + + // First ACL - Web Access ALLOW + let web_allow_rule_ipv4 = &generated_firewall_rules[0]; + assert_eq!( + web_allow_rule_ipv4.verdict, + i32::from(FirewallPolicy::Allow) + ); + assert_eq!( + web_allow_rule_ipv4.protocols, + vec![i32::from(Protocol::Tcp)] + ); + assert_eq!( + web_allow_rule_ipv4.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "192.168.1.0".to_string(), + end: "192.168.1.255".to_string(), + })), + }] + ); + assert_eq!( + web_allow_rule_ipv4.destination_ports, + vec![ + Port { + port: Some(PortInner::SinglePort(80)) + }, + Port { + port: Some(PortInner::SinglePort(443)) + } + ] + ); + // Source addresses should include devices of users 1,2 and network_device_1 + assert_eq!( + web_allow_rule_ipv4.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::Ip("10.0.100.1".to_string())), + }, + ] + ); + + let web_allow_rule_ipv6 = &generated_firewall_rules[1]; + assert_eq!( + web_allow_rule_ipv6.verdict, + i32::from(FirewallPolicy::Allow) + ); + assert_eq!( + web_allow_rule_ipv6.protocols, + vec![i32::from(Protocol::Tcp)] + ); + assert_eq!( + web_allow_rule_ipv6.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::".to_string(), + end: "fc00::ffff".to_string(), + })), + }] + ); + assert_eq!( + web_allow_rule_ipv6.destination_ports, + vec![ + Port { + port: Some(PortInner::SinglePort(80)) + }, + Port { + port: Some(PortInner::SinglePort(443)) + } + ] + ); + // Source addresses should include devices of users 1,2 and network_device_1 + assert_eq!( + web_allow_rule_ipv6.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::1:1".to_string(), + end: "ff00::1:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::2:1".to_string(), + end: "ff00::2:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::Ip("ff00::100:1".to_string())), + }, + ] + ); + + // First ACL - Web Access DENY + let web_deny_rule_ipv4 = &generated_firewall_rules[4]; + assert_eq!(web_deny_rule_ipv4.verdict, i32::from(FirewallPolicy::Deny)); + assert!(web_deny_rule_ipv4.protocols.is_empty()); + assert!(web_deny_rule_ipv4.destination_ports.is_empty()); + assert!(web_deny_rule_ipv4.source_addrs.is_empty()); + assert_eq!( + web_deny_rule_ipv4.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "192.168.1.0".to_string(), + end: "192.168.1.255".to_string(), + })), + }] + ); + + let web_deny_rule_ipv6 = &generated_firewall_rules[5]; + assert_eq!(web_deny_rule_ipv6.verdict, i32::from(FirewallPolicy::Deny)); + assert!(web_deny_rule_ipv6.protocols.is_empty()); + assert!(web_deny_rule_ipv6.destination_ports.is_empty()); + assert!(web_deny_rule_ipv6.source_addrs.is_empty()); + assert_eq!( + web_deny_rule_ipv6.destination_addrs, + vec![IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::".to_string(), + end: "fc00::ffff".to_string(), + })), + }] + ); + + // Second ACL - DNS Access ALLOW + let dns_allow_rule_ipv4 = &generated_firewall_rules[2]; + assert_eq!( + dns_allow_rule_ipv4.verdict, + i32::from(FirewallPolicy::Allow) + ); + assert_eq!( + dns_allow_rule_ipv4.protocols, + vec![i32::from(Protocol::Tcp), i32::from(Protocol::Udp)] + ); + assert_eq!( + dns_allow_rule_ipv4.destination_ports, + vec![Port { + port: Some(PortInner::SinglePort(53)) + }] + ); + // Source addresses should include network_devices 1,2 + assert_eq!( + dns_allow_rule_ipv4.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.1".to_string(), + end: "10.0.1.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.2.1".to_string(), + end: "10.0.2.2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.100.1".to_string(), + end: "10.0.100.2".to_string(), + })), + }, + ] + ); + assert_eq!( + dns_allow_rule_ipv4.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.13".to_string(), + end: "10.0.1.43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.52".to_string(), + end: "10.0.2.43".to_string(), + })), + }, + ] + ); + + let dns_allow_rule_ipv6 = &generated_firewall_rules[3]; + assert_eq!( + dns_allow_rule_ipv6.verdict, + i32::from(FirewallPolicy::Allow) + ); + assert_eq!( + dns_allow_rule_ipv6.protocols, + vec![i32::from(Protocol::Tcp), i32::from(Protocol::Udp)] + ); + assert_eq!( + dns_allow_rule_ipv6.destination_ports, + vec![Port { + port: Some(PortInner::SinglePort(53)) + }] + ); + // Source addresses should include network_devices 1,2 + assert_eq!( + dns_allow_rule_ipv6.source_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::1:1".to_string(), + end: "ff00::1:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::2:1".to_string(), + end: "ff00::2:2".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "ff00::100:1".to_string(), + end: "ff00::100:2".to_string(), + })), + }, + ] + ); + assert_eq!( + dns_allow_rule_ipv6.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:13".to_string(), + end: "fc00::1:43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:52".to_string(), + end: "fc00::2:43".to_string(), + })), + } + ] + ); + + // Second ACL - DNS Access DENY + let dns_deny_rule_ipv4 = &generated_firewall_rules[6]; + assert_eq!(dns_deny_rule_ipv4.verdict, i32::from(FirewallPolicy::Deny)); + assert!(dns_deny_rule_ipv4.protocols.is_empty(),); + assert!(dns_deny_rule_ipv4.destination_ports.is_empty(),); + assert!(dns_deny_rule_ipv4.source_addrs.is_empty(),); + assert_eq!( + dns_deny_rule_ipv4.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.13".to_string(), + end: "10.0.1.43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "10.0.1.52".to_string(), + end: "10.0.2.43".to_string(), + })), + }, + ] + ); + + let dns_deny_rule_ipv6 = &generated_firewall_rules[7]; + assert_eq!(dns_deny_rule_ipv6.verdict, i32::from(FirewallPolicy::Deny)); + assert!(dns_deny_rule_ipv6.protocols.is_empty(),); + assert!(dns_deny_rule_ipv6.destination_ports.is_empty(),); + assert!(dns_deny_rule_ipv6.source_addrs.is_empty(),); + assert_eq!( + dns_deny_rule_ipv6.destination_addrs, + vec![ + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:13".to_string(), + end: "fc00::1:43".to_string(), + })), + }, + IpAddress { + address: Some(Address::IpRange(IpRange { + start: "fc00::1:52".to_string(), + end: "fc00::2:43".to_string(), + })), + } + ] + ); + } + + #[sqlx::test] + async fn test_expired_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create expired ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were expired + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules not expired + acl_rule_1.expires = None; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.expires = Some(NaiveDateTime::MAX); + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_expired_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create expired ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were expired + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules not expired + acl_rule_1.expires = None; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.expires = Some(NaiveDateTime::MAX); + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_expired_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create expired ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: Some(DateTime::UNIX_EPOCH.naive_utc()), + enabled: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were expired + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules not expired + acl_rule_1.expires = None; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.expires = Some(NaiveDateTime::MAX); + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 4); + } + + #[sqlx::test] + async fn test_disabled_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create disabled ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were disabled + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules enabled + acl_rule_1.enabled = true; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.enabled = true; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_disabled_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create disabled ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were disabled + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules enabled + acl_rule_1.enabled = true; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.enabled = true; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_disabled_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create disabled ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: false, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were disabled + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules enabled + acl_rule_1.enabled = true; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.enabled = true; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 4); + } + + #[sqlx::test] + async fn test_unapplied_acl_rules_ipv4(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create unapplied ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::New, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::Modified, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were not applied + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules applied + acl_rule_1.state = RuleState::Applied; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.state = RuleState::Applied; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_unapplied_acl_rules_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create unapplied ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::New, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::Modified, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were not applied + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules applied + acl_rule_1.state = RuleState::Applied; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.state = RuleState::Applied; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 2); + } + + #[sqlx::test] + async fn test_unapplied_acl_rules_ipv4_and_ipv6(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + // Create test location + let location = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], + ..Default::default() + }; + let location = location.save(&pool).await.unwrap(); + + // create unapplied ACL rules + let mut acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::New, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + let mut acl_rule_2 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::Modified, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to location + for rule in [&acl_rule_1, &acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location.id, + }; + obj.save(&pool).await.unwrap(); + } + + let mut conn = pool.acquire().await.unwrap(); + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + + // both rules were not applied + assert_eq!(generated_firewall_rules.len(), 0); + + // make both rules applied + acl_rule_1.state = RuleState::Applied; + acl_rule_1.save(&pool).await.unwrap(); + + acl_rule_2.state = RuleState::Applied; + acl_rule_2.save(&pool).await.unwrap(); + + let generated_firewall_rules = location + .try_get_firewall_config(&mut conn) + .await + .unwrap() + .unwrap() + .rules; + assert_eq!(generated_firewall_rules.len(), 4); + } + + #[sqlx::test] + async fn test_acl_rules_all_locations_ipv4(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + let mut rng = thread_rng(); + + // Create test location + let location_1 = WireguardNetwork { + id: NoId, + acl_enabled: true, + ..Default::default() + }; + let location_1 = location_1.save(&pool).await.unwrap(); + + // Create another test location + let location_2 = WireguardNetwork { + id: NoId, + acl_enabled: true, + ..Default::default() + }; + let location_2 = location_2.save(&pool).await.unwrap(); + // Setup some test users and their devices + let user_1: User = rng.gen(); + let user_1 = user_1.save(&pool).await.unwrap(); + let user_2: User = rng.gen(); + let user_2 = user_2.save(&pool).await.unwrap(); + + for user in [&user_1, &user_2] { + // Create 2 devices per user + for device_num in 1..3 { + let device = Device { + id: NoId, + name: format!("device-{}-{}", user.id, device_num), + user_id: user.id, + device_type: DeviceType::User, + description: None, + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let device = device.save(&pool).await.unwrap(); + + // Add device to location's VPN network + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location_1.id, + wireguard_ips: vec![IpAddr::V4(Ipv4Addr::new( + 10, + 0, + user.id as u8, + device_num as u8, + ))], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location_2.id, + wireguard_ips: vec![IpAddr::V4(Ipv4Addr::new( + 10, + 10, + user.id as u8, + device_num as u8, + ))], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + } + + // create ACL rules + let acl_rule_1 = AclRule { + id: NoId, + expires: None, + enabled: true, + state: RuleState::Applied, + destination: vec!["192.168.1.0/24".parse().unwrap()], + ..Default::default() + } + .save(&pool) + .await .unwrap(); - let mut acl_rule_2 = AclRule { + + let acl_rule_2 = AclRule { id: NoId, expires: None, - enabled: false, + enabled: true, + all_networks: true, state: RuleState::Applied, ..Default::default() } @@ -2059,124 +3730,237 @@ mod test { .await .unwrap(); - // assign rules to location + let _acl_rule_3 = AclRule { + id: NoId, + expires: None, + enabled: true, + all_networks: true, + allow_all_users: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to locations for rule in [&acl_rule_1, &acl_rule_2] { let obj = AclRuleNetwork { id: NoId, rule_id: rule.id, - network_id: location.id, + network_id: location_1.id, + }; + obj.save(&pool).await.unwrap(); + } + for rule in [&acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location_2.id, }; obj.save(&pool).await.unwrap(); } let mut conn = pool.acquire().await.unwrap(); - let generated_firewall_rules = location + let generated_firewall_rules = location_1 .try_get_firewall_config(&mut conn) .await .unwrap() .unwrap() .rules; - // both rules were disabled - assert_eq!(generated_firewall_rules.len(), 0); - - // make both rules enabled - acl_rule_1.enabled = true; - acl_rule_1.save(&pool).await.unwrap(); - - acl_rule_2.enabled = true; - acl_rule_2.save(&pool).await.unwrap(); + // both rules were assigned to this location + assert_eq!(generated_firewall_rules.len(), 4); - let generated_firewall_rules = location + let generated_firewall_rules = location_2 .try_get_firewall_config(&mut conn) .await .unwrap() .unwrap() .rules; - assert_eq!(generated_firewall_rules.len(), 2); + + // rule with `all_networks` enabled was used for this location + assert_eq!(generated_firewall_rules.len(), 3); } #[sqlx::test] - async fn test_unapplied_acl_rules(_: PgPoolOptions, options: PgConnectOptions) { + async fn test_acl_rules_all_locations_ipv6(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; + let mut rng = thread_rng(); // Create test location - let location = WireguardNetwork { + let location_1 = WireguardNetwork { id: NoId, acl_enabled: true, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], ..Default::default() }; - let location = location.save(&pool).await.unwrap(); + let location_1 = location_1.save(&pool).await.unwrap(); - // create unapplied ACL rules - let mut acl_rule_1 = AclRule { + // Create another test location + let location_2 = WireguardNetwork { + id: NoId, + acl_enabled: true, + address: vec![IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap()], + ..Default::default() + }; + let location_2 = location_2.save(&pool).await.unwrap(); + + // Setup some test users and their devices + let user_1: User = rng.gen(); + let user_1 = user_1.save(&pool).await.unwrap(); + let user_2: User = rng.gen(); + let user_2 = user_2.save(&pool).await.unwrap(); + + for user in [&user_1, &user_2] { + // Create 2 devices per user + for device_num in 1..3 { + let device = Device { + id: NoId, + name: format!("device-{}-{}", user.id, device_num), + user_id: user.id, + device_type: DeviceType::User, + description: None, + wireguard_pubkey: Default::default(), + created: Default::default(), + configured: true, + }; + let device = device.save(&pool).await.unwrap(); + + // Add device to location's VPN network + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location_1.id, + wireguard_ips: vec![IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 0, + 0, + user.id as u16, + device_num as u16, + ))], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + let network_device = WireguardNetworkDevice { + device_id: device.id, + wireguard_network_id: location_2.id, + wireguard_ips: vec![IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 10, + 10, + user.id as u16, + device_num as u16, + ))], + preshared_key: None, + is_authorized: true, + authorized_at: None, + }; + network_device.insert(&pool).await.unwrap(); + } + } + + // create ACL rules + let acl_rule_1 = AclRule { id: NoId, expires: None, enabled: true, - state: RuleState::New, + state: RuleState::Applied, + destination: vec!["fc00::0/112".parse().unwrap()], ..Default::default() } .save(&pool) .await .unwrap(); - let mut acl_rule_2 = AclRule { + + let acl_rule_2 = AclRule { id: NoId, expires: None, enabled: true, - state: RuleState::Modified, + all_networks: true, + state: RuleState::Applied, ..Default::default() } .save(&pool) .await .unwrap(); - // assign rules to location + let _acl_rule_3 = AclRule { + id: NoId, + expires: None, + enabled: true, + all_networks: true, + allow_all_users: true, + state: RuleState::Applied, + ..Default::default() + } + .save(&pool) + .await + .unwrap(); + + // assign rules to locations for rule in [&acl_rule_1, &acl_rule_2] { let obj = AclRuleNetwork { id: NoId, rule_id: rule.id, - network_id: location.id, + network_id: location_1.id, + }; + obj.save(&pool).await.unwrap(); + } + for rule in [&acl_rule_2] { + let obj = AclRuleNetwork { + id: NoId, + rule_id: rule.id, + network_id: location_2.id, }; obj.save(&pool).await.unwrap(); } let mut conn = pool.acquire().await.unwrap(); - let generated_firewall_rules = location + let generated_firewall_rules = location_1 .try_get_firewall_config(&mut conn) .await .unwrap() .unwrap() .rules; - // both rules were not applied - assert_eq!(generated_firewall_rules.len(), 0); - - // make both rules applied - acl_rule_1.state = RuleState::Applied; - acl_rule_1.save(&pool).await.unwrap(); - - acl_rule_2.state = RuleState::Applied; - acl_rule_2.save(&pool).await.unwrap(); + // both rules were assigned to this location + assert_eq!(generated_firewall_rules.len(), 4); - let generated_firewall_rules = location + let generated_firewall_rules = location_2 .try_get_firewall_config(&mut conn) .await .unwrap() .unwrap() .rules; - assert_eq!(generated_firewall_rules.len(), 2); + + // rule with `all_networks` enabled was used for this location + assert_eq!(generated_firewall_rules.len(), 3); } #[sqlx::test] - async fn test_acl_rules_all_locations(_: PgPoolOptions, options: PgConnectOptions) { + async fn test_acl_rules_all_locations_ipv4_and_ipv6( + _: PgPoolOptions, + options: PgConnectOptions, + ) { let pool = setup_pool(options).await; - let mut rng = thread_rng(); // Create test location let location_1 = WireguardNetwork { id: NoId, acl_enabled: true, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], ..Default::default() }; let location_1 = location_1.save(&pool).await.unwrap(); @@ -2185,6 +3969,10 @@ mod test { let location_2 = WireguardNetwork { id: NoId, acl_enabled: true, + address: vec![ + IpNetwork::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0).unwrap(), + IpNetwork::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0).unwrap(), + ], ..Default::default() }; let location_2 = location_2.save(&pool).await.unwrap(); @@ -2213,7 +4001,19 @@ mod test { let network_device = WireguardNetworkDevice { device_id: device.id, wireguard_network_id: location_1.id, - wireguard_ip: IpAddr::V4(Ipv4Addr::new(10, 0, user.id as u8, device_num as u8)), + wireguard_ips: vec![ + IpAddr::V4(Ipv4Addr::new(10, 0, user.id as u8, device_num as u8)), + IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 0, + 0, + user.id as u16, + device_num as u16, + )), + ], preshared_key: None, is_authorized: true, authorized_at: None, @@ -2222,12 +4022,19 @@ mod test { let network_device = WireguardNetworkDevice { device_id: device.id, wireguard_network_id: location_2.id, - wireguard_ip: IpAddr::V4(Ipv4Addr::new( - 10, - 10, - user.id as u8, - device_num as u8, - )), + wireguard_ips: vec![ + IpAddr::V4(Ipv4Addr::new(10, 10, user.id as u8, device_num as u8)), + IpAddr::V6(Ipv6Addr::new( + 0xff00, + 0, + 0, + 0, + 10, + 10, + user.id as u16, + device_num as u16, + )), + ], preshared_key: None, is_authorized: true, authorized_at: None, @@ -2242,7 +4049,10 @@ mod test { expires: None, enabled: true, state: RuleState::Applied, - destination: vec!["192.168.1.0/24".parse().unwrap()], + destination: vec![ + "192.168.1.0/24".parse().unwrap(), + "fc00::0/112".parse().unwrap(), + ], ..Default::default() } .save(&pool) @@ -2301,7 +4111,7 @@ mod test { .rules; // both rules were assigned to this location - assert_eq!(generated_firewall_rules.len(), 4); + assert_eq!(generated_firewall_rules.len(), 8); let generated_firewall_rules = location_2 .try_get_firewall_config(&mut conn) @@ -2311,7 +4121,7 @@ mod test { .rules; // rule with `all_networks` enabled was used for this location - assert_eq!(generated_firewall_rules.len(), 3); + assert_eq!(generated_firewall_rules.len(), 6); } #[sqlx::test] @@ -2355,7 +4165,12 @@ mod test { let network_device = WireguardNetworkDevice { device_id: device.id, wireguard_network_id: location.id, - wireguard_ip: IpAddr::V4(Ipv4Addr::new(10, 0, user.id as u8, device_num as u8)), + wireguard_ips: vec![IpAddr::V4(Ipv4Addr::new( + 10, + 0, + user.id as u8, + device_num as u8, + ))], preshared_key: None, is_authorized: true, authorized_at: None, diff --git a/src/error.rs b/src/error.rs index a0518f2330..5908ba8233 100644 --- a/src/error.rs +++ b/src/error.rs @@ -100,7 +100,7 @@ impl From for WebError { match error { DeviceError::PubkeyConflict(..) => Self::PubkeyValidation(error.to_string()), DeviceError::DatabaseError(_) => Self::DbError(error.to_string()), - DeviceError::ModelError(_) => Self::ModelError(error.to_string()), + DeviceError::NetworkIpAssignmentError(_) => Self::ModelError(error.to_string()), DeviceError::Unexpected(_) => Self::Http(StatusCode::INTERNAL_SERVER_ERROR), } } diff --git a/src/grpc/desktop_client_mfa.rs b/src/grpc/desktop_client_mfa.rs index 72d046d224..9565ba8c08 100644 --- a/src/grpc/desktop_client_mfa.rs +++ b/src/grpc/desktop_client_mfa.rs @@ -266,7 +266,7 @@ impl ClientMfaServer { device: device.clone(), network_info: vec![DeviceNetworkInfo { network_id: location.id, - device_wireguard_ip: network_device.wireguard_ip, + device_wireguard_ips: network_device.wireguard_ips, preshared_key: network_device.preshared_key, is_authorized: network_device.is_authorized, }], diff --git a/src/grpc/enrollment.rs b/src/grpc/enrollment.rs index 1ffe8beea0..fb3751b832 100644 --- a/src/grpc/enrollment.rs +++ b/src/grpc/enrollment.rs @@ -1,4 +1,3 @@ -use ipnetwork::IpNetwork; use sqlx::{PgPool, Transaction}; use tokio::sync::{broadcast::Sender, mpsc::UnboundedSender}; use tonic::Status; @@ -30,6 +29,7 @@ use crate::{ mail::Mail, server_config, templates::{self, TemplateLocation}, + AsCsv, }; pub(super) struct EnrollmentServer { @@ -634,7 +634,7 @@ impl EnrollmentServer { .iter() .map(|c| TemplateLocation { name: c.network_name.clone(), - assigned_ip: c.address.to_string(), + assigned_ips: c.address.as_csv(), }) .collect(); @@ -722,20 +722,14 @@ impl InitialUserInfo { impl From for ProtoDeviceConfig { fn from(config: DeviceConfig) -> Self { - let allowed_ips = config - .allowed_ips - .iter() - .map(IpNetwork::to_string) - .collect::>() - .join(","); Self { network_id: config.network_id, network_name: config.network_name, config: config.config, endpoint: config.endpoint, - assigned_ip: config.address.to_string(), + assigned_ip: config.address.as_csv(), pubkey: config.pubkey, - allowed_ips, + allowed_ips: config.allowed_ips.as_csv(), dns: config.dns, mfa_enabled: config.mfa_enabled, keepalive_interval: config.keepalive_interval, diff --git a/src/grpc/gateway.rs b/src/grpc/gateway.rs index 33b7ade226..94558787a0 100644 --- a/src/grpc/gateway.rs +++ b/src/grpc/gateway.rs @@ -1,4 +1,5 @@ use std::{ + net::IpAddr, pin::Pin, sync::{Arc, Mutex}, task::{Context, Poll}, @@ -68,7 +69,11 @@ impl WireguardNetwork { debug!("Fetching all peers for network {}", self.id); let rows = query!( "SELECT d.wireguard_pubkey pubkey, preshared_key, \ - array[host(wnd.wireguard_ip)] \"allowed_ips!: Vec\" \ + -- TODO possible to not use ARRAY-unnest here? + ARRAY( + SELECT host(ip) + FROM unnest(wnd.wireguard_ips) AS ip + ) \"allowed_ips!: Vec\" \ FROM wireguard_network_device wnd \ JOIN device d ON wnd.device_id = d.id \ JOIN \"user\" u ON d.user_id = u.id \ @@ -281,7 +286,11 @@ impl GatewayUpdatesHandler { self.send_peer_update( Peer { pubkey: device.device.wireguard_pubkey, - allowed_ips: vec![network_info.device_wireguard_ip.to_string()], + allowed_ips: network_info + .device_wireguard_ips + .iter() + .map(IpAddr::to_string) + .collect(), preshared_key: network_info.preshared_key.clone(), keepalive_interval: Some( self.network.keepalive_interval as u32, @@ -311,7 +320,11 @@ impl GatewayUpdatesHandler { self.send_peer_update( Peer { pubkey: device.device.wireguard_pubkey, - allowed_ips: vec![network_info.device_wireguard_ip.to_string()], + allowed_ips: network_info + .device_wireguard_ips + .iter() + .map(IpAddr::to_string) + .collect(), preshared_key: network_info.preshared_key.clone(), keepalive_interval: Some( self.network.keepalive_interval as u32, diff --git a/src/grpc/utils.rs b/src/grpc/utils.rs index 2213a68bfe..457d52286e 100644 --- a/src/grpc/utils.rs +++ b/src/grpc/utils.rs @@ -1,4 +1,3 @@ -use ipnetwork::IpNetwork; use sqlx::PgPool; use tonic::Status; @@ -16,6 +15,7 @@ use crate::{ Device, Id, Settings, User, }, enterprise::db::models::enterprise_settings::EnterpriseSettings, + AsCsv, }; // Create a new token for configuration polling. @@ -109,20 +109,14 @@ pub(crate) async fn build_device_config_response( ); Status::internal(format!("unexpected error: {err}")) })?; - let allowed_ips = network - .allowed_ips - .iter() - .map(IpNetwork::to_string) - .collect::>() - .join(","); let config = ProtoDeviceConfig { config: Device::create_config(&network, &wireguard_network_device), network_id: network.id, network_name: network.name, - assigned_ip: wireguard_network_device.wireguard_ip.to_string(), + assigned_ip: wireguard_network_device.wireguard_ips.as_csv(), endpoint: format!("{}:{}", network.endpoint, network.port), pubkey: network.pubkey, - allowed_ips, + allowed_ips: network.allowed_ips.as_csv(), dns: network.dns, mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, @@ -142,20 +136,14 @@ pub(crate) async fn build_device_config_response( Status::internal(format!("unexpected error: {err}")) })?; if let Some(wireguard_network_device) = wireguard_network_device { - let allowed_ips = network - .allowed_ips - .iter() - .map(IpNetwork::to_string) - .collect::>() - .join(","); let config = ProtoDeviceConfig { config: Device::create_config(&network, &wireguard_network_device), network_id: network.id, network_name: network.name, - assigned_ip: wireguard_network_device.wireguard_ip.to_string(), + assigned_ip: wireguard_network_device.wireguard_ips.as_csv(), endpoint: format!("{}:{}", network.endpoint, network.port), pubkey: network.pubkey, - allowed_ips, + allowed_ips: network.allowed_ips.as_csv(), dns: network.dns, mfa_enabled: network.mfa_enabled, keepalive_interval: network.keepalive_interval, diff --git a/src/handlers/network_devices.rs b/src/handlers/network_devices.rs index 720a244514..3cfe059beb 100644 --- a/src/handlers/network_devices.rs +++ b/src/handlers/network_devices.rs @@ -1,5 +1,5 @@ use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, + net::{AddrParseError, IpAddr, Ipv4Addr, Ipv6Addr}, str::FromStr, }; @@ -17,13 +17,17 @@ use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, db::{ - models::device::{DeviceConfig, DeviceInfo, DeviceType, WireguardNetworkDevice}, + models::{ + device::{DeviceConfig, DeviceInfo, DeviceType, WireguardNetworkDevice}, + wireguard::NetworkAddressError, + }, Device, GatewayEvent, Id, User, WireguardNetwork, }, enterprise::limits::update_counts, handlers::mail::send_new_device_added_email, server_config, templates::TemplateLocation, + AsCsv, }; #[derive(Serialize)] @@ -36,14 +40,14 @@ struct NetworkDeviceLocation { struct NetworkDeviceInfo { id: Id, name: String, - assigned_ip: IpAddr, + assigned_ips: Vec, description: Option, added_by: String, added_date: NaiveDateTime, location: NetworkDeviceLocation, wireguard_pubkey: String, configured: bool, - split_ip: SplitIP, + split_ips: Vec, } impl NetworkDeviceInfo { @@ -67,18 +71,26 @@ impl NetworkDeviceInfo { device.name, network.name )))?; let added_by = device.get_owner(&mut *transaction).await?; - let net_addr = network - .address - .first() - .ok_or(WebError::ObjectNotFound(format!( - "Failed to find the network address for network {}", - network.name - )))?; - let split_ip = split_ip(&wireguard_device.wireguard_ip, net_addr); + let split_ips: Vec = wireguard_device + .wireguard_ips + .iter() + .copied() + .map(|ip| { + network + .get_containing_network(ip) + .map(|net_addr| split_ip(&ip, &net_addr)) + .ok_or_else(|| { + WebError::ObjectNotFound(format!( + "Failed to find the network address for network {}", + network.name + )) + }) + }) + .collect::>()?; Ok(NetworkDeviceInfo { id: device.id, name: device.name, - assigned_ip: wireguard_device.wireguard_ip, + assigned_ips: wireguard_device.wireguard_ips, description: device.description, added_by: added_by.username, added_date: device.created, @@ -88,7 +100,7 @@ impl NetworkDeviceInfo { name: network.name, }, configured: device.configured, - split_ip, + split_ips, }) } } @@ -191,7 +203,7 @@ pub struct AddNetworkDevice { pub name: String, pub description: Option, pub location_id: i64, - pub assigned_ip: String, + pub assigned_ips: Vec, pub wireguard_pubkey: String, } @@ -201,55 +213,16 @@ pub struct AddNetworkDeviceResult { device: NetworkDeviceInfo, } -/// Checks if the IP address falls into the range of the network -/// and if it is not already assigned to another device. -async fn check_ip( - ip_addr: IpAddr, - network: &WireguardNetwork, - transaction: &mut PgConnection, -) -> Result<(), WebError> { - if let Some(network_address) = network.address.first() { - if !network_address.contains(ip_addr) { - return Err(WebError::BadRequest(format!( - "Provided IP address {ip_addr} is not in the network ({}) range {network_address}", - network.name, - ))); - } - if ip_addr == network_address.network() || ip_addr == network_address.broadcast() { - return Err(WebError::BadRequest(format!( - "Provided IP address {ip_addr} is network or broadcast address of network {}", - network.name - ))); - } - if ip_addr == network_address.ip() { - return Err(WebError::BadRequest(format!( - "Provided IP address {ip_addr} may overlap with the network's gateway IP in network {}", - network.name - ))); - } - - let device = Device::find_by_ip(transaction, ip_addr, network.id).await?; - if let Some(device) = device { - return Err(WebError::BadRequest(format!( - "Provided IP address {ip_addr} is already assigned to device {} in network {}", - device.name, network.name - ))); - } - } - - Ok(()) -} - #[derive(Deserialize)] pub struct IpAvailabilityCheck { - ip: String, + ips: Vec, } pub(crate) async fn check_ip_availability( _admin_role: AdminRole, Path(network_id): Path, State(appstate): State, - Json(ip): Json, + Json(check): Json, ) -> ApiResult { let mut transaction = appstate.pool.begin().await?; let network = WireguardNetwork::find_by_id(&appstate.pool, network_id) @@ -261,10 +234,15 @@ pub(crate) async fn check_ip_availability( ); WebError::BadRequest("Failed to check IP availability, network not found".into()) })?; - - let Ok(ip) = IpAddr::from_str(&ip.ip) else { + let ips = check + .ips + .iter() + .map(|ip| IpAddr::from_str(ip)) + .collect::, AddrParseError>>(); + let Ok(ips) = ips else { warn!( - "Failed to check IP availability for network with ID {network_id}, invalid IP address", + "Failed to check IP availability for network {}, invalid IP address", + network.name ); return Ok(ApiResponse { json: json!({ @@ -275,72 +253,46 @@ pub(crate) async fn check_ip_availability( }); }; - if let Some(network_address) = network.address.first() { - if !network_address.contains(ip) { + let mkresponse = |available: bool, valid: bool| { + Ok(ApiResponse { + json: json!({ + "available": available, + "valid": valid, + }), + status: StatusCode::OK, + }) + }; + return match network.can_assign_ips(&mut transaction, &ips, None).await { + Ok(_) => mkresponse(true, true), + Err(NetworkAddressError::NoContainingNetwork(name, ip, networks)) => { warn!( - "Provided device IP address is not in the network ({}) range {network_address}", - network.name + "Provided device IP address {ip} is not in the network {name} range: {networks:?}" ); - return Ok(ApiResponse { - json: json!({ - "available": false, - "valid": false, - }), - status: StatusCode::OK, - }); + mkresponse(false, false) } - if ip == network_address.network() || ip == network_address.broadcast() { + Err(NetworkAddressError::ReservedForGateway(name, ip)) => { warn!( - "Provided device IP address is network or broadcast address of network {}", - network.name + "Provided device IP address {ip} may overlap with the gateway's IP address on network {name}", ); - return Ok(ApiResponse { - json: json!({ - "available": false, - "valid": true, - }), - status: StatusCode::OK, - }); + mkresponse(false, true) } - if ip == network_address.ip() { - warn!( - "Provided device IP address may overlap with the gateway's IP address on network {}", - network.name - ); - return Ok(ApiResponse { - json: json!({ - "available": false, - "valid": true, - }), - status: StatusCode::OK, - }); + Err(NetworkAddressError::IsBroadcastAddress(name, ip)) => { + warn!("Provided device IP address {ip} is broadcast address of network {name}"); + mkresponse(false, true) } - } - - if let Some(device) = Device::find_by_ip(&mut *transaction, ip, network.id).await? { - warn!( - "Provided device IP is already assigned to device {} in network {}", - device.name, network.name - ); - Ok(ApiResponse { - json: json!({ - "available": false, - "valid": true, - }), - status: StatusCode::OK, - }) - } else { - Ok(ApiResponse { - json: json!({ - "available": true, - "valid": true, - }), - status: StatusCode::OK, - }) - } + Err(NetworkAddressError::IsNetworkAddress(name, ip)) => { + warn!("Provided device IP address {ip} is network address of network {name}"); + mkresponse(false, true) + } + Err(NetworkAddressError::AddressAlreadyAssigned(name, ip)) => { + warn!("Provided device IP {ip} is already assigned in network {name}"); + mkresponse(false, true) + } + Err(NetworkAddressError::DbError(err)) => Err(err)?, + }; } -pub(crate) async fn find_available_ip( +pub(crate) async fn find_available_ips( _admin_role: AdminRole, Path(network_id): Path, State(appstate): State, @@ -356,7 +308,8 @@ pub(crate) async fn find_available_ip( })?; let mut transaction = appstate.pool.begin().await?; - if let Some(network_address) = network.address.first() { + let mut split_ips = Vec::new(); + for network_address in &network.address { let net_ip = network_address.ip(); let net_network = network_address.network(); let net_broadcast = network_address.broadcast(); @@ -365,25 +318,37 @@ pub(crate) async fn find_available_ip( continue; } - // Break loop if IP is unassigned and return network device + // Break the loop if IP is unassigned and return network device if Device::find_by_ip(&mut *transaction, ip, network.id) .await? .is_none() { - let split_ip = split_ip(&ip, network_address); - transaction.commit().await?; - return Ok(ApiResponse { - json: json!(split_ip), - status: StatusCode::OK, - }); + split_ips.push(split_ip(&ip, network_address)); + break; } } } - Ok(ApiResponse { - json: json!({}), - status: StatusCode::NOT_FOUND, - }) + transaction.commit().await?; + if split_ips.len() != network.address.len() { + warn!( + "Failed to find available IPs for new device in network {} ({:?})", + network.name, network.address + ); + Ok(ApiResponse { + json: json!({}), + status: StatusCode::NOT_FOUND, + }) + } else { + debug!( + "Found addresses {:?} for new device i network {} ({:?})", + split_ips, network.name, network.address + ); + Ok(ApiResponse { + json: json!(split_ips), + status: StatusCode::OK, + }) + } } #[derive(Serialize, Deserialize, Debug)] @@ -391,7 +356,13 @@ pub struct StartNetworkDeviceSetup { name: String, description: Option, location_id: i64, - assigned_ip: String, + assigned_ips: Vec, +} + +impl From for WebError { + fn from(error: NetworkAddressError) -> Self { + WebError::BadRequest(error.to_string()) + } } // Setup a network device to be later configured by a CLI client @@ -440,18 +411,26 @@ pub(crate) async fn start_network_device_setup( device.id ); - let ip: IpAddr = setup_start.assigned_ip.parse().map_err(|e| { - error!("Failed to add network device {device_name}, invalid IP address: {e}"); - WebError::BadRequest("Invalid IP address".to_string()) - })?; - check_ip(ip, &network, &mut transaction).await?; + let ips = setup_start + .assigned_ips + .iter() + .map(|ip| IpAddr::from_str(ip)) + .collect::, AddrParseError>>() + .map_err(|e| { + let msg = + format!("Failed to add network device {device_name}, invalid IP address: {e}"); + error!(msg); + WebError::BadRequest(msg) + })?; + + network.can_assign_ips(&mut transaction, &ips, None).await?; let (_, config) = device - .add_to_network(&network, ip, &mut transaction) + .add_to_network(&network, &ips, &mut transaction) .await?; info!( - "User {} added a new unconfigured network device {device_name} with IP {ip} to network {}", + "User {} added a new unconfigured network device {device_name} with IPs {ips:?} to network {}", user.username, network.name ); @@ -603,14 +582,21 @@ pub(crate) async fn add_network_device( .save(&mut *transaction) .await?; - let ip: IpAddr = add_network_device.assigned_ip.parse().map_err(|e| { - error!("Failed to add network device {device_name}, invalid IP address: {e}"); - WebError::BadRequest("Invalid IP address".to_string()) - })?; - check_ip(ip, &network, &mut transaction).await?; + let ips = add_network_device + .assigned_ips + .iter() + .map(|ip| IpAddr::from_str(ip)) + .collect::, AddrParseError>>() + .map_err(|e| { + let msg = + format!("Failed to add network device {device_name}, invalid IP address: {e}"); + error!(msg); + WebError::BadRequest(msg) + })?; + network.can_assign_ips(&mut transaction, &ips, None).await?; let (network_info, config) = device - .add_to_network(&network, ip, &mut transaction) + .add_to_network(&network, &ips, &mut transaction) .await?; appstate.send_wireguard_event(GatewayEvent::DeviceCreated(DeviceInfo { @@ -630,7 +616,7 @@ pub(crate) async fn add_network_device( let template_locations = vec![TemplateLocation { name: config.network_name.clone(), - assigned_ip: config.address.to_string(), + assigned_ips: config.address.as_csv(), }]; send_new_device_added_email( @@ -665,7 +651,7 @@ pub(crate) async fn add_network_device( pub struct ModifyNetworkDevice { name: String, description: Option, - assigned_ip: String, + assigned_ips: Vec, } pub async fn modify_network_device( @@ -698,20 +684,17 @@ pub async fn modify_network_device( error!("Failed to update device {device_id}, device not found in any network"); WebError::ObjectNotFound(format!("Device {device_id} not found in any network")) })?; - let new_ip = IpAddr::from_str(&data.assigned_ip).map_err(|e| { - WebError::BadRequest(format!( - "Failed to update device {device_id}, invalid IP address: {e}" - )) - })?; - device.name = data.name; device.description = data.description; device.save(&mut *transaction).await?; // IP address has changed, so remove device from network and add it again with new IP address. - if new_ip != wireguard_network_device.wireguard_ip { - check_ip(new_ip, &device_network, &mut transaction).await?; - wireguard_network_device.wireguard_ip = new_ip; + if data.assigned_ips != *wireguard_network_device.wireguard_ips { + device_network + .can_assign_ips(&mut transaction, &data.assigned_ips, Some(device.id)) + .await?; + let old_ips = wireguard_network_device.wireguard_ips.clone(); + wireguard_network_device.wireguard_ips = data.assigned_ips; wireguard_network_device.update(&mut *transaction).await?; let device_info = DeviceInfo::from_device(&mut *transaction, device.clone()).await?; appstate.send_wireguard_event(GatewayEvent::DeviceModified(device_info)); @@ -730,10 +713,11 @@ pub async fn modify_network_device( } info!( - "User {} changed IP address of network device {} from {} to {new_ip} in network {}", + "User {} changed IP addresses of network device {} from {:?} to {:?} in network {}", session.user.username, device.name, - wireguard_network_device.wireguard_ip, + old_ips, + wireguard_network_device.wireguard_ips, device_network.name ); } @@ -748,7 +732,7 @@ pub async fn modify_network_device( } #[derive(Debug, Serialize)] -struct SplitIP { +struct SplitIp { network_part: String, modifiable_part: String, network_prefix: String, @@ -758,13 +742,13 @@ struct SplitIP { /// Splits the IP address (IPv4 or IPv6) into three parts: network part, modifiable part and prefix /// The network part is the part that can't be changed by the user. /// This is to display an IP address in the UI like this: 192.168.(1.1)/16, where the part in the parenthesis can be changed by the user. -// The algorithm works as follows: -// 1. Get the network address, last address and IP address segments, e.g. 192.1.1.1 would be [192, 1, 1, 1] -// 2. Iterate over the segments and compare the last address and network segments, as long as the current segments are equal, append the segment to the network part. -// If they are not equal, we found the first modifiable segment (one of the segments of an address that may change between hosts in the same network), -// append the rest of the segments to the modifiable part. -// 3. Join the segments with the delimiter and return the network part, modifiable part and the network prefix -fn split_ip(ip: &IpAddr, network: &IpNetwork) -> SplitIP { +/// The algorithm works as follows: +/// 1. Get the network address, last address and IP address segments, e.g. 192.1.1.1 would be [192, 1, 1, 1] +/// 2. Iterate over the segments and compare the last address and network segments, as long as the current segments are equal, append the segment to the network part. +/// If they are not equal, we found the first modifiable segment (one of the segments of an address that may change between hosts in the same network), +/// append the rest of the segments to the modifiable part. +/// 3. Join the segments with the delimiter and return the network part, modifiable part and the network prefix +fn split_ip(ip: &IpAddr, network: &IpNetwork) -> SplitIp { let network_addr = network.network(); let network_prefix = network.prefix(); @@ -822,7 +806,7 @@ fn split_ip(ip: &IpAddr, network: &IpNetwork) -> SplitIP { network_part.push_str(&format!("{formatted}{delimiter}")); } - SplitIP { + SplitIp { ip: ip.to_string(), network_part, modifiable_part, diff --git a/src/handlers/wireguard.rs b/src/handlers/wireguard.rs index bcd88ee54e..5de9691561 100644 --- a/src/handlers/wireguard.rs +++ b/src/handlers/wireguard.rs @@ -40,6 +40,7 @@ use crate::{ server_config, templates::TemplateLocation, wg_config::{parse_wireguard_config, ImportedDevice}, + AsCsv, }; /// Parse a string with comma-separated IP addresses. @@ -462,7 +463,7 @@ pub(crate) async fn import_network( let reserved_ips: Vec = imported_devices .iter() - .map(|dev| dev.wireguard_ip) + .flat_map(|dev| dev.wireguard_ips.clone()) .collect(); let (devices, gateway_events) = network .handle_imported_devices(&mut transaction, imported_devices) @@ -652,7 +653,7 @@ pub(crate) async fn add_device( ))); } - // save device + // save the device let mut transaction = appstate.pool.begin().await?; let device = Device::new( add_device.name, @@ -707,7 +708,7 @@ pub(crate) async fn add_device( .iter() .map(|c| TemplateLocation { name: c.network_name.clone(), - assigned_ip: c.address.to_string(), + assigned_ips: c.address.as_csv(), }) .collect(); @@ -823,7 +824,7 @@ pub(crate) async fn modify_device( if let Some(wireguard_network_device) = wireguard_network_device { let device_network_info = DeviceNetworkInfo { network_id: network.id, - device_wireguard_ip: wireguard_network_device.wireguard_ip, + device_wireguard_ips: wireguard_network_device.wireguard_ips, preshared_key: wireguard_network_device.preshared_key, is_authorized: wireguard_network_device.is_authorized, }; diff --git a/src/lib.rs b/src/lib.rs index b1cfaa7cbb..b9be84c176 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,7 +31,7 @@ use handlers::{ group::{bulk_assign_to_groups, list_groups_info}, network_devices::{ add_network_device, check_ip_availability, download_network_device_config, - find_available_ip, get_network_device, list_network_devices, modify_network_device, + find_available_ips, get_network_device, list_network_devices, modify_network_device, start_network_device_setup, start_network_device_setup_for_device, }, ssh_authorized_keys::{ @@ -513,7 +513,7 @@ pub fn build_webapp( // Network devices, as opposed to user devices .route("/device/network", post(add_network_device)) .route("/device/network", get(list_network_devices)) - .route("/device/network/ip/{network_id}", get(find_available_ip)) + .route("/device/network/ip/{network_id}", get(find_available_ips)) .route( "/device/network/ip/{network_id}", post(check_ip_availability), @@ -775,3 +775,21 @@ pub async fn init_vpn_location( Ok(token) } + +pub trait AsCsv { + fn as_csv(&self) -> String; +} + +impl AsCsv for I +where + I: ?Sized + std::iter::IntoIterator, + for<'a> &'a I: IntoIterator, + T: ToString, +{ + fn as_csv(&self) -> String { + self.into_iter() + .map(ToString::to_string) + .collect::>() + .join(",") + } +} diff --git a/src/templates.rs b/src/templates.rs index 84bd498868..926789861e 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -177,7 +177,7 @@ pub fn support_data_mail() -> Result { #[derive(Serialize, Debug, Clone)] pub struct TemplateLocation { pub name: String, - pub assigned_ip: String, + pub assigned_ips: String, } pub fn new_device_added_mail( @@ -408,11 +408,11 @@ mod test { let template_locations: Vec = vec![ TemplateLocation { name: "Test 01".into(), - assigned_ip: "10.0.0.10".into(), + assigned_ips: "10.0.0.10".into(), }, TemplateLocation { name: "Test 02".into(), - assigned_ip: "10.0.0.10".into(), + assigned_ips: "10.0.0.10".into(), }, ]; assert_ok!(new_device_added_mail( diff --git a/src/wg_config.rs b/src/wg_config.rs index 911de66508..74179d0620 100644 --- a/src/wg_config.rs +++ b/src/wg_config.rs @@ -20,7 +20,7 @@ pub struct ImportedDevice { pub user_id: Option, pub name: String, pub wireguard_pubkey: String, - pub wireguard_ip: IpAddr, + pub wireguard_ips: Vec, } #[derive(Debug, Error)] @@ -92,18 +92,20 @@ pub(crate) fn parse_wireguard_config( } } // Require at least one IP address. - let Some(network_address) = addresses.first() else { + if addresses.is_empty() { return Err(WireguardConfigParseError::MissingAddress); - }; - let allowed_ips = IpNetwork::new(network_address.network(), network_address.prefix())?; - let network_address = *network_address; + } + let allowed_ips = addresses + .iter() + .map(|addr| IpNetwork::new(addr.network(), addr.prefix())) + .collect::, _>>()?; let mut network = WireguardNetwork::new( pubkey.clone(), - addresses, + addresses.clone(), port, String::new(), dns, - vec![allowed_ips], + allowed_ips, false, DEFAULT_KEEPALIVE_INTERVAL, DEFAULT_DISCONNECT_THRESHOLD, @@ -118,18 +120,28 @@ pub(crate) fn parse_wireguard_config( let mut devices = Vec::new(); for peer in peer_sections { - let ip = peer + let allowed_ips = peer .get("AllowedIPs") .ok_or_else(|| WireguardConfigParseError::KeyNotFound("AllowedIPs"))?; - let ip_network: IpNetwork = ip.parse()?; - let ip = ip_network.ip(); - - // check if assigned IP collides with gateway IP - let net_ip = network_address.ip(); - let net_network = network_address.network(); - let net_broadcast = network_address.broadcast(); - if ip == net_ip || ip == net_network || ip == net_broadcast { - return Err(WireguardConfigParseError::InvalidPeerIp(ip)); + + let mut peer_addresses: Vec = Vec::new(); + for allowed_ip in allowed_ips.split(',') { + match allowed_ip.trim().parse::() { + Ok(network) => { + let ip = network.ip(); + // check if assigned IP collides with any of gateway IPs + for network_address in &addresses { + let net_ip = network_address.ip(); + let net_network = network_address.network(); + let net_broadcast = network_address.broadcast(); + if ip == net_ip || ip == net_network || ip == net_broadcast { + return Err(WireguardConfigParseError::InvalidPeerIp(ip)); + } + } + peer_addresses.push(ip); + } + Err(err) => return Err(WireguardConfigParseError::InvalidIp(err)), + } } let pubkey = peer @@ -148,7 +160,7 @@ pub(crate) fn parse_wireguard_config( user_id: None, name: pubkey.to_string(), wireguard_pubkey: pubkey.to_string(), - wireguard_ip: ip, + wireguard_ips: peer_addresses, }); } @@ -165,7 +177,7 @@ mod test { let config = " [Interface] PrivateKey = GAA2X3DW0WakGVx+DsGjhDpTgg50s1MlmrLf24Psrlg= - Address = 10.0.0.1/24, fc00::defc/64 + Address = 10.0.0.1/24 ListenPort = 55055 DNS = 10.0.0.2 @@ -186,11 +198,75 @@ mod test { ); assert_eq!(network.id, NoId); assert_eq!(network.name, "Y5ewP5RXstQd71gkmS/M0xL8wi0yVbbVY/ocLM4cQ1Y="); + assert_eq!(network.address, vec!["10.0.0.1/24".parse().unwrap()]); + assert_eq!(network.port, 55055); + assert_eq!( + network.pubkey, + "Y5ewP5RXstQd71gkmS/M0xL8wi0yVbbVY/ocLM4cQ1Y=" + ); + assert_eq!( + network.prvkey, + "GAA2X3DW0WakGVx+DsGjhDpTgg50s1MlmrLf24Psrlg=" + ); + assert_eq!(network.endpoint, ""); + assert_eq!(network.dns, Some("10.0.0.2".to_string())); + assert_eq!(network.allowed_ips, vec!["10.0.0.0/24".parse().unwrap()]); + assert_eq!(network.connected_at, None); + + assert_eq!(devices.len(), 2); + + let device1 = &devices[0]; + assert_eq!( + device1.wireguard_pubkey, + "2LYRr2HgSSpGCdXKDDAlcFe0Uuc6RR8TFgSquNc9VAE=" + ); + assert_eq!( + device1.wireguard_ips, + vec!["10.0.0.10".parse::().unwrap()] + ); + + let device2 = &devices[1]; + assert_eq!( + device2.wireguard_pubkey, + "OLQNaEH3FxW0hiodaChEHoETzd+7UzcqIbsLs+X8rD0=" + ); + assert_eq!( + device2.wireguard_ips, + vec!["10.0.0.11".parse::().unwrap()] + ); + } + + #[test] + fn test_parse_config_dualstack() { + let config = " + [Interface] + PrivateKey = GAA2X3DW0WakGVx+DsGjhDpTgg50s1MlmrLf24Psrlg= + Address = 10.0.0.1/24,fc00::/112 + ListenPort = 55055 + DNS = 10.0.0.2 + + [Peer] + PublicKey = 2LYRr2HgSSpGCdXKDDAlcFe0Uuc6RR8TFgSquNc9VAE= + AllowedIPs = 10.0.0.10/24,fc00::10 + PersistentKeepalive = 300 + + [Peer] + PublicKey = OLQNaEH3FxW0hiodaChEHoETzd+7UzcqIbsLs+X8rD0= + AllowedIPs = 10.0.0.11/24,fc00::11 + PersistentKeepalive = 300 + "; + let (network, devices) = parse_wireguard_config(config).unwrap(); + assert_eq!( + network.prvkey, + "GAA2X3DW0WakGVx+DsGjhDpTgg50s1MlmrLf24Psrlg=" + ); + assert_eq!(network.id, NoId); + assert_eq!(network.name, "Y5ewP5RXstQd71gkmS/M0xL8wi0yVbbVY/ocLM4cQ1Y="); assert_eq!( network.address, vec![ "10.0.0.1/24".parse().unwrap(), - "fc00::defc/64".parse().unwrap() + "fc00::/112".parse().unwrap() ] ); assert_eq!(network.port, 55055); @@ -204,7 +280,13 @@ mod test { ); assert_eq!(network.endpoint, ""); assert_eq!(network.dns, Some("10.0.0.2".to_string())); - assert_eq!(network.allowed_ips, vec!["10.0.0.0/24".parse().unwrap()]); + assert_eq!( + network.allowed_ips, + vec![ + "10.0.0.0/24".parse().unwrap(), + "fc00::/112".parse().unwrap(), + ] + ); assert_eq!(network.connected_at, None); assert_eq!(devices.len(), 2); @@ -214,13 +296,25 @@ mod test { device1.wireguard_pubkey, "2LYRr2HgSSpGCdXKDDAlcFe0Uuc6RR8TFgSquNc9VAE=" ); - assert_eq!(device1.wireguard_ip.to_string(), "10.0.0.10"); + assert_eq!( + device1.wireguard_ips, + vec![ + "10.0.0.10".parse::().unwrap(), + "fc00::10".parse::().unwrap() + ] + ); let device2 = &devices[1]; assert_eq!( device2.wireguard_pubkey, "OLQNaEH3FxW0hiodaChEHoETzd+7UzcqIbsLs+X8rD0=" ); - assert_eq!(device2.wireguard_ip.to_string(), "10.0.0.11"); + assert_eq!( + device2.wireguard_ips, + vec![ + "10.0.0.11".parse::().unwrap(), + "fc00::11".parse::().unwrap(), + ] + ); } } diff --git a/src/wireguard_peer_disconnect.rs b/src/wireguard_peer_disconnect.rs index c1a4498b25..eb79a32b00 100644 --- a/src/wireguard_peer_disconnect.rs +++ b/src/wireguard_peer_disconnect.rs @@ -106,7 +106,7 @@ pub async fn run_periodic_peer_disconnect( device, network_info: vec![DeviceNetworkInfo { network_id: location.id, - device_wireguard_ip: device_network_config.wireguard_ip, + device_wireguard_ips: device_network_config.wireguard_ips, preshared_key: device_network_config.preshared_key, is_authorized: device_network_config.is_authorized, }], diff --git a/templates/mail_new_device_added.tera b/templates/mail_new_device_added.tera index 3b24e2972d..4309486c69 100644 --- a/templates/mail_new_device_added.tera +++ b/templates/mail_new_device_added.tera @@ -11,7 +11,7 @@ assigned_ip -> ip of device in location {# Generate locations list#} {% macro device_locations(locations) %} {% for location in locations %} -{{ macros::paragraph_with_title(title=location.name ~ ":", content=location.assigned_ip)}} +{{ macros::paragraph_with_title(title=location.name ~ ":", content=location.assigned_ips)}} {% endfor %} {% endmacro device_locations %} {# mail content #} diff --git a/tests/integration/auth.rs b/tests/integration/auth.rs index f904e47b13..3ff4bf50bc 100644 --- a/tests/integration/auth.rs +++ b/tests/integration/auth.rs @@ -1,6 +1,6 @@ use std::time::SystemTime; -use chrono::NaiveDateTime; +use chrono::DateTime; use claims::{assert_err, assert_ok}; use defguard::{ auth::{TOTP_CODE_DIGITS, TOTP_CODE_VALIDITY_PERIOD}, @@ -835,7 +835,7 @@ async fn test_session_cookie(_: PgPoolOptions, options: PgConnectOptions) { // Forcibly expire the session query!( "UPDATE session SET expires = $1 WHERE id = $2", - NaiveDateTime::UNIX_EPOCH, + DateTime::UNIX_EPOCH.naive_utc(), session_id ) .execute(&pool) diff --git a/tests/integration/wireguard.rs b/tests/integration/wireguard.rs index 92f9490273..775ba14934 100644 --- a/tests/integration/wireguard.rs +++ b/tests/integration/wireguard.rs @@ -306,7 +306,7 @@ async fn test_device_permissions(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::CREATED); let device = json!({"devices": [{ "name": "device_2", - "wireguard_ip": "10.0.0.3", + "wireguard_ips": ["10.0.0.3"], "wireguard_pubkey": "TJgN9JzUF5zdZAPYD96G/Wys2M3TvaT5TIrErUl20nI=", "user_id": 1, "created": "2023-05-05T23:56:04" @@ -330,7 +330,7 @@ async fn test_device_permissions(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::CREATED); let device = json!({"devices": [{ "name": "device_4", - "wireguard_ip": "10.0.0.5", + "wireguard_ips": ["10.0.0.5"], "wireguard_pubkey": "gTMFF29nNLkJR1UhoiO3ZJLF60h2hW+WxmIu5DGJ0B4=", "user_id": 2, "created": "2023-05-05T23:56:04" @@ -359,7 +359,7 @@ async fn test_device_permissions(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::CREATED); let device = json!({"devices": [{ "name": "device_6", - "wireguard_ip": "10.0.0.7", + "wireguard_ips": ["10.0.0.7"], "wireguard_pubkey": "xGLqgxVAnmk9+tsj5X/wzwouwx3bF1b3W+VWAb4NLjM=", "user_id": 2, "created": "2023-05-05T23:56:04" @@ -383,7 +383,7 @@ async fn test_device_permissions(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::FORBIDDEN); let device = json!({"devices": [{ "name": "device_8", - "wireguard_ip": "10.0.0.9", + "wireguard_ips": ["10.0.0.9"], "wireguard_pubkey": "A2cg4qMe+s0MSFlV6xyhz7XY6PrET6mli9GVSUshXAk=", "user_id": 1, "created": "2023-05-05T23:56:04" @@ -502,14 +502,14 @@ async fn test_device_pubkey(_: PgPoolOptions, options: PgConnectOptions) { // try to create multiple devices let devices = json!({"devices": [{ "name": "device_2", - "wireguard_ip": "10.0.0.9", + "wireguard_ips": ["10.0.0.9"], "wireguard_pubkey": "o/8q3kmv5nnbrcb/7aceQWGE44a0yI707wObXRyyWGU=", "user_id": 1, "created": "2023-05-05T23:56:04" }, { "name": "device_3", - "wireguard_ip": "10.0.0.10", + "wireguard_ips": ["10.0.0.10"], "wireguard_pubkey": "invalid_key", "user_id": 1, "created": "2023-05-05T23:56:04" diff --git a/tests/integration/wireguard_network_allowed_groups.rs b/tests/integration/wireguard_network_allowed_groups.rs index 4509e7c108..a8f123fc5b 100644 --- a/tests/integration/wireguard_network_allowed_groups.rs +++ b/tests/integration/wireguard_network_allowed_groups.rs @@ -1,7 +1,10 @@ +use std::net::IpAddr; + use claims::assert_err; use defguard::{ db::{models::device::DeviceType, Device, GatewayEvent, Group, Id, User, WireguardNetwork}, handlers::{wireguard::ImportedNetworkData, Auth}, + AsCsv, }; use matches::assert_matches; use reqwest::StatusCode; @@ -380,7 +383,10 @@ async fn test_import_network_existing_devices(_: PgPoolOptions, options: PgConne response.devices[0].wireguard_pubkey, "l07+qPWs4jzW3Gp1DKbHgBMRRm4Jg3q2BJxw0ZYl6c4=" ); - assert_eq!(response.devices[0].wireguard_ip.to_string(), "10.0.0.12"); + assert_eq!( + response.devices[0].wireguard_ips, + ["10.0.0.12".parse::().unwrap()] + ); let network = response.network; let peers = network.get_peers(&client_state.pool).await.unwrap(); @@ -399,7 +405,7 @@ async fn test_import_network_existing_devices(_: PgPoolOptions, options: PgConne assert_eq!(device_info.network_info.len(), 1); assert_eq!(device_info.network_info[0].network_id, 1); assert_eq!( - device_info.network_info[0].device_wireguard_ip.to_string(), + device_info.network_info[0].device_wireguard_ips.as_csv(), peers[1].allowed_ips[0] ); @@ -410,7 +416,7 @@ async fn test_import_network_existing_devices(_: PgPoolOptions, options: PgConne assert_eq!(device_info.network_info.len(), 1); assert_eq!(device_info.network_info[0].network_id, 1); assert_eq!( - device_info.network_info[0].device_wireguard_ip.to_string(), + device_info.network_info[0].device_wireguard_ips.as_csv(), peers[0].allowed_ips[0] ); @@ -505,8 +511,8 @@ PersistentKeepalive = 300 assert_eq!(device_info.network_info.len(), 1); assert_eq!(device_info.network_info[0].network_id, 1); assert_eq!( - device_info.network_info[0].device_wireguard_ip, - mapped_devices[0].wireguard_ip + device_info.network_info[0].device_wireguard_ips, + mapped_devices[0].wireguard_ips, ); let GatewayEvent::DeviceCreated(device_info) = wg_rx.try_recv().unwrap() else { @@ -519,8 +525,8 @@ PersistentKeepalive = 300 assert_eq!(device_info.network_info.len(), 1); assert_eq!(device_info.network_info[0].network_id, 1); assert_eq!( - device_info.network_info[0].device_wireguard_ip, - mapped_devices[1].wireguard_ip + device_info.network_info[0].device_wireguard_ips, + mapped_devices[1].wireguard_ips, ); assert_err!(wg_rx.try_recv()); diff --git a/tests/integration/wireguard_network_devices.rs b/tests/integration/wireguard_network_devices.rs index b5d6d9a782..d3e12a0cbc 100644 --- a/tests/integration/wireguard_network_devices.rs +++ b/tests/integration/wireguard_network_devices.rs @@ -90,19 +90,19 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { // ip suggestions let response = client.get("/api/v1/device/network/ip/1").send().await; assert_eq!(response.status(), StatusCode::OK); - let res = response.json::().await; - let ip = res["ip"].as_str().unwrap(); - let ip = ip.parse::().unwrap(); - let net_ip = IpAddr::from_str("10.1.1.1").unwrap(); - let network_range = IpNetwork::new(net_ip, 24).unwrap(); - assert!(network_range.contains(ip)); + #[derive(Deserialize)] + struct SplitIp { + ip: IpAddr, + } + let ips: Vec = response.json().await; + assert_eq!(ips.len(), 1); + let network_range = IpNetwork::from_str("10.1.1.1/24").unwrap(); + assert!(network_range.contains(ips[0].ip)); // checking whether ip is valid/available - let ip_check = json!( - { - "ip": "10.1.1.2".to_string(), - } - ); + let ip_check = json!({ + "ips": ["10.1.1.2".to_string()], + }); let response = client .post("/api/v1/device/network/ip/1") .json(&ip_check) @@ -113,11 +113,9 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { assert!(res.available); assert!(res.valid); - let ip_check = json!( - { - "ip": "10.1.1.0".to_string(), - } - ); + let ip_check = json!({ + "ips": ["10.1.1.0".to_string()], + }); let response = client .post("/api/v1/device/network/ip/1") .json(&ip_check) @@ -128,11 +126,9 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { assert!(!res.available); assert!(res.valid); - let ip_check = json!( - { - "ip": "10.1.1.1".to_string(), - } - ); + let ip_check = json!({ + "ips": ["10.1.1.1".to_string()], + }); let response = client .post("/api/v1/device/network/ip/1") .json(&ip_check) @@ -143,11 +139,9 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { assert!(!res.available); assert!(res.valid); - let ip_check = json!( - { - "ip": "10.1.1.abc".to_string(), - } - ); + let ip_check = json!({ + "ips": ["10.1.1.abc".to_string()], + }); let response = client .post("/api/v1/device/network/ip/1") .json(&ip_check) @@ -162,7 +156,7 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { let network_device = AddNetworkDevice { name: "device-1".into(), wireguard_pubkey: "LQKsT6/3HWKuJmMulH63R8iK+5sI8FyYEL6WDIi6lQU=".into(), - assigned_ip: ip.to_string(), + assigned_ips: ips.iter().map(|ip| ip.ip.to_string()).collect(), location_id: 1, description: None, }; @@ -190,7 +184,7 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { let modify_device = json!({ "name": "device-1", "description": "new description", - "assigned_ip": "10.1.1.3" + "assigned_ips": ["10.1.1.3"] }); let response = client .put(format!("/api/v1/device/network/{device_id}")) @@ -200,11 +194,10 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); let json = response.json::().await; let description = json["description"].as_str().unwrap(); - let assigned_ip = json["assigned_ip"].as_str().unwrap(); assert_eq!(description, "new description"); assert_eq!( - assigned_ip, - IpAddr::from_str("10.1.1.3").unwrap().to_string() + json["assigned_ips"], + serde_json::from_str::("[\"10.1.1.3\"]").unwrap() ); let device = Device::find_by_id(&client_state.pool, device_id) .await @@ -240,7 +233,7 @@ async fn test_network_devices(_: PgPoolOptions, options: PgConnectOptions) { { "name": "device-2", "description": "new description", - "assigned_ip": "10.1.1.10", + "assigned_ips": ["10.1.1.10"], "location_id": 1, } ); diff --git a/tests/integration/wireguard_network_import.rs b/tests/integration/wireguard_network_import.rs index 062c202eed..76e2e01b7e 100644 --- a/tests/integration/wireguard_network_import.rs +++ b/tests/integration/wireguard_network_import.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use defguard::{ db::{ models::{ @@ -140,7 +142,10 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { .unwrap() .unwrap(); assert_eq!(user_device_1.networks.len(), 2); - assert_eq!(user_device_1.networks[1].device_wireguard_ip, "10.0.0.12"); + assert_eq!( + user_device_1.networks[1].device_wireguard_ips, + vec!["10.0.0.12"] + ); // generated IP for other existing device assert_matches!(wg_rx.try_recv().unwrap(), GatewayEvent::DeviceCreated(..)); let user_device_2 = UserDevice::from_device(&pool, device_2) @@ -154,7 +159,10 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(devices.len(), 2); let mut device1 = devices[0].clone(); - assert_eq!(device1.wireguard_ip.to_string(), "10.0.0.10"); + assert_eq!( + device1.wireguard_ips, + ["10.0.0.10".parse::().unwrap()] + ); assert_eq!( device1.wireguard_pubkey, "2LYRr2HgSSpGCdXKDDAlcFe0Uuc6RR8TFgSquNc9VAE=" @@ -163,7 +171,10 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(device1.user_id, None); let mut device2 = devices[1].clone(); - assert_eq!(device2.wireguard_ip.to_string(), "10.0.0.11"); + assert_eq!( + device2.wireguard_ips, + ["10.0.0.11".parse::().unwrap()] + ); assert_eq!( device2.wireguard_pubkey, "OLQNaEH3FxW0hiodaChEHoETzd+7UzcqIbsLs+X8rD0=" @@ -210,23 +221,23 @@ async fn test_config_import(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(user_info.devices.len(), 4); assert_eq!(user_info.devices[0].device.name, "test device"); assert_eq!( - user_info.devices[0].networks[1].device_wireguard_ip, - "10.0.0.12" + user_info.devices[0].networks[1].device_wireguard_ips, + vec!["10.0.0.12"] ); assert_eq!(user_info.devices[1].device.name, "another test device"); assert_eq!( - user_info.devices[1].networks[1].device_wireguard_ip, - "10.0.0.2" + user_info.devices[1].networks[1].device_wireguard_ips, + vec!["10.0.0.2"] ); assert_eq!(user_info.devices[2].device.name, "device_1"); assert_eq!( - user_info.devices[2].networks[1].device_wireguard_ip, - "10.0.0.10" + user_info.devices[2].networks[1].device_wireguard_ips, + vec!["10.0.0.10"] ); assert_eq!(user_info.devices[3].device.name, "device_2"); assert_eq!( - user_info.devices[3].networks[1].device_wireguard_ip, - "10.0.0.11" + user_info.devices[3].networks[1].device_wireguard_ips, + vec!["10.0.0.11"] ); } diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 352e13126e..b9a528e1f5 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -2206,7 +2206,7 @@ Any other requests you can reach us at: support@defguard.net labels: { name: 'Device Name', location: 'Location', - assignedIp: 'IP Address', + assignedIps: 'IP Addresses', description: 'Description', addedBy: 'Added By', addedAt: 'Add Date', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index 57e3ef7254..ca65d11546 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -5200,9 +5200,9 @@ type RootTranslation = { */ location: string /** - * I​P​ ​A​d​d​r​e​s​s + * I​P​ ​A​d​d​r​e​s​s​e​s */ - assignedIp: string + assignedIps: string /** * D​e​s​c​r​i​p​t​i​o​n */ @@ -11071,9 +11071,9 @@ export type TranslationFunctions = { */ location: () => LocalizedString /** - * IP Address + * IP Addresses */ - assignedIp: () => LocalizedString + assignedIps: () => LocalizedString /** * Description */ diff --git a/web/src/pages/devices/components/DevicesList/DevicesList.tsx b/web/src/pages/devices/components/DevicesList/DevicesList.tsx index 4780c25869..dc71bf6bff 100644 --- a/web/src/pages/devices/components/DevicesList/DevicesList.tsx +++ b/web/src/pages/devices/components/DevicesList/DevicesList.tsx @@ -6,6 +6,7 @@ import { useCallback, useMemo } from 'react'; import { shallow } from 'zustand/shallow'; import { useI18nContext } from '../../../../i18n/i18n-react'; +import { ListCellTags } from '../../../../shared/components/Layout/ListCellTags/ListCellTags'; import SvgIconCopy from '../../../../shared/components/svg/IconCopy'; import { DeviceAvatar } from '../../../../shared/defguard-ui/components/Layout/DeviceAvatar/DeviceAvatar'; import { EditButton } from '../../../../shared/defguard-ui/components/Layout/EditButton/EditButton'; @@ -21,6 +22,7 @@ import useApi from '../../../../shared/hooks/useApi'; import { useClipboard } from '../../../../shared/hooks/useClipboard'; import { useToaster } from '../../../../shared/hooks/useToaster'; import { StandaloneDevice } from '../../../../shared/types'; +import { ListCellTag } from '../../../acl/AclIndexPage/components/shared/types'; import { useDeleteStandaloneDeviceModal } from '../../hooks/useDeleteStandaloneDeviceModal'; import { useDevicesPage } from '../../hooks/useDevicesPage'; import { useEditStandaloneDeviceModal } from '../../hooks/useEditStandaloneDeviceModal'; @@ -48,7 +50,7 @@ export const DevicesList = () => { sortDirection: ListSortDirection.DESC, }, { key: 1, text: labels.location() }, - { key: 2, text: labels.assignedIp() }, + { key: 2, text: labels.assignedIps() }, { key: 3, text: labels.description() }, { key: 4, text: labels.addedBy() }, { key: 5, text: labels.addedAt() }, @@ -83,7 +85,16 @@ export const DevicesList = () => { }; const DeviceRow = (props: StandaloneDevice) => { - const { description, id, location, name, added_by, added_date, assigned_ip } = props; + const { description, id, location, name, added_by, added_date, assigned_ips } = props; + const ipsTags = useMemo( + (): ListCellTag[] => + assigned_ips.map((ip) => ({ + key: ip, + label: ip, + displayAsTag: false, + })), + [assigned_ips], + ); const formatDate = useMemo(() => { const day = dayjs(added_date); return day.format('DD.MM.YYYY | HH:mm'); @@ -112,20 +123,7 @@ const DeviceRow = (props: StandaloneDevice) => { />
- { - void writeToClipboard(assigned_ip); - }} - > - - - } - /> +
diff --git a/web/src/pages/devices/components/DevicesList/style.scss b/web/src/pages/devices/components/DevicesList/style.scss index de0166f604..a8c0a2131c 100644 --- a/web/src/pages/devices/components/DevicesList/style.scss +++ b/web/src/pages/devices/components/DevicesList/style.scss @@ -1,7 +1,7 @@ @mixin spacing { display: inline-grid; grid-template-rows: 1fr; - grid-template-columns: 250px repeat(2, 150px) 1fr 180px 200px 50px; + grid-template-columns: 250px 150px 350px 1fr 180px 200px 50px; column-gap: 15px; } diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx index 791f77a622..49e7fcb1a7 100644 --- a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupCliStep/SetupCliStep.tsx @@ -80,7 +80,7 @@ export const SetupCliStep = () => { const defaultValues = useMemo(() => { if (initIpResponse && locationOptions) { const res: AddStandaloneDeviceFormFields = { - modifiableIpPart: initIpResponse.modifiable_part, + modifiableIpParts: initIpResponse.map((ip) => ip.modifiable_part), generationChoice: WGConfigGenChoice.AUTO, location_id: locationOptions[0].value, name: '', @@ -95,7 +95,7 @@ export const SetupCliStep = () => { const handleSubmit = useCallback( async (values: AddStandaloneDeviceFormFields) => { const response = await mutateAsync({ - assigned_ip: values.modifiableIpPart, + assigned_ips: values.modifiableIpParts, location_id: values.location_id, name: values.name, description: values.description, diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx index 1674a19461..d732966625 100644 --- a/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/steps/SetupManualStep/SetupManualStep.tsx @@ -82,7 +82,7 @@ export const SetupManualStep = () => { }); } const response = await mutateAsync({ - assigned_ip: values.modifiableIpPart, + assigned_ips: values.modifiableIpParts, location_id: values.location_id, name: values.name, description: values.description, @@ -100,7 +100,7 @@ export const SetupManualStep = () => { const defaultFormValues = useMemo(() => { if (locationOptions && initialIpResponse) { const res: AddStandaloneDeviceFormFields = { - modifiableIpPart: initialIpResponse.modifiable_part, + modifiableIpParts: initialIpResponse.map((ip) => ip.modifiable_part), generationChoice: WGConfigGenChoice.AUTO, location_id: locationOptions[0].value, name: '', diff --git a/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts b/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts index d971f27bc0..0c43cda035 100644 --- a/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts +++ b/web/src/pages/devices/modals/AddStandaloneDeviceModal/types.ts @@ -19,8 +19,8 @@ export enum WGConfigGenChoice { export type AddStandaloneDeviceFormFields = { name: string; location_id: number; - modifiableIpPart: string; - wireguard_pubkey: string; + modifiableIpParts: string[]; + wireguard_pubkey?: string; generationChoice: WGConfigGenChoice; description?: string; }; diff --git a/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx b/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx index 76ffc5062a..362be4b095 100644 --- a/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx +++ b/web/src/pages/devices/modals/EditStandaloneDeviceModal/EditStandaloneModal.tsx @@ -103,15 +103,15 @@ const ModalContent = () => { const defaultValues = useMemo(() => { if (locationOptions && device) { - let modifiablePart = device.assigned_ip.split(device.split_ip.network_part)[1]; - - if (modifiablePart === undefined) { - modifiablePart = device.split_ip.modifiable_part; - } + const modifiableParts = device.assigned_ips.map( + (ip, i) => + ip.split(device.split_ips[i].network_part)[1] || + device.split_ips[i].modifiable_part, + ); const res: AddStandaloneDeviceFormFields = { name: device?.name, - modifiableIpPart: modifiablePart, + modifiableIpParts: modifiableParts, location_id: device.location.id, description: device.description, generationChoice: WGConfigGenChoice.AUTO, @@ -126,7 +126,7 @@ const ModalContent = () => { async (values: AddStandaloneDeviceFormFields) => { if (device) { await mutateAsync({ - assigned_ip: values.modifiableIpPart, + assigned_ips: values.modifiableIpParts, id: device.id, name: values.name, description: values.description, @@ -151,10 +151,10 @@ const ModalContent = () => { onSubmit={handleSubmit} submitSubject={submitSubject} reservedNames={reservedDeviceNames} - initialIpRecommendation={{ - ip: device.assigned_ip, - ...device.split_ip, - }} + initialIpRecommendation={device.assigned_ips.map((_, i) => ({ + ip: device.assigned_ips[i], + ...device.split_ips[i], + }))} /> )} {!defaultValues && ( diff --git a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx index 03c1882f3e..b4c03e185d 100644 --- a/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx +++ b/web/src/pages/devices/modals/components/StandaloneDeviceModalForm/StandaloneDeviceModalForm.tsx @@ -47,7 +47,7 @@ export const StandaloneDeviceModalForm = ({ reservedNames, initialIpRecommendation, }: Props) => { - const [internalRecommendedIp, setInternalRecommendedIp] = useState< + const [internalRecommendedIps, setInternalRecommendedIps] = useState< GetAvailableLocationIpResponse | undefined >(); const { LL } = useI18nContext(); @@ -112,8 +112,8 @@ export const StandaloneDeviceModalForm = ({ return !reservedNames.includes(value.trim()); }, LL.form.error.reservedName()), location_id: z.number(), - description: z.string(), - modifiableIpPart: z.string().min(1, LL.form.error.required()), + description: z.string().optional(), + modifiableIpParts: z.array(z.string().min(1, LL.form.error.required())), generationChoice: z.nativeEnum(WGConfigGenChoice), wireguard_pubkey: z.string().optional(), }) @@ -156,26 +156,34 @@ export const StandaloneDeviceModalForm = ({ const generationChoiceValue = watch('generationChoice'); + function newIps(formIps: string[]): string[] { + const initialIpsSet = new Set( + initialIpRecommendation.map((ip) => ip.network_part + ip.modifiable_part), + ); + const formIpsSet = new Set(formIps); + return Array.from(formIpsSet.difference(initialIpsSet)); + } const submitHandler: SubmitHandler = async ( formValues, ) => { const values = formValues; - const { modifiableIpPart } = values; + const { modifiableIpParts: modifiableIpPart } = values; values.description = values.description?.trim(); values.name = values.name.trim(); - const currentIpResp = internalRecommendedIp ?? initialIpRecommendation; - values.modifiableIpPart = - currentIpResp.network_part + formValues.modifiableIpPart.trim(); + const currentIpResp = internalRecommendedIps ?? initialIpRecommendation; + values.modifiableIpParts = currentIpResp.map( + (resp, i) => resp.network_part + formValues.modifiableIpParts[i].trim(), + ); if ( mode === StandaloneDeviceModalFormMode.EDIT && - modifiableIpPart === defaults.modifiableIpPart + modifiableIpPart === defaults.modifiableIpParts ) { await onSubmit(values); return; } try { const response = await validateLocationIp({ - ip: values.modifiableIpPart, + ips: newIps(values.modifiableIpParts), location: values.location_id, }); const { available, valid } = response; @@ -183,12 +191,12 @@ export const StandaloneDeviceModalForm = ({ await onSubmit(values); } else { if (!available) { - setError('modifiableIpPart', { + setError('modifiableIpParts', { message: LL.form.error.reservedIp(), }); } if (!valid) { - setError('modifiableIpPart', { + setError('modifiableIpParts', { message: LL.form.error.invalidIp(), }); } @@ -207,9 +215,9 @@ export const StandaloneDeviceModalForm = ({ locationId, }) .then((resp) => { - setInternalRecommendedIp(resp); - resetField('modifiableIpPart', { - defaultValue: resp.modifiable_part, + setInternalRecommendedIps(resp); + resetField('modifiableIpParts', { + defaultValue: resp.map((r) => r.modifiable_part), }); }) .finally(() => { @@ -236,36 +244,35 @@ export const StandaloneDeviceModalForm = ({ return () => sub.unsubscribe(); }, [submitSubject]); + const recommendedIps = internalRecommendedIps || initialIpRecommendation; return (
-
- + + + {recommendedIps.map((ip, i) => ( -
- + ))} {mode === StandaloneDeviceModalFormMode.CREATE_MANUAL && ( <> {
@@ -163,7 +165,7 @@ const MainCardContent = ({ data }: MainCardContentProps) => {
@@ -202,21 +204,22 @@ const MainCardContent = ({ data }: MainCardContentProps) => { interface NameBoxProps { name: string; publicIp?: string; - wireguardIp?: string; + wireguardIps?: string[]; } -const NameBox = ({ name, publicIp, wireguardIp }: NameBoxProps) => { +const NameBox = ({ name, publicIp, wireguardIps }: NameBoxProps) => { return (
{name} - {(publicIp || wireguardIp) && ( + {(isPresent(publicIp) || isPresent(wireguardIps)) && (
{publicIp !== undefined && publicIp.length > 0 && ( )} - {wireguardIp !== undefined && wireguardIp.length > 0 && ( - - )} + {isPresent(wireguardIps) && + wireguardIps.map((ip) => ( + + ))}
)}
@@ -332,7 +335,7 @@ const ExpandedDeviceCard = ({ data }: ExpandedDeviceCardProps) => {
diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss index a65daf9b9b..9e010fa1b6 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionCard/style.scss @@ -43,7 +43,7 @@ & > .device { display: grid; - grid-template-rows: 40px 50px; + grid-template-rows: auto 50px; grid-template-columns: 1fr; width: 100%; row-gap: 20px; @@ -235,7 +235,7 @@ max-width: 100%; overflow: hidden; display: grid; - grid-template-rows: 1fr 15px; + grid-template-rows: auto 1fr; grid-template-columns: 1fr; row-gap: 8px; overflow: hidden; @@ -252,12 +252,12 @@ grid-row: 2; grid-column: 1 / 2; display: flex; - flex-flow: row nowrap; + flex-flow: row wrap; overflow: hidden; align-items: center; align-content: center; justify-content: flex-start; - column-gap: 5px; + gap: 5px; } } diff --git a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx index e26904329e..d7b026f3fc 100644 --- a/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx +++ b/web/src/pages/overview/OverviewConnectedUsers/UserConnectionListItem/UserConnectionListItem.tsx @@ -83,7 +83,7 @@ const UserRow = ({ data }: UserRowProps) => {
@@ -138,7 +138,7 @@ const DeviceRow = ({ data }: DeviceRowProps) => {
- +
@@ -194,14 +194,16 @@ const ActiveDevices = ({ data }: ActiveDevicesProps) => { interface DeviceIpsProps { publicIp: string; - wireguardIp: string; + wireguardIps: string[]; } -const DeviceIps = ({ publicIp, wireguardIp }: DeviceIpsProps) => { +const DeviceIps = ({ publicIp, wireguardIps }: DeviceIpsProps) => { return (
- + {wireguardIps.map((ip) => ( + + ))}
); }; diff --git a/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx b/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx index 58de136eaa..b2b9ad6419 100644 --- a/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx +++ b/web/src/pages/users/UserProfile/UserDevices/DeviceCard/DeviceCard.tsx @@ -7,6 +7,7 @@ import { isUndefined, orderBy } from 'lodash-es'; import { useMemo, useState } from 'react'; import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { ListCellTags } from '../../../../../shared/components/Layout/ListCellTags/ListCellTags'; import IconClip from '../../../../../shared/components/svg/IconClip'; import SvgIconCollapse from '../../../../../shared/components/svg/IconCollapse'; import SvgIconCopy from '../../../../../shared/components/svg/IconCopy'; @@ -26,6 +27,7 @@ import { useUserProfileStore } from '../../../../../shared/hooks/store/useUserPr import { useClipboard } from '../../../../../shared/hooks/useClipboard'; import { Device, DeviceNetworkInfo } from '../../../../../shared/types'; import { sortByDate } from '../../../../../shared/utils/sortByDate'; +import { ListCellTag } from '../../../../acl/AclIndexPage/components/shared/types'; import { useDeleteDeviceModal } from '../hooks/useDeleteDeviceModal'; import { useDeviceConfigModal } from '../hooks/useDeviceConfigModal'; import { useEditDeviceModal } from '../hooks/useEditDeviceModal'; @@ -234,11 +236,20 @@ const DeviceLocation = ({ network_gateway_ip, last_connected_ip, last_connected_at, - device_wireguard_ip, + device_wireguard_ips, }, }: DeviceLocationProps) => { const { LL } = useI18nContext(); const { writeToClipboard } = useClipboard(); + const ipsTags = useMemo( + (): ListCellTag[] => + device_wireguard_ips.map((ip) => ({ + key: ip, + label: ip, + displayAsTag: false, + })), + [device_wireguard_ips], + ); return (
@@ -282,20 +293,7 @@ const DeviceLocation = ({
- { - void writeToClipboard(device_wireguard_ip); - }} - > - - - } - /> +
diff --git a/web/src/pages/wizard/components/WizardMapDevices/WizardMapDevices.tsx b/web/src/pages/wizard/components/WizardMapDevices/WizardMapDevices.tsx index a34f94187f..a1c722ea16 100644 --- a/web/src/pages/wizard/components/WizardMapDevices/WizardMapDevices.tsx +++ b/web/src/pages/wizard/components/WizardMapDevices/WizardMapDevices.tsx @@ -49,7 +49,7 @@ export const WizardMapDevices = () => { z.object({ devices: z.array( z.object({ - wireguard_ip: z.string().min(1, LL.form.error.required()), + wireguard_ips: z.array(z.string().min(1, LL.form.error.required())), user_id: z .number({ invalid_type_error: LL.form.error.required(), @@ -103,7 +103,7 @@ export const WizardMapDevices = () => { const getHeaders = useMemo( (): ListHeader[] => [ { text: 'Device Name', key: 0, sortable: false }, - { text: 'IP', key: 1, sortable: false }, + { text: 'IPs', key: 1, sortable: false }, { text: 'User', key: 2, sortable: false }, ], [], @@ -129,7 +129,6 @@ export const WizardMapDevices = () => { const handleInvalidSubmit: SubmitErrorHandler = () => { toaster.error(LL.wizard.deviceMap.messages.errorsInForm()); }; - const devicesList = useMemo((): DeviceRowData[] => { if (importedDevices) { return importedDevices.map((_, index) => ({ @@ -171,7 +170,6 @@ export const WizardMapDevices = () => { }, [getValues, setImportedDevices]); if (isLoading || !importedDevices || createLoading) return ; - return ( diff --git a/web/src/pages/wizard/components/WizardMapDevices/components/MapDeviceRow.tsx b/web/src/pages/wizard/components/WizardMapDevices/components/MapDeviceRow.tsx index 38a0df4444..8d44ae4b1b 100644 --- a/web/src/pages/wizard/components/WizardMapDevices/components/MapDeviceRow.tsx +++ b/web/src/pages/wizard/components/WizardMapDevices/components/MapDeviceRow.tsx @@ -31,7 +31,7 @@ export const MapDeviceRow = ({ options, control, index }: Props) => { const ipController = useController({ control, - name: `devices.${index}.wireguard_ip`, + name: `devices.${index}.wireguard_ips`, }); const hasErrors = useMemo(() => { @@ -63,7 +63,7 @@ export const MapDeviceRow = ({ options, control, index }: Props) => { return ( - {ipController.field.value} + {ipController.field.value.join(' ')}