diff --git a/.sqlx/query-27e7e18a7014af541fe5f8f051f78d61eebe6a79945324e98ca452b50d6abc90.json b/.sqlx/query-27e7e18a7014af541fe5f8f051f78d61eebe6a79945324e98ca452b50d6abc90.json index 92440ceb4c..c6e366dae4 100644 --- a/.sqlx/query-27e7e18a7014af541fe5f8f051f78d61eebe6a79945324e98ca452b50d6abc90.json +++ b/.sqlx/query-27e7e18a7014af541fe5f8f051f78d61eebe6a79945324e98ca452b50d6abc90.json @@ -50,7 +50,7 @@ }, { "ordinal": 9, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -79,7 +94,10 @@ false, true, false, - false + false, + true, + true, + true ] }, "hash": "27e7e18a7014af541fe5f8f051f78d61eebe6a79945324e98ca452b50d6abc90" diff --git a/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json b/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json index aa509dfc3e..97c45d80cc 100644 --- a/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json +++ b/.sqlx/query-2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080.json @@ -50,7 +50,7 @@ }, { "ordinal": 9, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -79,7 +94,10 @@ false, true, false, - false + false, + true, + true, + true ] }, "hash": "2ce93887379d80ff03753caaf94ec1ab4c6f0ead212fc74bb881e1d5c0d96080" diff --git a/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json b/.sqlx/query-2ff663d549b92de999cf87960dd3afda3fb17e9b28034593b960dcb3856460c0.json similarity index 59% rename from .sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json rename to .sqlx/query-2ff663d549b92de999cf87960dd3afda3fb17e9b28034593b960dcb3856460c0.json index 2b5f406c85..e9ac6d8ccc 100644 --- a/.sqlx/query-938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1.json +++ b/.sqlx/query-2ff663d549b92de999cf87960dd3afda3fb17e9b28034593b960dcb3856460c0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"proxy\" (\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate\",\"certificate_expiry\",\"modified_at\",\"modified_by\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id", + "query": "INSERT INTO \"proxy\" (\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate_serial\",\"certificate_expiry\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) RETURNING id", "describe": { "columns": [ { @@ -21,12 +21,15 @@ "Text", "Timestamp", "Timestamp", - "Text" + "Text", + "Bytea", + "Bytea", + "Timestamp" ] }, "nullable": [ false ] }, - "hash": "938c250b35e5b2b46cff9efbe41fce3100fe0ff1a86be48b7a22b58ef3da5bf1" + "hash": "2ff663d549b92de999cf87960dd3afda3fb17e9b28034593b960dcb3856460c0" } diff --git a/.sqlx/query-304b0e9ef4c04cc998581114f043b3b3fccb2d956776dbb409d157cb53b2b8ac.json b/.sqlx/query-304b0e9ef4c04cc998581114f043b3b3fccb2d956776dbb409d157cb53b2b8ac.json new file mode 100644 index 0000000000..8a646219d0 --- /dev/null +++ b/.sqlx/query-304b0e9ef4c04cc998581114f043b3b3fccb2d956776dbb409d157cb53b2b8ac.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE wizard\n SET initial_setup_state = NULL\n WHERE is_singleton", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "304b0e9ef4c04cc998581114f043b3b3fccb2d956776dbb409d157cb53b2b8ac" +} diff --git a/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json b/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json index fc05918a2f..1504472b17 100644 --- a/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json +++ b/.sqlx/query-3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400.json @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -67,6 +67,21 @@ "ordinal": 12, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 13, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 15, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -87,7 +102,10 @@ false, false, false, - false + false, + true, + true, + true ] }, "hash": "3c6a119f2f10046bd9e42314df953a0a0b3b44d0a87d43f69425729c15e1a400" diff --git a/.sqlx/query-3f61241d9934c717b7c5f89047333d1c3f2b3a4f20a93089bad74a8d560b1843.json b/.sqlx/query-3f61241d9934c717b7c5f89047333d1c3f2b3a4f20a93089bad74a8d560b1843.json new file mode 100644 index 0000000000..c7b2c08e83 --- /dev/null +++ b/.sqlx/query-3f61241d9934c717b7c5f89047333d1c3f2b3a4f20a93089bad74a8d560b1843.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE proxy SET disconnected_at = NOW() WHERE connected_at IS NOT NULL AND (disconnected_at IS NULL OR disconnected_at < connected_at)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3f61241d9934c717b7c5f89047333d1c3f2b3a4f20a93089bad74a8d560b1843" +} diff --git a/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json b/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json index 68e6a6c079..313b1c5a73 100644 --- a/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json +++ b/.sqlx/query-472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506.json @@ -50,7 +50,7 @@ }, { "ordinal": 9, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -79,7 +94,10 @@ false, true, false, - false + false, + true, + true, + true ] }, "hash": "472e3903cf3df3c5938527c5584a5a53edc5b492a6bb12eac3d97f3ebc5f8506" diff --git a/.sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json b/.sqlx/query-4b6305c0cc7e4bff7f918f9113dcbe02cd4b54d4ec042f238c854637a6b101d0.json similarity index 75% rename from .sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json rename to .sqlx/query-4b6305c0cc7e4bff7f918f9113dcbe02cd4b54d4ec042f238c854637a6b101d0.json index 6d9d8cf06d..a58578c831 100644 --- a/.sqlx/query-d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1.json +++ b/.sqlx/query-4b6305c0cc7e4bff7f918f9113dcbe02cd4b54d4ec042f238c854637a6b101d0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT gateway.*, CASE WHEN gateway.connected_at IS NULL THEN false WHEN gateway.disconnected_at IS NULL THEN true WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false END AS \"connected!\", wn.name AS location_name FROM gateway JOIN wireguard_network wn ON gateway.location_id = wn.id", + "query": "SELECT g.id, g.location_id, g.name, g.address, g.port, g.connected_at, g.disconnected_at, CASE WHEN g.connected_at IS NULL THEN false WHEN g.disconnected_at IS NULL THEN true WHEN g.connected_at >= g.disconnected_at THEN true ELSE false END AS \"connected!\", g.certificate_serial, g.certificate_expiry, g.version, g.enabled, g.modified_at, g.modified_by, wn.name AS location_name FROM gateway g JOIN wireguard_network wn ON g.location_id = wn.id", "describe": { "columns": [ { @@ -15,48 +15,48 @@ }, { "ordinal": 2, - "name": "connected_at", - "type_info": "Timestamp" + "name": "name", + "type_info": "Text" }, { "ordinal": 3, - "name": "disconnected_at", - "type_info": "Timestamp" + "name": "address", + "type_info": "Text" }, { "ordinal": 4, - "name": "certificate_expiry", - "type_info": "Timestamp" + "name": "port", + "type_info": "Int4" }, { "ordinal": 5, - "name": "version", - "type_info": "Text" + "name": "connected_at", + "type_info": "Timestamp" }, { "ordinal": 6, - "name": "name", - "type_info": "Text" + "name": "disconnected_at", + "type_info": "Timestamp" }, { "ordinal": 7, - "name": "certificate", - "type_info": "Text" + "name": "connected!", + "type_info": "Bool" }, { "ordinal": 8, - "name": "address", + "name": "certificate_serial", "type_info": "Text" }, { "ordinal": 9, - "name": "port", - "type_info": "Int4" + "name": "certificate_expiry", + "type_info": "Timestamp" }, { "ordinal": 10, - "name": "modified_at", - "type_info": "Timestamp" + "name": "version", + "type_info": "Text" }, { "ordinal": 11, @@ -65,13 +65,13 @@ }, { "ordinal": 12, - "name": "modified_by", - "type_info": "Text" + "name": "modified_at", + "type_info": "Timestamp" }, { "ordinal": 13, - "name": "connected!", - "type_info": "Bool" + "name": "modified_by", + "type_info": "Text" }, { "ordinal": 14, @@ -83,22 +83,22 @@ "Left": [] }, "nullable": [ + false, + false, + false, false, false, true, true, + null, true, true, - false, true, false, false, false, - false, - false, - null, false ] }, - "hash": "d66aaea81e06f0b0fffe8103ffa9384fa989a4bfd58b3490d7b94f2547f261b1" + "hash": "4b6305c0cc7e4bff7f918f9113dcbe02cd4b54d4ec042f238c854637a6b101d0" } diff --git a/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json b/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json index 0f433d31da..6de76f5130 100644 --- a/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json +++ b/.sqlx/query-4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea.json @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -67,6 +67,21 @@ "ordinal": 12, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 13, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 15, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -88,7 +103,10 @@ false, false, false, - false + false, + true, + true, + true ] }, "hash": "4d9c4562a138038ba054b5b83b646341ee18e24f0d32399e6ce2ebaedef64cea" diff --git a/.sqlx/query-19ad6391ddfb43ab2f1100aefab048e985a9c6ba7c02475756c3e40e9a66b450.json b/.sqlx/query-6c0570ec090a92e22b111cdbb131c07340f6c0993ac4aef58cea272ff3c27ef8.json similarity index 68% rename from .sqlx/query-19ad6391ddfb43ab2f1100aefab048e985a9c6ba7c02475756c3e40e9a66b450.json rename to .sqlx/query-6c0570ec090a92e22b111cdbb131c07340f6c0993ac4aef58cea272ff3c27ef8.json index febbd693ef..ca515c2bb4 100644 --- a/.sqlx/query-19ad6391ddfb43ab2f1100aefab048e985a9c6ba7c02475756c3e40e9a66b450.json +++ b/.sqlx/query-6c0570ec090a92e22b111cdbb131c07340f6c0993ac4aef58cea272ff3c27ef8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\" FROM \"gateway\" LIMIT $1 OFFSET $2", + "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate_serial\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"gateway\" LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -67,6 +67,21 @@ "ordinal": 12, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 13, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 15, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -88,8 +103,11 @@ true, false, false, - false + false, + true, + true, + true ] }, - "hash": "19ad6391ddfb43ab2f1100aefab048e985a9c6ba7c02475756c3e40e9a66b450" + "hash": "6c0570ec090a92e22b111cdbb131c07340f6c0993ac4aef58cea272ff3c27ef8" } diff --git a/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json b/.sqlx/query-702eeefc7607721e6bf4e84fad0c21c8ee0fe9d2cf0b574332bddc24a0ab4a37.json similarity index 53% rename from .sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json rename to .sqlx/query-702eeefc7607721e6bf4e84fad0c21c8ee0fe9d2cf0b574332bddc24a0ab4a37.json index 0703ef2639..a5a69d0a1e 100644 --- a/.sqlx/query-6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e.json +++ b/.sqlx/query-702eeefc7607721e6bf4e84fad0c21c8ee0fe9d2cf0b574332bddc24a0ab4a37.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"gateway\" SET \"location_id\" = $2,\"name\" = $3,\"address\" = $4,\"port\" = $5,\"connected_at\" = $6,\"disconnected_at\" = $7,\"certificate\" = $8,\"certificate_expiry\" = $9,\"version\" = $10,\"enabled\" = $11,\"modified_at\" = $12,\"modified_by\" = $13 WHERE id = $1", + "query": "UPDATE \"gateway\" SET \"location_id\" = $2,\"name\" = $3,\"address\" = $4,\"port\" = $5,\"connected_at\" = $6,\"disconnected_at\" = $7,\"certificate_serial\" = $8,\"certificate_expiry\" = $9,\"version\" = $10,\"enabled\" = $11,\"modified_at\" = $12,\"modified_by\" = $13,\"core_client_cert_der\" = $14,\"core_client_cert_key_der\" = $15,\"core_client_cert_expiry\" = $16 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -17,10 +17,13 @@ "Text", "Bool", "Timestamp", - "Text" + "Text", + "Bytea", + "Bytea", + "Timestamp" ] }, "nullable": [] }, - "hash": "6b1506441fd24aff832ee8ee9edb6d8423cfc61bf59ceaf0364c07ddde47127e" + "hash": "702eeefc7607721e6bf4e84fad0c21c8ee0fe9d2cf0b574332bddc24a0ab4a37" } diff --git a/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json b/.sqlx/query-8d142b160cee06ddc6b3578b3808d731e8c60d5284b8a7ed29b1e32601b667f6.json similarity index 67% rename from .sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json rename to .sqlx/query-8d142b160cee06ddc6b3578b3808d731e8c60d5284b8a7ed29b1e32601b667f6.json index 448d27be49..f0683b5736 100644 --- a/.sqlx/query-f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2.json +++ b/.sqlx/query-8d142b160cee06ddc6b3578b3808d731e8c60d5284b8a7ed29b1e32601b667f6.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate\",\"certificate_expiry\",\"modified_at\",\"modified_by\" FROM \"proxy\"", + "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate_serial\",\"certificate_expiry\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"proxy\"", "describe": { "columns": [ { @@ -45,7 +45,7 @@ }, { "ordinal": 8, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -79,8 +94,11 @@ true, true, false, - false + false, + true, + true, + true ] }, - "hash": "f14171d837b8ac91e765e9b86153186ac78bf78ce3cfc5af7441d84be52749d2" + "hash": "8d142b160cee06ddc6b3578b3808d731e8c60d5284b8a7ed29b1e32601b667f6" } diff --git a/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json b/.sqlx/query-93a4240e469c663e4038cee30aa9c81e888b03cb8d158ed3770a4bc19bae6b22.json similarity index 68% rename from .sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json rename to .sqlx/query-93a4240e469c663e4038cee30aa9c81e888b03cb8d158ed3770a4bc19bae6b22.json index 6bac2b9d11..21323cb26a 100644 --- a/.sqlx/query-0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4.json +++ b/.sqlx/query-93a4240e469c663e4038cee30aa9c81e888b03cb8d158ed3770a4bc19bae6b22.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\" FROM \"gateway\"", + "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate_serial\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"gateway\"", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -67,6 +67,21 @@ "ordinal": 12, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 13, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 15, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -85,8 +100,11 @@ true, false, false, - false + false, + true, + true, + true ] }, - "hash": "0fc80b6949eaaeda77dabad7093bca70bd327c14eea4b8db1c9f11c722a00bf4" + "hash": "93a4240e469c663e4038cee30aa9c81e888b03cb8d158ed3770a4bc19bae6b22" } diff --git a/.sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json b/.sqlx/query-9ec638cdabc0500b54cedc5ba18c7745a48080f1e8faa7f7bf7ffae65f2b6ebf.json similarity index 74% rename from .sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json rename to .sqlx/query-9ec638cdabc0500b54cedc5ba18c7745a48080f1e8faa7f7bf7ffae65f2b6ebf.json index 2f03d82e6a..259fafa7de 100644 --- a/.sqlx/query-127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf.json +++ b/.sqlx/query-9ec638cdabc0500b54cedc5ba18c7745a48080f1e8faa7f7bf7ffae65f2b6ebf.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT gateway.*, CASE WHEN gateway.connected_at IS NULL THEN false WHEN gateway.disconnected_at IS NULL THEN true WHEN gateway.connected_at >= gateway.disconnected_at THEN true ELSE false END AS \"connected!\", wn.name AS location_name FROM gateway JOIN wireguard_network wn ON gateway.location_id = wn.id WHERE location_id = $1", + "query": "SELECT g.id, g.location_id, g.name, g.address, g.port, g.connected_at, g.disconnected_at, CASE WHEN g.connected_at IS NULL THEN false WHEN g.disconnected_at IS NULL THEN true WHEN g.connected_at >= g.disconnected_at THEN true ELSE false END AS \"connected!\", g.certificate_serial, g.certificate_expiry, g.version, g.enabled, g.modified_at, g.modified_by, wn.name AS location_name FROM gateway g JOIN wireguard_network wn ON g.location_id = wn.id WHERE location_id = $1", "describe": { "columns": [ { @@ -15,48 +15,48 @@ }, { "ordinal": 2, - "name": "connected_at", - "type_info": "Timestamp" + "name": "name", + "type_info": "Text" }, { "ordinal": 3, - "name": "disconnected_at", - "type_info": "Timestamp" + "name": "address", + "type_info": "Text" }, { "ordinal": 4, - "name": "certificate_expiry", - "type_info": "Timestamp" + "name": "port", + "type_info": "Int4" }, { "ordinal": 5, - "name": "version", - "type_info": "Text" + "name": "connected_at", + "type_info": "Timestamp" }, { "ordinal": 6, - "name": "name", - "type_info": "Text" + "name": "disconnected_at", + "type_info": "Timestamp" }, { "ordinal": 7, - "name": "certificate", - "type_info": "Text" + "name": "connected!", + "type_info": "Bool" }, { "ordinal": 8, - "name": "address", + "name": "certificate_serial", "type_info": "Text" }, { "ordinal": 9, - "name": "port", - "type_info": "Int4" + "name": "certificate_expiry", + "type_info": "Timestamp" }, { "ordinal": 10, - "name": "modified_at", - "type_info": "Timestamp" + "name": "version", + "type_info": "Text" }, { "ordinal": 11, @@ -65,13 +65,13 @@ }, { "ordinal": 12, - "name": "modified_by", - "type_info": "Text" + "name": "modified_at", + "type_info": "Timestamp" }, { "ordinal": 13, - "name": "connected!", - "type_info": "Bool" + "name": "modified_by", + "type_info": "Text" }, { "ordinal": 14, @@ -85,22 +85,22 @@ ] }, "nullable": [ + false, + false, + false, false, false, true, true, + null, true, true, - false, true, false, false, false, - false, - false, - null, false ] }, - "hash": "127e21275639df135aaa9fce8dd1c2b3dffb146a8eacd47faced4894772cfacf" + "hash": "9ec638cdabc0500b54cedc5ba18c7745a48080f1e8faa7f7bf7ffae65f2b6ebf" } diff --git a/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json b/.sqlx/query-a05a752af1643a1d9f9b9544df2055218f9c51b0ae143a9a1c5ff9e13dab9c75.json similarity index 59% rename from .sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json rename to .sqlx/query-a05a752af1643a1d9f9b9544df2055218f9c51b0ae143a9a1c5ff9e13dab9c75.json index 4ea6dccd4a..e207617bf3 100644 --- a/.sqlx/query-8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599.json +++ b/.sqlx/query-a05a752af1643a1d9f9b9544df2055218f9c51b0ae143a9a1c5ff9e13dab9c75.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO \"gateway\" (\"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id", + "query": "INSERT INTO \"gateway\" (\"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate_serial\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\") VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING id", "describe": { "columns": [ { @@ -22,12 +22,15 @@ "Text", "Bool", "Timestamp", - "Text" + "Text", + "Bytea", + "Bytea", + "Timestamp" ] }, "nullable": [ false ] }, - "hash": "8d21a38672059e820d355590df83c1c9c5f75956f8b7c2a1a235189e1583a599" + "hash": "a05a752af1643a1d9f9b9544df2055218f9c51b0ae143a9a1c5ff9e13dab9c75" } diff --git a/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json index 87ce4e720e..aefb3da70e 100644 --- a/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json +++ b/.sqlx/query-a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877.json @@ -50,7 +50,7 @@ }, { "ordinal": 9, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "enabled", "type_info": "Bool" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -82,7 +97,10 @@ false, true, false, - false + false, + true, + true, + true ] }, "hash": "a41787c8c8307414165ab23ef96d82a34d3bfa4364cbe9b8368e71445bc20877" diff --git a/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json b/.sqlx/query-d4c6847ef8197f425c3cb19964509f11c97ab5d68c7ccd1c173aa8212a659770.json similarity index 68% rename from .sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json rename to .sqlx/query-d4c6847ef8197f425c3cb19964509f11c97ab5d68c7ccd1c173aa8212a659770.json index 3c397182d9..249ff49abb 100644 --- a/.sqlx/query-beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad.json +++ b/.sqlx/query-d4c6847ef8197f425c3cb19964509f11c97ab5d68c7ccd1c173aa8212a659770.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\" FROM \"gateway\" WHERE id = $1", + "query": "SELECT id, \"location_id\",\"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"certificate_serial\",\"certificate_expiry\",\"version\",\"enabled\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"gateway\" WHERE id = $1", "describe": { "columns": [ { @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -67,6 +67,21 @@ "ordinal": 12, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 13, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 15, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -87,8 +102,11 @@ true, false, false, - false + false, + true, + true, + true ] }, - "hash": "beffd1aad66ce9d9a179f14e224d9ca63f0c0aa378460bd344f7a7daa8985bad" + "hash": "d4c6847ef8197f425c3cb19964509f11c97ab5d68c7ccd1c173aa8212a659770" } diff --git a/.sqlx/query-c2bf39775cc4142f18165b1e342731b0d6b7370543b5c5e8cabdfd47487a3056.json b/.sqlx/query-d9dc6788c19efa7b1ec9060651398e85a165c0e03167abd4940b248e8d29ccf1.json similarity index 67% rename from .sqlx/query-c2bf39775cc4142f18165b1e342731b0d6b7370543b5c5e8cabdfd47487a3056.json rename to .sqlx/query-d9dc6788c19efa7b1ec9060651398e85a165c0e03167abd4940b248e8d29ccf1.json index a1db3a8fba..5e4f9de11b 100644 --- a/.sqlx/query-c2bf39775cc4142f18165b1e342731b0d6b7370543b5c5e8cabdfd47487a3056.json +++ b/.sqlx/query-d9dc6788c19efa7b1ec9060651398e85a165c0e03167abd4940b248e8d29ccf1.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate\",\"certificate_expiry\",\"modified_at\",\"modified_by\" FROM \"proxy\" LIMIT $1 OFFSET $2", + "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate_serial\",\"certificate_expiry\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"proxy\" LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -45,7 +45,7 @@ }, { "ordinal": 8, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -82,8 +97,11 @@ true, true, false, - false + false, + true, + true, + true ] }, - "hash": "c2bf39775cc4142f18165b1e342731b0d6b7370543b5c5e8cabdfd47487a3056" + "hash": "d9dc6788c19efa7b1ec9060651398e85a165c0e03167abd4940b248e8d29ccf1" } diff --git a/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json b/.sqlx/query-eb50eae3a1786cf685b8ec084e905afc8c9c0ae34ffe29bf6adb2a89f1ae6edb.json similarity index 67% rename from .sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json rename to .sqlx/query-eb50eae3a1786cf685b8ec084e905afc8c9c0ae34ffe29bf6adb2a89f1ae6edb.json index ce8b4b42a0..7f8ec2a131 100644 --- a/.sqlx/query-bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb.json +++ b/.sqlx/query-eb50eae3a1786cf685b8ec084e905afc8c9c0ae34ffe29bf6adb2a89f1ae6edb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate\",\"certificate_expiry\",\"modified_at\",\"modified_by\" FROM \"proxy\" WHERE id = $1", + "query": "SELECT id, \"name\",\"address\",\"port\",\"connected_at\",\"disconnected_at\",\"version\",\"enabled\",\"certificate_serial\",\"certificate_expiry\",\"modified_at\",\"modified_by\",\"core_client_cert_der\",\"core_client_cert_key_der\",\"core_client_cert_expiry\" FROM \"proxy\" WHERE id = $1", "describe": { "columns": [ { @@ -45,7 +45,7 @@ }, { "ordinal": 8, - "name": "certificate", + "name": "certificate_serial", "type_info": "Text" }, { @@ -62,6 +62,21 @@ "ordinal": 11, "name": "modified_by", "type_info": "Text" + }, + { + "ordinal": 12, + "name": "core_client_cert_der", + "type_info": "Bytea" + }, + { + "ordinal": 13, + "name": "core_client_cert_key_der", + "type_info": "Bytea" + }, + { + "ordinal": 14, + "name": "core_client_cert_expiry", + "type_info": "Timestamp" } ], "parameters": { @@ -81,8 +96,11 @@ true, true, false, - false + false, + true, + true, + true ] }, - "hash": "bcb405dc3159cd72c5ccebf29bf4b6163ee0e324cd95cbf3e32d025b5ba7fcbb" + "hash": "eb50eae3a1786cf685b8ec084e905afc8c9c0ae34ffe29bf6adb2a89f1ae6edb" } diff --git a/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json b/.sqlx/query-f0bd5b48faffc4152e2683d4aecfcfb5a2496f961ead8474e8ed5e290cabec85.json similarity index 54% rename from .sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json rename to .sqlx/query-f0bd5b48faffc4152e2683d4aecfcfb5a2496f961ead8474e8ed5e290cabec85.json index 332b4b8b4e..62d5dfa726 100644 --- a/.sqlx/query-780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a.json +++ b/.sqlx/query-f0bd5b48faffc4152e2683d4aecfcfb5a2496f961ead8474e8ed5e290cabec85.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"proxy\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"connected_at\" = $5,\"disconnected_at\" = $6,\"version\" = $7,\"enabled\" = $8,\"certificate\" = $9,\"certificate_expiry\" = $10,\"modified_at\" = $11,\"modified_by\" = $12 WHERE id = $1", + "query": "UPDATE \"proxy\" SET \"name\" = $2,\"address\" = $3,\"port\" = $4,\"connected_at\" = $5,\"disconnected_at\" = $6,\"version\" = $7,\"enabled\" = $8,\"certificate_serial\" = $9,\"certificate_expiry\" = $10,\"modified_at\" = $11,\"modified_by\" = $12,\"core_client_cert_der\" = $13,\"core_client_cert_key_der\" = $14,\"core_client_cert_expiry\" = $15 WHERE id = $1", "describe": { "columns": [], "parameters": { @@ -16,10 +16,13 @@ "Text", "Timestamp", "Timestamp", - "Text" + "Text", + "Bytea", + "Bytea", + "Timestamp" ] }, "nullable": [] }, - "hash": "780d66e4628d13c6c2f489cc87c7358945b93628104ac57aac207b8ec74be08a" + "hash": "f0bd5b48faffc4152e2683d4aecfcfb5a2496f961ead8474e8ed5e290cabec85" } diff --git a/.sqlx/query-f2eab45b5d87910672e8970e662b99e657fce3a80d9eb42760728db17d8d844e.json b/.sqlx/query-f2eab45b5d87910672e8970e662b99e657fce3a80d9eb42760728db17d8d844e.json new file mode 100644 index 0000000000..70fbe11a0e --- /dev/null +++ b/.sqlx/query-f2eab45b5d87910672e8970e662b99e657fce3a80d9eb42760728db17d8d844e.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE wizard\n SET auto_adoption_state = NULL\n WHERE is_singleton", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "f2eab45b5d87910672e8970e662b99e657fce3a80d9eb42760728db17d8d844e" +} diff --git a/.sqlx/query-fa84c8e5a9db1d10c78a73e7eef6c942054e73522b9814323391009adfbd5e69.json b/.sqlx/query-fa84c8e5a9db1d10c78a73e7eef6c942054e73522b9814323391009adfbd5e69.json new file mode 100644 index 0000000000..4f2ac9dcbf --- /dev/null +++ b/.sqlx/query-fa84c8e5a9db1d10c78a73e7eef6c942054e73522b9814323391009adfbd5e69.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE gateway SET disconnected_at = NOW() WHERE connected_at IS NOT NULL AND (disconnected_at IS NULL OR disconnected_at <= connected_at)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "fa84c8e5a9db1d10c78a73e7eef6c942054e73522b9814323391009adfbd5e69" +} diff --git a/.sqlx/query-fff6c48d97533e3b6b82954925f0ac762ebb9bfcedbc24a104c7237802e3a70b.json b/.sqlx/query-fff6c48d97533e3b6b82954925f0ac762ebb9bfcedbc24a104c7237802e3a70b.json new file mode 100644 index 0000000000..d650aa3d1b --- /dev/null +++ b/.sqlx/query-fff6c48d97533e3b6b82954925f0ac762ebb9bfcedbc24a104c7237802e3a70b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE wizard\n SET migration_wizard_state = $1\n WHERE is_singleton", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "fff6c48d97533e3b6b82954925f0ac762ebb9bfcedbc24a104c7237802e3a70b" +} diff --git a/Cargo.lock b/Cargo.lock index 5c744740c2..d10d66dcba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1371,6 +1371,7 @@ dependencies = [ "claims", "defguard_certs", "defguard_common", + "defguard_grpc_tls", "defguard_mail", "defguard_proto", "defguard_static_ip", @@ -1514,9 +1515,11 @@ version = "0.0.0" dependencies = [ "defguard_common", "http", + "hyper-rustls", "rustls", "thiserror 2.0.18", "tokio", + "tonic", "tower-service", "tracing", "x509-parser 0.18.1", @@ -4057,9 +4060,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.77" +version = "0.10.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe4646e360ec77dff7dde40ed3d6c5fee52d156ef4a62f53973d38294dad87f" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" dependencies = [ "bitflags 2.11.1", "cfg-if", @@ -4089,9 +4092,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.113" +version = "0.9.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad2f2c0eba47118757e4c6d2bff2838f3e0523380021356e7875e858372ce644" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" dependencies = [ "cc", "libc", @@ -5657,9 +5660,9 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" dependencies = [ "digest", "keccak", @@ -6725,9 +6728,9 @@ dependencies = [ [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "uaparser" @@ -7009,11 +7012,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -7022,7 +7025,7 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] @@ -7610,6 +7613,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" diff --git a/crates/defguard_certs/src/lib.rs b/crates/defguard_certs/src/lib.rs index 9d0c34d13b..99d64667e5 100644 --- a/crates/defguard_certs/src/lib.rs +++ b/crates/defguard_certs/src/lib.rs @@ -1,10 +1,10 @@ -use std::str::FromStr; +use std::{net::IpAddr, str::FromStr}; use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::NaiveDateTime; use rcgen::{ - BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, - ExtendedKeyUsagePurpose, IsCa, Issuer, KeyPair, KeyUsagePurpose, SigningKey, string::Ia5String, + BasicConstraints, Certificate, CertificateParams, CertificateSigningRequestParams, IsCa, + Issuer, KeyPair, KeyUsagePurpose, SigningKey, string::Ia5String, }; use rustls_pki_types::{CertificateDer, CertificateSigningRequestDer, pem::PemObject}; use thiserror::Error; @@ -14,6 +14,8 @@ use x509_parser::{ parse_x509_certificate, }; +pub use rcgen::ExtendedKeyUsagePurpose; + const CA_NAME: &str = "Defguard CA"; const NOT_BEFORE_OFFSET_SECS: Duration = Duration::minutes(5); const DEFAULT_CERT_VALIDITY_DAYS: i64 = 1825; @@ -26,6 +28,8 @@ pub enum CertificateError { ParsingError(String), #[error(transparent)] IoError(#[from] std::io::Error), + #[error("CSR hostname mismatch: {0}")] + HostnameMismatch(String), } pub struct CertificateAuthority<'a> { @@ -92,16 +96,37 @@ impl CertificateAuthority<'_> { Self::from_key_cert_params(ca_key_pair, ca_params) } - pub fn sign_csr(&self, csr: &Csr) -> Result { - // TODO: make validity configurable? - self.sign_csr_with_validity(csr, DEFAULT_CERT_VALIDITY_DAYS) + /// Sign a server-facing component certificate (`ServerAuth` EKU only). + /// + /// Use [`sign_client_cert`] for Core gRPC client certificates, or + /// [`sign_csr_with_validity`] when custom validity is needed. + pub fn sign_server_cert(&self, csr: &Csr) -> Result { + self.sign_csr_with_validity( + csr, + DEFAULT_CERT_VALIDITY_DAYS, + &[ExtendedKeyUsagePurpose::ServerAuth], + ) + } + + /// Sign a Core gRPC client certificate (`ClientAuth` EKU only). + pub fn sign_client_cert(&self, csr: &Csr) -> Result { + self.sign_csr_with_validity( + csr, + DEFAULT_CERT_VALIDITY_DAYS, + &[ExtendedKeyUsagePurpose::ClientAuth], + ) } - /// Sign CSR with explicit validity in days. + /// Sign a CSR with explicit validity in days and extended key usages. + /// + /// `extended_key_usages` controls which EKUs are encoded in the signed + /// certificate. Pass `&[ServerAuth]` for component server certs and + /// `&[ClientAuth]` for Core gRPC client certs. pub fn sign_csr_with_validity( &self, csr: &Csr, days_valid: i64, + extended_key_usages: &[ExtendedKeyUsagePurpose], ) -> Result { let mut csr_params = csr.params()?; @@ -116,15 +141,37 @@ impl CertificateAuthority<'_> { KeyUsagePurpose::DigitalSignature, KeyUsagePurpose::KeyEncipherment, ]; - csr_params.params.extended_key_usages = vec![ - ExtendedKeyUsagePurpose::ServerAuth, - ExtendedKeyUsagePurpose::ClientAuth, - ]; + csr_params.params.extended_key_usages = extended_key_usages.to_vec(); let cert = csr_params.signed_by(&self.issuer)?; Ok(cert) } + /// Issue a Core gRPC client certificate for a specific Gateway or Edge. + /// + /// Generates a fresh key pair, creates a CSR with `common_name` as both + /// the Subject CN and the SAN DNS name, signs it with `ClientAuth` EKU, + /// and returns all materials needed to store in the database and build a + /// [`CertBundle`]. + pub fn issue_core_client_cert( + &self, + common_name: &str, + ) -> Result { + let key_pair = generate_key_pair()?; + let csr = Csr::new( + &key_pair, + &[common_name.to_string()], + vec![(rcgen::DnType::CommonName, common_name)], + )?; + let cert = self.sign_client_cert(&csr)?; + let expiry = CertificateInfo::from_der(cert.der())?.not_after; + Ok(CoreClientCert { + cert_der: cert.der().to_vec(), + key_der: key_pair.serialized_der().to_vec(), + expiry, + }) + } + pub fn cert_pem(&self) -> Result { der_to_pem(self.cert_der.as_ref(), PemLabel::Certificate) } @@ -145,6 +192,18 @@ impl CertificateAuthority<'_> { } } +/// A Core gRPC client certificate issued for a specific Gateway or Edge component. +/// +/// The DER bytes are stored in the database; the key bytes never leave Core. +pub struct CoreClientCert { + /// DER-encoded client certificate signed with `ClientAuth` EKU. + pub cert_der: Vec, + /// DER-encoded private key for the client certificate. + pub key_der: Vec, + /// Certificate expiry timestamp (UTC). + pub expiry: NaiveDateTime, +} + pub struct CertificateInfo { pub subject_common_name: String, pub subject_email: Option, @@ -241,6 +300,41 @@ impl Csr<'_> { Ok(params) } + /// Verify that the CSR's SAN list contains exactly `expected_hostname` and + /// nothing else. The hostname may be a DNS name or an IP address literal. + /// + /// This is used during component setup to ensure the component has not + /// substituted a different hostname in the CSR it returns to Core. + pub fn verify_hostname(&self, expected_hostname: &str) -> Result<(), CertificateError> { + let params = self.params()?; + let sans = ¶ms.params.subject_alt_names; + + if sans.is_empty() { + return Err(CertificateError::HostnameMismatch(format!( + "CSR contains no SANs; expected {expected_hostname:?}" + ))); + } + + let expected_ip: Option = expected_hostname.parse().ok(); + + for san in sans { + let matches = match san { + rcgen::SanType::IpAddress(ip) => expected_ip.is_some_and(|e| &e == ip), + rcgen::SanType::DnsName(name) => { + expected_ip.is_none() && name.as_str() == expected_hostname + } + _ => false, + }; + if !matches { + return Err(CertificateError::HostnameMismatch(format!( + "CSR SAN does not match expected hostname {expected_hostname}" + ))); + } + } + + Ok(()) + } + #[must_use] pub fn to_der(&self) -> &[u8] { self.csr.as_ref() @@ -329,7 +423,7 @@ mod tests { } #[test] - fn test_sign_csr() { + fn test_sign_server_cert() { let ca = CertificateAuthority::new("Defguard CA", "email@email.com", 10).unwrap(); let cert_key_pair = generate_key_pair().unwrap(); let csr = Csr::new( @@ -341,7 +435,7 @@ mod tests { ], ) .unwrap(); - let signed_cert: Certificate = ca.sign_csr(&csr).unwrap(); + let signed_cert = ca.sign_server_cert(&csr).unwrap(); assert!(signed_cert.pem().contains("BEGIN CERTIFICATE")); } @@ -357,7 +451,9 @@ mod tests { vec![(rcgen::DnType::CommonName, "example.com")], ) .unwrap(); - let signed_cert: Certificate = ca.sign_csr_with_validity(&csr, 90).unwrap(); + let signed_cert = ca + .sign_csr_with_validity(&csr, 90, &[ExtendedKeyUsagePurpose::ServerAuth]) + .unwrap(); let der = signed_cert.der(); let (_rem, parsed) = parse_x509_certificate(der).unwrap(); let validity = parsed.tbs_certificate.validity; @@ -465,4 +561,52 @@ mod tests { let parsed = parse_pem_certificate(&pem).unwrap(); assert_eq!(parsed, ca.cert_der); } + + #[test] + fn test_csr_verify_hostname_dns_ok() { + let key = generate_key_pair().unwrap(); + let csr = Csr::new(&key, &["proxy.example.com".to_string()], vec![]).unwrap(); + assert!( + csr.verify_hostname("proxy.example.com").is_ok(), + "matching DNS SAN should pass" + ); + } + + #[test] + fn test_csr_verify_hostname_ip_ok() { + let key = generate_key_pair().unwrap(); + let csr = Csr::new(&key, &["10.0.0.1".to_string()], vec![]).unwrap(); + assert!( + csr.verify_hostname("10.0.0.1").is_ok(), + "matching IP SAN should pass" + ); + } + + #[test] + fn test_csr_verify_hostname_mismatch() { + let key = generate_key_pair().unwrap(); + let csr = Csr::new(&key, &["evil.attacker.com".to_string()], vec![]).unwrap(); + assert!( + csr.verify_hostname("proxy.example.com").is_err(), + "mismatched DNS SAN should fail" + ); + } + + #[test] + fn test_csr_verify_hostname_extra_san_rejected() { + let key = generate_key_pair().unwrap(); + let csr = Csr::new( + &key, + &[ + "proxy.example.com".to_string(), + "evil.extra.com".to_string(), + ], + vec![], + ) + .unwrap(); + assert!( + csr.verify_hostname("proxy.example.com").is_err(), + "CSR with extra SANs beyond the expected hostname should fail" + ); + } } diff --git a/crates/defguard_common/src/db/mod.rs b/crates/defguard_common/src/db/mod.rs index 2827b97073..c767be6b84 100644 --- a/crates/defguard_common/src/db/mod.rs +++ b/crates/defguard_common/src/db/mod.rs @@ -59,8 +59,7 @@ pub enum TriggerOperation { } #[derive(Deserialize)] -pub struct ChangeNotification { +pub struct ChangeNotification { pub operation: TriggerOperation, - pub old: Option, - pub new: Option, + pub id: Id, } diff --git a/crates/defguard_common/src/db/models/gateway.rs b/crates/defguard_common/src/db/models/gateway.rs index 3a387eb433..b13328f4c8 100644 --- a/crates/defguard_common/src/db/models/gateway.rs +++ b/crates/defguard_common/src/db/models/gateway.rs @@ -7,7 +7,7 @@ use sqlx::{PgExecutor, query, query_as, query_scalar}; use crate::db::{Id, NoId}; -#[derive(Clone, Debug, Deserialize, Model, Serialize, PartialEq)] +#[derive(Clone, Deserialize, Model, Serialize, PartialEq)] pub struct Gateway { pub id: I, pub location_id: Id, @@ -16,12 +16,43 @@ pub struct Gateway { pub port: i32, pub connected_at: Option, pub disconnected_at: Option, - pub certificate: Option, + pub certificate_serial: Option, pub certificate_expiry: Option, pub version: Option, pub enabled: bool, pub modified_at: NaiveDateTime, pub modified_by: String, + #[serde(skip)] + pub core_client_cert_der: Option>, + #[serde(skip)] + pub core_client_cert_key_der: Option>, + pub core_client_cert_expiry: Option, +} + +impl fmt::Debug for Gateway { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Gateway") + .field("id", &self.id) + .field("location_id", &self.location_id) + .field("name", &self.name) + .field("address", &self.address) + .field("port", &self.port) + .field("connected_at", &self.connected_at) + .field("disconnected_at", &self.disconnected_at) + .field("certificate_serial", &self.certificate_serial) + .field("certificate_expiry", &self.certificate_expiry) + .field("version", &self.version) + .field("enabled", &self.enabled) + .field("modified_at", &self.modified_at) + .field("modified_by", &self.modified_by) + .field( + "core_client_cert_der", + &self.core_client_cert_der.as_ref().map(|_| ""), + ) + .field("core_client_cert_key_der", &"") + .field("core_client_cert_expiry", &self.core_client_cert_expiry) + .finish() + } } impl Gateway { @@ -67,12 +98,15 @@ impl Gateway { port, connected_at: None, disconnected_at: None, - certificate: None, + certificate_serial: None, certificate_expiry: None, version: None, enabled: true, modified_by: modified_by.into(), modified_at, + core_client_cert_der: None, + core_client_cert_key_der: None, + core_client_cert_expiry: None, } } } @@ -83,7 +117,7 @@ impl Gateway { where E: PgExecutor<'e>, { - query( + query!( "UPDATE gateway \ SET disconnected_at = NOW() \ WHERE connected_at IS NOT NULL \ diff --git a/crates/defguard_common/src/db/models/initial_setup_wizard.rs b/crates/defguard_common/src/db/models/initial_setup_wizard.rs index 749f924b82..03e46b6279 100644 --- a/crates/defguard_common/src/db/models/initial_setup_wizard.rs +++ b/crates/defguard_common/src/db/models/initial_setup_wizard.rs @@ -79,7 +79,7 @@ impl InitialSetupState { where E: PgExecutor<'e>, { - query( + query!( "UPDATE wizard SET initial_setup_state = NULL WHERE is_singleton", diff --git a/crates/defguard_common/src/db/models/migration_wizard.rs b/crates/defguard_common/src/db/models/migration_wizard.rs index e0acb5a2e8..3ea0ad23c0 100644 --- a/crates/defguard_common/src/db/models/migration_wizard.rs +++ b/crates/defguard_common/src/db/models/migration_wizard.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use sqlx::PgExecutor; +use sqlx::{PgExecutor, query}; #[derive(Debug, Serialize, Deserialize, Default)] pub enum MigrationWizardStep { @@ -76,12 +76,12 @@ impl MigrationWizardState { let state = serde_json::to_value(self).map_err(|error| sqlx::Error::Decode(Box::new(error)))?; - sqlx::query( + query!( "UPDATE wizard SET migration_wizard_state = $1 WHERE is_singleton", + state ) - .bind(state) .execute(executor) .await?; @@ -92,7 +92,7 @@ impl MigrationWizardState { where E: PgExecutor<'e>, { - sqlx::query!( + query!( "Update wizard \ SET migration_wizard_state = NULL \ WHERE is_singleton" diff --git a/crates/defguard_common/src/db/models/proxy.rs b/crates/defguard_common/src/db/models/proxy.rs index 6e7530aaf0..536d159a36 100644 --- a/crates/defguard_common/src/db/models/proxy.rs +++ b/crates/defguard_common/src/db/models/proxy.rs @@ -3,15 +3,12 @@ use std::fmt; use chrono::{NaiveDateTime, Utc}; use model_derive::Model; use serde::{Deserialize, Serialize}; -use sqlx::PgPool; +use sqlx::{PgExecutor, PgPool, query, query_as}; use utoipa::ToSchema; -use crate::{ - db::{Id, NoId}, - types::proxy::ProxyInfo, -}; +use crate::db::{Id, NoId}; -#[derive(Clone, Debug, Deserialize, Model, Serialize, ToSchema, PartialEq)] +#[derive(Clone, Deserialize, Model, Serialize, ToSchema, PartialEq)] pub struct Proxy { pub id: I, pub name: String, @@ -21,10 +18,40 @@ pub struct Proxy { pub disconnected_at: Option, pub version: Option, pub enabled: bool, - pub certificate: Option, + pub certificate_serial: Option, pub certificate_expiry: Option, pub modified_at: NaiveDateTime, pub modified_by: String, + #[serde(skip)] + pub core_client_cert_der: Option>, + #[serde(skip)] + pub core_client_cert_key_der: Option>, + pub core_client_cert_expiry: Option, +} + +impl fmt::Debug for Proxy { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Proxy") + .field("id", &self.id) + .field("name", &self.name) + .field("address", &self.address) + .field("port", &self.port) + .field("connected_at", &self.connected_at) + .field("disconnected_at", &self.disconnected_at) + .field("version", &self.version) + .field("enabled", &self.enabled) + .field("certificate_serial", &self.certificate_serial) + .field("certificate_expiry", &self.certificate_expiry) + .field("modified_at", &self.modified_at) + .field("modified_by", &self.modified_by) + .field( + "core_client_cert_der", + &self.core_client_cert_der.as_ref().map(|_| ""), + ) + .field("core_client_cert_key_der", &"") + .field("core_client_cert_expiry", &self.core_client_cert_expiry) + .finish() + } } impl fmt::Display for Proxy { @@ -56,12 +83,15 @@ impl Proxy { port, connected_at: None, disconnected_at: None, - certificate: None, + certificate_serial: None, certificate_expiry: None, version: None, enabled: true, modified_by: modified_by.into(), modified_at: Utc::now().naive_utc(), + core_client_cert_der: None, + core_client_cert_key_der: None, + core_client_cert_expiry: None, } } } @@ -81,11 +111,8 @@ impl Proxy { } /// Mark all proxies currently considered connected as disconnected. - pub async fn mark_all_disconnected<'e, E>(executor: E) -> sqlx::Result<()> - where - E: sqlx::PgExecutor<'e>, - { - sqlx::query( + pub async fn mark_all_disconnected<'e, E: PgExecutor<'e>>(executor: E) -> sqlx::Result<()> { + query!( "UPDATE proxy \ SET disconnected_at = NOW() \ WHERE connected_at IS NOT NULL \ @@ -98,11 +125,8 @@ impl Proxy { } /// Fetch all enabled Proxies. - pub async fn all_enabled<'e, E>(executor: E) -> sqlx::Result> - where - E: sqlx::PgExecutor<'e>, - { - sqlx::query_as!(Self, "SELECT * FROM proxy WHERE enabled") + pub async fn all_enabled<'e, E: PgExecutor<'e>>(executor: E) -> sqlx::Result> { + query_as!(Self, "SELECT * FROM proxy WHERE enabled") .fetch_all(executor) .await } @@ -112,8 +136,8 @@ impl Proxy { address: &str, port: i32, ) -> sqlx::Result> { - sqlx::query_as!( - Proxy, + query_as!( + Self, "SELECT * FROM proxy WHERE address = $1 AND port = $2", address, port @@ -122,8 +146,8 @@ impl Proxy { .await } - pub async fn list(pool: &PgPool) -> sqlx::Result> { - sqlx::query_as!(ProxyInfo, "SELECT * FROM proxy",) + pub async fn list(pool: &PgPool) -> sqlx::Result> { + query_as!(Self, "SELECT * FROM proxy",) .fetch_all(pool) .await } @@ -144,11 +168,8 @@ impl Proxy { } /// Fetch all enabled, but one. Used for expired licence. - pub async fn leave_one_enabled<'e, E>(executor: E) -> sqlx::Result> - where - E: sqlx::PgExecutor<'e>, - { - sqlx::query_as!( + pub async fn leave_one_enabled<'e, E: PgExecutor<'e>>(executor: E) -> sqlx::Result> { + query_as!( Self, "SELECT * FROM proxy WHERE enabled AND id NOT IN (\ SELECT id FROM proxy WHERE enabled LIMIT 1 diff --git a/crates/defguard_common/src/db/models/setup_auto_adoption.rs b/crates/defguard_common/src/db/models/setup_auto_adoption.rs index 7d75022383..f9d0ebb18f 100644 --- a/crates/defguard_common/src/db/models/setup_auto_adoption.rs +++ b/crates/defguard_common/src/db/models/setup_auto_adoption.rs @@ -85,7 +85,7 @@ impl AutoAdoptionWizardState { where E: PgExecutor<'e>, { - query( + query!( "UPDATE wizard SET auto_adoption_state = NULL WHERE is_singleton", diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index 2d83925527..4726e93b96 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -1708,7 +1708,7 @@ mod test { settings.ldap_group_search_base = Some("ou=groups,dc=example,dc=com".into()); settings.ldap_remote_enrollment_enabled = true; update_current_settings(&pool, settings).await.unwrap(); - // user fields unchanged from the previous case — only the setting changed + // user fields unchanged from the previous case - only the setting changed assert!( !user.is_enrolled(), "LDAP user should not be enrolled when remote enrollment is enabled but not completed" diff --git a/crates/defguard_common/src/types/proxy.rs b/crates/defguard_common/src/types/proxy.rs index 503f5996f1..560d2172be 100644 --- a/crates/defguard_common/src/types/proxy.rs +++ b/crates/defguard_common/src/types/proxy.rs @@ -2,7 +2,7 @@ use chrono::NaiveDateTime; use serde::Serialize; use utoipa::ToSchema; -use crate::db::Id; +use crate::db::{Id, models::proxy::Proxy}; // Used by the proxy manager to control proxies (start/shutdown). pub enum ProxyControlMessage { @@ -27,8 +27,27 @@ pub struct ProxyInfo { pub disconnected_at: Option, pub version: Option, pub enabled: bool, - pub certificate: Option, + pub certificate_serial: Option, pub certificate_expiry: Option, pub modified_at: NaiveDateTime, pub modified_by: String, } + +impl From> for ProxyInfo { + fn from(value: Proxy) -> Self { + Self { + id: value.id, + name: value.name, + address: value.address, + port: value.port, + connected_at: value.connected_at, + disconnected_at: value.disconnected_at, + version: value.version, + enabled: value.enabled, + certificate_serial: value.certificate_serial, + certificate_expiry: value.certificate_expiry, + modified_at: value.modified_at, + modified_by: value.modified_by, + } + } +} diff --git a/crates/defguard_core/Cargo.toml b/crates/defguard_core/Cargo.toml index b089f91a46..fb24a87761 100644 --- a/crates/defguard_core/Cargo.toml +++ b/crates/defguard_core/Cargo.toml @@ -16,6 +16,7 @@ defguard_web_ui = { workspace = true } defguard_version = { workspace = true } model_derive = { workspace = true } defguard_certs = { workspace = true } +defguard_grpc_tls = { workspace = true } defguard_static_ip = { workspace = true } # external dependencies diff --git a/crates/defguard_core/src/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index 685ebeeded..0ff36b1176 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -52,7 +52,7 @@ where })?; if let Some(header) = maybe_auth_header { let token_string = header.token(); - debug!("Trying to authorize request using API token: {token_string}"); + debug!("Trying to authorize request using API token"); return match ApiToken::try_find_by_auth_token(&pool, token_string).await { Ok(Some(api_token)) => { // create a dummy session and don't store it in the DB diff --git a/crates/defguard_core/src/cert_settings.rs b/crates/defguard_core/src/cert_settings.rs index aab47ead67..6dc308f486 100644 --- a/crates/defguard_core/src/cert_settings.rs +++ b/crates/defguard_core/src/cert_settings.rs @@ -160,7 +160,7 @@ pub async fn apply_internal_url_settings( let san = vec![hostname.clone()]; let dn = vec![(DnType::CommonName, hostname.as_str())]; let csr = Csr::new(&key_pair, &san, dn)?; - let server_cert = ca.sign_csr(&csr)?; + let server_cert = ca.sign_server_cert(&csr)?; let cert_der = server_cert.der().to_vec(); let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; @@ -300,7 +300,7 @@ pub async fn apply_external_url_settings( let san = vec![hostname.clone()]; let dn = vec![(DnType::CommonName, hostname.as_str())]; let csr = Csr::new(&key_pair, &san, dn)?; - let server_cert = ca.sign_csr(&csr)?; + let server_cert = ca.sign_server_cert(&csr)?; let cert_der = server_cert.der().to_vec(); let cert_pem = der_to_pem(&cert_der, PemLabel::Certificate)?; diff --git a/crates/defguard_core/src/enrollment_management.rs b/crates/defguard_core/src/enrollment_management.rs index 04758b0647..014544328f 100644 --- a/crates/defguard_core/src/enrollment_management.rs +++ b/crates/defguard_core/src/enrollment_management.rs @@ -186,7 +186,7 @@ pub async fn clear_unused_enrollment_tokens<'e, E: PgExecutor<'e>>( /// Sends an enrollment invitation to a newly-created LDAP user when both /// `ldap_remote_enrollment_enabled` and `ldap_remote_enrollment_send_invite` settings are enabled. /// -/// Errors are logged and swallowed — this must not disrupt the caller's flow. +/// Errors are logged and swallowed - this must not disrupt the caller's flow. pub async fn try_send_ldap_enrollment_invite(user: &mut User, conn: &mut PgConnection) { let settings = Settings::get_current_settings(); if !settings.ldap_remote_enrollment_enabled || !settings.ldap_remote_enrollment_send_invite { diff --git a/crates/defguard_core/src/enterprise/ldap/tests.rs b/crates/defguard_core/src/enterprise/ldap/tests.rs index af0a081154..0a6d64237d 100644 --- a/crates/defguard_core/src/enterprise/ldap/tests.rs +++ b/crates/defguard_core/src/enterprise/ldap/tests.rs @@ -3446,7 +3446,7 @@ async fn test_sync_does_not_send_invite_when_flags_disabled( let pool = setup_pool(options).await; let _ = initialize_current_settings(&pool).await; - // Create an admin so find_admins() would have something to return — we want to prove + // Create an admin so find_admins() would have something to return - we want to prove // the early-return on the flag guard, not the no-admin guard. make_test_admin(&pool, "sync_admin_nodisabled").await; @@ -3522,7 +3522,7 @@ async fn test_sync_invite_skipped_when_send_invite_flag_disabled( /// syncing a new LDAP user must create an enrollment token and set `enrollment_pending = true`. /// /// SMTP is configured in settings but no real SMTP server is reachable, so `new_account_mail` -/// will fail — but the token and flag are persisted before the mail attempt, so the DB side +/// will fail - but the token and flag are persisted before the mail attempt, so the DB side /// effects are still observable. #[sqlx::test] async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: PgConnectOptions) { @@ -3572,7 +3572,7 @@ async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: Pg "Token should belong to the synced user" ); - // Second sync: user already exists in Defguard — must NOT create a second token. + // Second sync: user already exists in Defguard - must NOT create a second token. ldap_conn.sync(&pool, false).await.unwrap(); let tokens = Token::fetch_all(&pool).await.unwrap(); @@ -3584,7 +3584,7 @@ async fn test_sync_sends_invite_when_flags_enabled(_: PgPoolOptions, options: Pg } /// When both invite flags are on but there are no active admins in Defguard, the sync must -/// succeed and the user must be saved — the invite is silently skipped with a logged error. +/// succeed and the user must be saved - the invite is silently skipped with a logged error. #[sqlx::test] async fn test_sync_invite_skipped_when_no_admin_exists( _: PgPoolOptions, @@ -3687,7 +3687,7 @@ async fn test_ldap_login_sends_invite_when_flags_enabled( "Token should belong to the logged-in user" ); - // Second login: user now exists in Defguard — must NOT create a second token. + // Second login: user now exists in Defguard - must NOT create a second token. let result = login_through_ldap_with_connection(&pool, &mut ldap_conn, "login_invite_user", PASSWORD) .await; diff --git a/crates/defguard_core/src/enterprise/ldap/utils.rs b/crates/defguard_core/src/enterprise/ldap/utils.rs index 9d38d468f0..c16e10f1b4 100644 --- a/crates/defguard_core/src/enterprise/ldap/utils.rs +++ b/crates/defguard_core/src/enterprise/ldap/utils.rs @@ -96,7 +96,7 @@ pub(crate) async fn login_through_ldap_with_connection( // Attempt to send enrollment invite after the original DB transaction is committed, // so that the user row is visible to the new transaction inside try_send_ldap_enrollment_invite. - // Only send for newly-created users — returning users must not receive a second invite. + // Only send for newly-created users - returning users must not receive a second invite. if is_new_user { let mut transaction = pool.begin().await?; try_send_ldap_enrollment_invite(&mut user, &mut transaction).await; diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index 90924344a4..c189e60da4 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -29,7 +29,7 @@ use defguard_common::{ utils::strip_scheme, }; use defguard_proto::{ - common::{CertificateInfo, DerPayload}, + common::{CertBundle, CertificateInfo}, gateway::gateway_setup_client::GatewaySetupClient, proxy::{AcmeStep, proxy_setup_client::ProxySetupClient}, }; @@ -38,7 +38,13 @@ use futures::Stream; use reqwest::Url; use serde::{Deserialize, Serialize}; use sqlx::PgPool; -use tokio::sync::mpsc::{Sender, UnboundedReceiver, unbounded_channel}; +use tokio::{ + sync::{ + mpsc::{Sender, UnboundedReceiver, unbounded_channel}, + oneshot, + }, + time::{Instant, sleep_until, timeout}, +}; use tokio_stream::StreamExt; use tonic::{ Request, Status, @@ -57,6 +63,11 @@ use crate::{ const TOKEN_CLIENT_ID: &str = "Defguard Core"; const CONNECTION_TIMEOUT: Duration = Duration::from_secs(10); +/// Maximum lifetime of a one-time setup session token. +/// The setup handshake must complete within this window; tokens that outlive +/// it are useless and limiting the expiry reduces the damage window if the +/// token is captured from the plaintext setup channel. +const SETUP_TOKEN_EXPIRY_SECS: u64 = 300; /// Guard that aborts a tokio task when dropped struct TaskGuard(tokio::task::JoinHandle<()>); @@ -348,7 +359,7 @@ pub async fn setup_proxy_tls_stream( defguard_common::auth::claims::ClaimsType::Gateway, url.to_string(), TOKEN_CLIENT_ID.to_string(), - u32::MAX.into(), + SETUP_TOKEN_EXPIRY_SECS, ) .to_jwt() { @@ -373,7 +384,7 @@ pub async fn setup_proxy_tls_stream( request.grpc_port ); - let response_with_metadata = match tokio::time::timeout(CONNECTION_TIMEOUT, client.start(())).await { + let response_with_metadata = match timeout(CONNECTION_TIMEOUT, client.start(())).await { Ok(Ok(response)) => response, Ok(Err(status)) => { let error_msg = status.message(); @@ -510,6 +521,11 @@ pub async fn setup_proxy_tls_stream( } }; + if let Err(e) = csr.verify_hostname(hostname) { + yield Ok(flow.error(&format!("CSR hostname validation failed: {e}"))); + return; + } + debug!("Received certificate signing request from Edge for hostname: {hostname}"); // Step 5: Sign certificate @@ -534,7 +550,7 @@ pub async fn setup_proxy_tls_stream( debug!("Certificate authority loaded and ready to sign certificates"); - let cert = match ca.sign_csr(&csr) { + let cert = match ca.sign_server_cert(&csr) { Ok(c) => c, Err(e) => { yield Ok(flow.error(&format!("Failed to sign CSR: {e}"))); @@ -547,7 +563,20 @@ pub async fn setup_proxy_tls_stream( // Step 6: Configure TLS yield Ok(flow.step(SetupStep::ConfiguringTls)); - if let Err(e) = client.send_cert(DerPayload { der_data: cert.der().to_vec() }).await { + let core_client = match ca.issue_core_client_cert(&request.common_name) { + Ok(c) => c, + Err(e) => { + yield Ok(flow.error(&format!("Failed to issue Core client certificate: {e}"))); + return; + } + }; + + let bundle = CertBundle { + component_cert_der: cert.der().to_vec(), + ca_cert_der: ca_cert_der.clone(), + core_client_cert_der: core_client.cert_der.clone(), + }; + if let Err(e) = client.send_cert(bundle).await { yield Ok(flow.error(&format!("Failed to send certificate: {e}"))); return; } @@ -571,8 +600,11 @@ pub async fn setup_proxy_tls_stream( i32::from(request.grpc_port), session.user.fullname().as_str(), ); - proxy.certificate = Some(serial); + proxy.certificate_serial = Some(serial); proxy.certificate_expiry = Some(expiry); + proxy.core_client_cert_der = Some(core_client.cert_der); + proxy.core_client_cert_key_der = Some(core_client.key_der); + proxy.core_client_cert_expiry = Some(core_client.expiry); let proxy = match proxy.save(&pool).await { Ok(p) => p, @@ -776,7 +808,7 @@ pub async fn setup_gateway_tls_stream( defguard_common::auth::claims::ClaimsType::Gateway, url.to_string(), TOKEN_CLIENT_ID.to_string(), - u32::MAX.into(), + SETUP_TOKEN_EXPIRY_SECS, ) .to_jwt() { @@ -803,7 +835,7 @@ pub async fn setup_gateway_tls_stream( debug!("Initiating connection to Gateway at {ip_or_domain}:{}", request.grpc_port); - let response_with_metadata = match tokio::time::timeout( + let response_with_metadata = match timeout( CONNECTION_TIMEOUT, client.start(()) ).await { @@ -952,6 +984,11 @@ pub async fn setup_gateway_tls_stream( } }; + if let Err(e) = csr.verify_hostname(hostname) { + yield Ok(flow.error(&format!("CSR hostname validation failed: {e}"))); + return; + } + debug!("Received certificate signing request from Gateway for hostname: {hostname}"); // Step 5: Sign certificate @@ -980,7 +1017,7 @@ pub async fn setup_gateway_tls_stream( debug!("Certificate authority loaded and ready to sign certificates"); - let cert = match ca.sign_csr(&csr) { + let cert = match ca.sign_server_cert(&csr) { Ok(c) => c, Err(e) => { yield Ok(flow.error(&format!("Failed to sign CSR: {e}"))); @@ -993,11 +1030,20 @@ pub async fn setup_gateway_tls_stream( // Step 6: Configure TLS yield Ok(flow.step(SetupStep::ConfiguringTls)); - let response = DerPayload { - der_data: cert.der().to_vec(), + let core_client = match ca.issue_core_client_cert(&request.common_name) { + Ok(c) => c, + Err(e) => { + yield Ok(flow.error(&format!("Failed to issue Core client certificate: {e}"))); + return; + } }; - if let Err(e) = client.send_cert(response).await { + let bundle = CertBundle { + component_cert_der: cert.der().to_vec(), + ca_cert_der: ca_cert_der.clone(), + core_client_cert_der: core_client.cert_der.clone(), + }; + if let Err(e) = client.send_cert(bundle).await { yield Ok(flow.error(&format!("Failed to send certificate: {e}"))); return; } @@ -1028,8 +1074,11 @@ pub async fn setup_gateway_tls_stream( session.user.fullname(), ); - gateway.certificate = Some(serial); + gateway.certificate_serial = Some(serial); gateway.certificate_expiry = Some(expiry); + gateway.core_client_cert_der = Some(core_client.cert_der); + gateway.core_client_cert_key_der = Some(core_client.key_der); + gateway.core_client_cert_expiry = Some(core_client.expiry); if let Err(err) = gateway.save(&pool).await { yield Ok(flow.error(&format!("Failed to save Gateway to database: {err}"))); @@ -1123,7 +1172,7 @@ pub async fn stream_proxy_acme( let account_credentials_json = certs.acme_account_credentials.clone().unwrap_or_default(); - let proxies = match Proxy::list(&pool).await { + let proxies = match Proxy::all_enabled(&pool).await { Ok(list) => list, Err(e) => { yield Ok(acme_error_event( @@ -1146,8 +1195,8 @@ pub async fn stream_proxy_acme( return; }; - let proxy_host = proxy.address.clone(); - let proxy_port = proxy.port as u16; + let proxy_host = &proxy.address; + let proxy_port = proxy.port; info!( "Triggering ACME HTTP-01 via Edge gRPC TriggerAcme for domain: {domain} \ Edge={proxy_host}:{proxy_port}" @@ -1156,7 +1205,7 @@ pub async fn stream_proxy_acme( let (progress_tx, mut progress_rx) = unbounded_channel::(); let (result_tx, result_rx) = - tokio::sync::oneshot::channel::)>>(); + oneshot::channel::)>>(); let pool_clone = pool.clone(); let domain_clone = domain.clone(); @@ -1164,8 +1213,7 @@ pub async fn stream_proxy_acme( tokio::spawn(async move { let result = call_proxy_trigger_acme( &pool_clone, - &proxy_host, - proxy_port, + &proxy, domain_clone, acct_creds_clone, progress_tx, @@ -1175,7 +1223,7 @@ pub async fn stream_proxy_acme( }); let mut current_step: &'static str = "Connecting"; - let deadline = tokio::time::Instant::now() + ACME_TIMEOUT; + let deadline = Instant::now() + ACME_TIMEOUT; // Drain progress steps until the ACME task finishes (channel closed) or times out. loop { @@ -1193,7 +1241,7 @@ pub async fn stream_proxy_acme( } } - () = tokio::time::sleep_until(deadline) => { + () = sleep_until(deadline) => { yield Ok(acme_error_event( current_step, format!( diff --git a/crates/defguard_core/src/handlers/gateway.rs b/crates/defguard_core/src/handlers/gateway.rs index a36d142f61..da6a7ac9c3 100644 --- a/crates/defguard_core/src/handlers/gateway.rs +++ b/crates/defguard_core/src/handlers/gateway.rs @@ -28,7 +28,7 @@ pub struct GatewayInfo { pub connected_at: Option, pub disconnected_at: Option, pub connected: bool, - pub certificate: Option, + pub certificate_serial: Option, pub certificate_expiry: Option, pub version: Option, pub enabled: bool, @@ -41,16 +41,19 @@ impl GatewayInfo { pub async fn list(pool: &PgPool) -> sqlx::Result> { query_as!( Self, - "SELECT gateway.*, \ + "SELECT \ + g.id, g.location_id, g.name, g.address, g.port, g.connected_at, g.disconnected_at, \ CASE \ - WHEN gateway.connected_at IS NULL THEN false \ - WHEN gateway.disconnected_at IS NULL THEN true \ - WHEN gateway.connected_at >= gateway.disconnected_at THEN true \ + WHEN g.connected_at IS NULL THEN false \ + WHEN g.disconnected_at IS NULL THEN true \ + WHEN g.connected_at >= g.disconnected_at THEN true \ ELSE false \ END AS \"connected!\", \ + g.certificate_serial, g.certificate_expiry, g.version, \ + g.enabled, g.modified_at, g.modified_by, \ wn.name AS location_name \ - FROM gateway \ - JOIN wireguard_network wn ON gateway.location_id = wn.id", + FROM gateway g \ + JOIN wireguard_network wn ON g.location_id = wn.id", ) .fetch_all(pool) .await @@ -59,16 +62,19 @@ impl GatewayInfo { pub async fn find_by_location_id(pool: &PgPool, location_id: Id) -> sqlx::Result> { query_as!( Self, - "SELECT gateway.*, \ + "SELECT \ + g.id, g.location_id, g.name, g.address, g.port, g.connected_at, g.disconnected_at, \ CASE \ - WHEN gateway.connected_at IS NULL THEN false \ - WHEN gateway.disconnected_at IS NULL THEN true \ - WHEN gateway.connected_at >= gateway.disconnected_at THEN true \ + WHEN g.connected_at IS NULL THEN false \ + WHEN g.disconnected_at IS NULL THEN true \ + WHEN g.connected_at >= g.disconnected_at THEN true \ ELSE false \ END AS \"connected!\", \ + g.certificate_serial, g.certificate_expiry, g.version, \ + g.enabled, g.modified_at, g.modified_by, \ wn.name AS location_name \ - FROM gateway \ - JOIN wireguard_network wn ON gateway.location_id = wn.id \ + FROM gateway g \ + JOIN wireguard_network wn ON g.location_id = wn.id \ WHERE location_id = $1", location_id ) diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 2a02d3ef82..0ec588d9f3 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -90,7 +90,7 @@ pub async fn send_support_data( "version": g.version.as_deref().unwrap_or("unknown"), "address": g.address, "port": g.port, - "certificate": g.certificate, + "certificate": g.certificate_serial, "name": g.name, "connected_at": g.connected_at, })).collect::>(), diff --git a/crates/defguard_core/src/handlers/proxy.rs b/crates/defguard_core/src/handlers/proxy.rs index 3f1769dd23..f6b76d1df9 100644 --- a/crates/defguard_core/src/handlers/proxy.rs +++ b/crates/defguard_core/src/handlers/proxy.rs @@ -45,6 +45,7 @@ pub(crate) async fn proxy_list( ) -> ApiResult { debug!("User {} displaying proxy list", session.user.username); let proxies = Proxy::list(&appstate.pool).await?; + let proxies: Vec = proxies.into_iter().map(Into::into).collect(); info!("User {} displayed proxy list", session.user.username); Ok(ApiResponse::json(proxies, StatusCode::OK)) diff --git a/crates/defguard_core/src/letsencrypt.rs b/crates/defguard_core/src/letsencrypt.rs index 601cc3ff50..4830cae57b 100644 --- a/crates/defguard_core/src/letsencrypt.rs +++ b/crates/defguard_core/src/letsencrypt.rs @@ -1,12 +1,15 @@ -use std::time::Duration; +use std::{collections::HashMap, sync::Arc, time::Duration}; use chrono::{NaiveDateTime, TimeDelta, Utc}; -use defguard_certs::der_to_pem; use defguard_common::{ VERSION, - db::models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, + db::{ + Id, + models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, + }, types::proxy::ProxyControlMessage, }; +use defguard_grpc_tls::certs::proxy_mtls_channel; use defguard_mail::templates; use defguard_proto::proxy::{ AcmeChallenge, AcmeLogs, AcmeStep, acme_issue_event, proxy_client::ProxyClient, @@ -14,12 +17,14 @@ use defguard_proto::proxy::{ use defguard_version::{Version, client::ClientVersionInterceptor}; use sqlx::PgPool; use thiserror::Error; -use tokio::sync::mpsc::{self, UnboundedSender, unbounded_channel}; -use tonic::{ - Request, - service::Interceptor, - transport::{Certificate, ClientTlsConfig, Endpoint}, +use tokio::{ + sync::{ + mpsc::{self, UnboundedSender, unbounded_channel}, + watch, + }, + time::timeout, }; +use tonic::{Request, service::Interceptor}; /// Maximum time (seconds) allowed for the ACME flow to complete end-to-end. #[cfg(not(test))] @@ -117,12 +122,11 @@ pub(crate) async fn do_letsencrypt_refresh( let (progress_tx, _progress_rx) = unbounded_channel::(); - match tokio::time::timeout( + match timeout( ACME_TIMEOUT, call_proxy_trigger_acme( pool, - &proxy_host, - proxy_port, + &proxy, domain.clone(), account_credentials_json, progress_tx, @@ -237,8 +241,7 @@ pub(crate) fn acme_step_name(step: AcmeStep) -> &'static str { /// collected during the ACME run (sent by the proxy via an [`AcmeLogs`] event). pub(crate) async fn call_proxy_trigger_acme( pool: &PgPool, - proxy_host: &str, - proxy_port: u16, + proxy: &Proxy, domain: String, account_credentials_json: String, progress_tx: UnboundedSender, @@ -253,32 +256,29 @@ pub(crate) async fn call_proxy_trigger_acme( ) })?; - let cert_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate) - .map_err(|e| (format!("Failed to convert CA cert to PEM: {e}"), Vec::new()))?; - - let endpoint_str = format!("https://{proxy_host}:{proxy_port}"); - let endpoint = Endpoint::from_shared(endpoint_str) - .map_err(|e| (format!("Failed to build Edge endpoint: {e}"), Vec::new()))? - .http2_keep_alive_interval(Duration::from_secs(5)) - .tcp_keepalive(Some(Duration::from_secs(5))) - .keep_alive_while_idle(true); - - let tls = ClientTlsConfig::new().ca_certificate(Certificate::from_pem(cert_pem)); - let endpoint = endpoint.tls_config(tls).map_err(|e| { + let cert_serial = proxy.certificate_serial.as_deref().ok_or_else(|| { ( - format!("Failed to configure TLS for Edge endpoint: {e}"), + "Edge certificate serial not provisioned".to_string(), Vec::new(), ) })?; + // Seed a one-shot serial map so the rustls verifier validates the server cert serial. + let (_, certs_rx) = watch::channel(Arc::new(HashMap::from([( + proxy.id, + cert_serial.to_string(), + )]))); + + let channel = proxy_mtls_channel(proxy, &ca_cert_der, certs_rx) + .map_err(|e| (format!("Failed to build mTLS channel: {e}"), Vec::new()))?; + let version = Version::parse(VERSION) .map_err(|e| (format!("Failed to parse core version: {e}"), Vec::new()))?; let version_interceptor = ClientVersionInterceptor::new(version); - let mut client = - ProxyClient::with_interceptor(endpoint.connect_lazy(), move |req: Request<()>| { - version_interceptor.clone().call(req) - }); + let mut client = ProxyClient::with_interceptor(channel, move |req: Request<()>| { + version_interceptor.clone().call(req) + }); let mut stream = client .trigger_acme(AcmeChallenge { @@ -338,7 +338,9 @@ mod tests { time::Duration, }; - use defguard_certs::{CertificateAuthority, Csr, DnType, PemLabel, generate_key_pair}; + use defguard_certs::{ + CertificateAuthority, Csr, DnType, ExtendedKeyUsagePurpose, PemLabel, generate_key_pair, + }; use defguard_common::{ db::{ models::{Certificates, ProxyCertSource, Settings, User, proxy::Proxy}, @@ -456,6 +458,7 @@ mod tests { struct MockAcmeServer { port: u16, + server_cert_serial: String, task: JoinHandle<()>, } @@ -466,7 +469,7 @@ mod tests { behavior: MockAcmeBehavior, ) -> Self { init_rustls_crypto_provider(); - let identity = make_server_identity(ca, common_name); + let (identity, server_cert_serial) = make_server_identity(ca, common_name); let listener = TcpListener::bind(SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0)) .await .expect("failed to bind mock ACME server"); @@ -487,7 +490,11 @@ mod tests { tokio::task::yield_now().await; - Self { port, task } + Self { + port, + server_cert_serial, + task, + } } } @@ -497,18 +504,26 @@ mod tests { } } - fn make_server_identity(ca: &CertificateAuthority<'_>, common_name: &str) -> Identity { + fn make_server_identity( + ca: &CertificateAuthority<'_>, + common_name: &str, + ) -> (Identity, String) { let key_pair = generate_key_pair().expect("failed to generate key pair"); let san = vec![common_name.to_string()]; let dn = vec![(DnType::CommonName, common_name)]; let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); - let cert = ca.sign_csr(&csr).expect("failed to sign server cert"); + let cert = ca + .sign_server_cert(&csr) + .expect("failed to sign server cert"); + let serial = defguard_certs::CertificateInfo::from_der(cert.der()) + .expect("failed to parse server cert info") + .serial; let cert_pem = defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); let key_pem = defguard_certs::der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey) .expect("key PEM"); - Identity::from_pem(cert_pem, key_pem) + (Identity::from_pem(cert_pem, key_pem), serial) } fn init_rustls_crypto_provider() { @@ -568,7 +583,7 @@ mod tests { let dn = vec![(DnType::CommonName, common_name)]; let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); let cert = ca - .sign_csr_with_validity(&csr, valid_for_days) + .sign_csr_with_validity(&csr, valid_for_days, &[ExtendedKeyUsagePurpose::ServerAuth]) .expect("failed to sign cert"); let cert_pem = defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"); @@ -588,9 +603,19 @@ mod tests { certs.save(pool).await.expect("failed to save LE certs"); } - async fn create_proxy(pool: &sqlx::PgPool, address: &str, port: u16) { + async fn create_proxy( + pool: &sqlx::PgPool, + address: &str, + port: u16, + certificate_serial: &str, + core_client_cert: &defguard_certs::CoreClientCert, + ) { let mut proxy = Proxy::new("test-proxy", address, i32::from(port), "tester"); proxy.enabled = true; + proxy.certificate_serial = Some(certificate_serial.to_string()); + proxy.core_client_cert_der = Some(core_client_cert.cert_der.clone()); + proxy.core_client_cert_key_der = Some(core_client_cert.key_der.clone()); + proxy.core_client_cert_expiry = Some(core_client_cert.expiry); proxy.save(pool).await.expect("failed to save proxy"); } @@ -674,7 +699,7 @@ mod tests { let san = vec!["localhost".to_string()]; let dn = vec![(DnType::CommonName, "localhost")]; let csr = Csr::new(&key_pair, &san, dn).expect("failed to create CSR"); - let cert = ca.sign_csr(&csr).expect("failed to sign cert"); + let cert = ca.sign_server_cert(&csr).expect("failed to sign cert"); ( defguard_certs::der_to_pem(cert.der(), PemLabel::Certificate).expect("cert PEM"), defguard_certs::der_to_pem( @@ -697,7 +722,17 @@ mod tests { }, ) .await; - create_proxy(&pool, "localhost", mock_server.port).await; + let core_client_cert = ca + .issue_core_client_cert("localhost") + .expect("failed to issue core client cert"); + create_proxy( + &pool, + "localhost", + mock_server.port, + &mock_server.server_cert_serial, + &core_client_cert, + ) + .await; let (proxy_control_tx, mut proxy_control_rx) = mpsc::channel(8); let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; @@ -750,7 +785,17 @@ mod tests { MockAcmeBehavior::RpcError(Status::unavailable("rpc unavailable")), ) .await; - create_proxy(&pool, "localhost", mock_server.port).await; + let core_client_cert = ca + .issue_core_client_cert("localhost") + .expect("failed to issue core client cert"); + create_proxy( + &pool, + "localhost", + mock_server.port, + &mock_server.server_cert_serial, + &core_client_cert, + ) + .await; let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); let result = do_letsencrypt_refresh(&pool, proxy_control_tx).await; @@ -774,7 +819,17 @@ mod tests { seed_letsencrypt_cert(&pool, &ca, "localhost", 1).await; let mock_server = MockAcmeServer::start(&ca, "localhost", MockAcmeBehavior::Hang).await; - create_proxy(&pool, "localhost", mock_server.port).await; + let core_client_cert = ca + .issue_core_client_cert("localhost") + .expect("failed to issue core client cert"); + create_proxy( + &pool, + "localhost", + mock_server.port, + &mock_server.server_cert_serial, + &core_client_cert, + ) + .await; let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(8); let result = timeout( diff --git a/crates/defguard_core/src/support.rs b/crates/defguard_core/src/support.rs index 806ed6e9c1..3b26f3370f 100644 --- a/crates/defguard_core/src/support.rs +++ b/crates/defguard_core/src/support.rs @@ -30,6 +30,7 @@ pub(crate) async fn dump_config(conn: &mut PgConnection) -> Result { settings.smtp_password = None; + settings.ldap_bind_password = None; json!(settings) } Ok(None) => json!({"error": "Settings not found"}), diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs index 16f0be96f2..3ca7cdecaf 100644 --- a/crates/defguard_core/tests/integration/api/acl/aliases.rs +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -399,8 +399,6 @@ async fn test_alias_audit_fields_track_acting_user_across_mutations( assert_ne!(created_alias_row.modified_by, "admin"); let created_modified_at = created_alias_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let mut alias_update = created_alias.clone(); alias_update.name = "alias updated by hpotter".to_string(); let response = client @@ -421,8 +419,6 @@ async fn test_alias_audit_fields_track_acting_user_across_mutations( assert!(updated_alias_row.modified_at > created_modified_at); let updated_modified_at = updated_alias_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let response = client .put("/api/v1/acl/alias/apply") .json(&json!({ "aliases": [updated_alias.id] })) diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index f1d69a1091..a170c941a0 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -551,8 +551,6 @@ async fn test_destination_audit_fields_track_acting_user_across_mutations( assert_ne!(created_destination_row.modified_by, "admin"); let created_modified_at = created_destination_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let mut destination_update = created_destination.clone(); destination_update.name = "destination updated by hpotter".to_string(); let response = client @@ -580,8 +578,6 @@ async fn test_destination_audit_fields_track_acting_user_across_mutations( assert!(updated_destination_row.modified_at > created_modified_at); let updated_modified_at = updated_destination_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let response = client .put("/api/v1/acl/destination/apply") .json(&json!({ "destinations": [updated_destination.id] })) diff --git a/crates/defguard_core/tests/integration/api/acl/rules.rs b/crates/defguard_core/tests/integration/api/acl/rules.rs index bb82c340b7..1885319783 100644 --- a/crates/defguard_core/tests/integration/api/acl/rules.rs +++ b/crates/defguard_core/tests/integration/api/acl/rules.rs @@ -568,7 +568,7 @@ async fn test_empty_strings(_: PgPoolOptions, options: PgConnectOptions) { let (mut client, _) = make_test_client(pool).await; authenticate_admin(&mut client).await; - // rule — empty address and port strings are parse-safe; use any_* flags so validation passes + // rule - empty address and port strings are parse-safe; use any_* flags so validation passes let mut rule = make_rule(); rule.addresses = String::new(); rule.ports = String::new(); @@ -1409,8 +1409,6 @@ async fn test_rule_audit_fields_track_acting_user_across_mutations( assert_ne!(created_rule_row.modified_by, "admin"); let created_modified_at = created_rule_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let mut updated_rule = created_rule.clone(); updated_rule.name = "rule updated by hpotter".to_string(); let response = client @@ -1429,8 +1427,6 @@ async fn test_rule_audit_fields_track_acting_user_across_mutations( assert!(updated_rule_row.modified_at > created_modified_at); let updated_modified_at = updated_rule_row.modified_at; - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - let response = client .put("/api/v1/acl/rule/apply") .json(&json!({ "rules": [created_rule.id] })) diff --git a/crates/defguard_core/tests/integration/api/auth.rs b/crates/defguard_core/tests/integration/api/auth.rs index 2feaf2816e..af2e8f8b9c 100644 --- a/crates/defguard_core/tests/integration/api/auth.rs +++ b/crates/defguard_core/tests/integration/api/auth.rs @@ -1166,7 +1166,7 @@ async fn test_totp_enable_persists(_: PgPoolOptions, options: PgConnectOptions) client.login_user("hpotter", "pass123").await; - // Init TOTP — the secret is returned directly, no SMTP required. + // Init TOTP - the secret is returned directly, no SMTP required. let response = client.post("/api/v1/auth/totp/init").send().await; assert_eq!(response.status(), StatusCode::OK); let auth_totp: AuthTotp = response.json().await; diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 738f693b9c..f514e313ba 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -7,7 +7,10 @@ use std::{ }; use axum_extra::extract::cookie::Key; -use defguard_certs::{CertificateAuthority, Csr, DnType, PemLabel, der_to_pem, generate_key_pair}; +use defguard_certs::{ + CertificateAuthority, Csr, DnType, ExtendedKeyUsagePurpose, PemLabel, der_to_pem, + generate_key_pair, +}; pub use defguard_common::db::setup_pool; use defguard_common::{ VERSION, @@ -255,7 +258,7 @@ pub(crate) fn generate_test_cert_pem(common_name: &str) -> (String, String) { let san = vec![common_name.to_string()]; let dn = vec![(DnType::CommonName, common_name)]; let csr = Csr::new(&key_pair, &san, dn).unwrap(); - let cert = ca.sign_csr(&csr).unwrap(); + let cert = ca.sign_server_cert(&csr).unwrap(); let cert_pem = der_to_pem(cert.der(), PemLabel::Certificate).unwrap(); let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey).unwrap(); (cert_pem, key_pem) @@ -267,7 +270,9 @@ pub(crate) fn generate_expired_test_cert_pem(common_name: &str) -> (String, Stri let san = vec![common_name.to_string()]; let dn = vec![(DnType::CommonName, common_name)]; let csr = Csr::new(&key_pair, &san, dn).unwrap(); - let cert = ca.sign_csr_with_validity(&csr, 0).unwrap(); + let cert = ca + .sign_csr_with_validity(&csr, 0, &[ExtendedKeyUsagePurpose::ServerAuth]) + .unwrap(); let cert_pem = der_to_pem(cert.der(), PemLabel::Certificate).unwrap(); let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey).unwrap(); (cert_pem, key_pem) diff --git a/crates/defguard_core/tests/integration/api/enrollment.rs b/crates/defguard_core/tests/integration/api/enrollment.rs index a1be4c3ba6..96678e6890 100644 --- a/crates/defguard_core/tests/integration/api/enrollment.rs +++ b/crates/defguard_core/tests/integration/api/enrollment.rs @@ -407,7 +407,7 @@ async fn test_ldap_user_enrolled_via_api_when_remote_enrollment_disabled( user.ldap_remote_enrollment_completed = false; user.save(&pool).await.unwrap(); - // ldap_remote_enrollment_enabled is false by default — no settings change needed. + // ldap_remote_enrollment_enabled is false by default - no settings change needed. let details = fetch_user_details(&client, &new_user.username).await; assert!( details.user.enrolled, diff --git a/crates/defguard_core/tests/integration/api/proxy_certs.rs b/crates/defguard_core/tests/integration/api/proxy_certs.rs index a9540a505a..44a2dd39dd 100644 --- a/crates/defguard_core/tests/integration/api/proxy_certs.rs +++ b/crates/defguard_core/tests/integration/api/proxy_certs.rs @@ -9,6 +9,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::{Arc, Mutex}, + time::Duration, }; use axum_extra::extract::cookie::Key; @@ -46,6 +47,7 @@ use tokio::{ broadcast, mpsc::{Receiver, Sender, channel, unbounded_channel}, }, + time::sleep, }; use super::common::{client::TestClient, generate_expired_test_cert_pem, generate_test_cert_pem}; @@ -61,7 +63,7 @@ impl ProxyBroadcastCapture { async fn drain_broadcast_certs(&mut self) -> Vec<(String, String)> { let mut results = Vec::new(); // Give the handler a brief moment to enqueue the message. - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + sleep(Duration::from_millis(50)).await; loop { match self.rx.try_recv() { Ok(ProxyControlMessage::BroadcastHttpsCerts { cert_pem, key_pem }) => { @@ -76,7 +78,7 @@ impl ProxyBroadcastCapture { async fn drain_clear_https_certs(&mut self) -> usize { let mut results = 0; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; + sleep(Duration::from_millis(50)).await; loop { match self.rx.try_recv() { Ok(ProxyControlMessage::ClearHttpsCerts) => { diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index d83bf17902..881a69287c 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -1165,7 +1165,7 @@ async fn test_modify_user_admin_updates_other_user(_: PgPoolOptions, options: Pg assert_eq!(updated.last_name, "UpdatedLast"); assert_eq!(updated.email, "updated@hogwart.edu.uk"); assert_eq!(updated.phone, Some("+48999888777".into())); - // mfa_method must NOT have changed — admin is not updating self + // mfa_method must NOT have changed - admin is not updating self assert_eq!(updated.mfa_method, old_user.mfa_method); client.verify_api_events(&[ApiEventType::UserModified { diff --git a/crates/defguard_gateway_manager/src/certs.rs b/crates/defguard_gateway_manager/src/certs.rs index 5e2367e179..2f4bb118c7 100644 --- a/crates/defguard_gateway_manager/src/certs.rs +++ b/crates/defguard_gateway_manager/src/certs.rs @@ -22,7 +22,7 @@ pub(super) async fn refresh_certs(pool: &PgPool, tx: &watch::Sender) { - if let Some(gateway_id) = maybe_gateway_id { - self.test_support.note_gateway_notification(gateway_id); - } + fn note_gateway_notification_for_tests(&self, gateway_id: Id) { + self.test_support.note_gateway_notification(gateway_id); } fn manager_reconnect_delay(&self) -> Duration { @@ -347,10 +349,12 @@ impl GatewayManager { let _refresh_certs_task = AbortTaskOnDrop::new(tokio::spawn(async move { loop { certs::refresh_certs(&refresh_pool, &certs_tx).await; - tokio::time::sleep(TEN_SECS).await; + sleep(TEN_SECS).await; } })); - let mut abort_handles = HashMap::new(); + // Stores the abort handle and a snapshot of the gateway at the time the handler was last + // started. The snapshot is used by the Update arm to detect connection-relevant changes. + let mut abort_handles: HashMap)> = HashMap::new(); for gateway in Gateway::all(&self.pool).await? { if !gateway.enabled { debug!("Existing Gateway is disabled, so it won't be handled"); @@ -358,9 +362,10 @@ impl GatewayManager { } let id = gateway.id; + let snapshot = gateway.clone(); let abort_handle = self.run_handler(gateway, Arc::clone(&self.clients), certs_rx.clone())?; - abort_handles.insert(id, abort_handle); + abort_handles.insert(id, (abort_handle, snapshot)); } // Observe gateway changes. @@ -372,116 +377,158 @@ impl GatewayManager { while let Ok(notification) = listener.recv().await { let payload = notification.payload(); - match serde_json::from_str::>>(payload) { + match serde_json::from_str::(payload) { Ok(gateway_notification) => { - let _maybe_gateway_id = match gateway_notification.operation { + let gateway_id = gateway_notification.id; + + match gateway_notification.operation { TriggerOperation::Insert => { - let Some(new) = gateway_notification.new else { - continue; + let gateway = match Gateway::find_by_id(&self.pool, gateway_id).await { + Ok(Some(gateway)) => gateway, + Ok(None) => { + warn!( + "Received Insert notification for Gateway \ + id={gateway_id} but it was not found in the database" + ); + #[cfg(test)] + self.note_gateway_notification_for_tests(gateway_id); + continue; + } + Err(err) => { + error!("Failed to fetch Gateway id={gateway_id}: {err}"); + continue; + } }; - let id = new.id; - if new.enabled { + if gateway.enabled { + let snapshot = gateway.clone(); let abort_handle = self.run_handler( - new, + gateway, Arc::clone(&self.clients), certs_rx.clone(), )?; - abort_handles.insert(id, abort_handle); + abort_handles.insert(gateway_id, (abort_handle, snapshot)); } else { - debug!("New Gateway is disabled, so it won't be handled"); + debug!( + "New Gateway id={gateway_id} is disabled, so it won't be \ + handled" + ); } - Some(id) + #[cfg(test)] + self.note_gateway_notification_for_tests(gateway_id); } TriggerOperation::Update => { - let (Some(mut old), Some(new)) = - (gateway_notification.old, gateway_notification.new) - else { - continue; - }; - - let id = new.id; - if old.address == new.address - && old.port == new.port - && old.enabled == new.enabled - { - debug!("Gateway address/port/state didn't change"); - } else { - self.remove_client(old.id); - if let Some(abort_handle) = abort_handles.remove(&old.id) { - if let Err(err) = old.touch_disconnected(&self.pool).await { - error!( - "Failed to update disconnection time for Gateway {old} \ - after database change: {err}" + let mut gateway = + match Gateway::find_by_id(&self.pool, gateway_id).await { + Ok(Some(gateway)) => gateway, + Ok(None) => { + warn!( + "Received Update notification for Gateway \ + id={gateway_id} but it was not found in the database" ); + #[cfg(test)] + self.note_gateway_notification_for_tests(gateway_id); + continue; + } + Err(err) => { + error!("Failed to fetch Gateway id={gateway_id}: {err}"); + continue; } + }; + + // Only restart the handler when connection-relevant fields have actually changed + let should_restart = match abort_handles.get(&gateway_id) { + Some((_, snapshot)) => needs_restart(snapshot, &gateway), + // Gateway not currently handled - treat as needing a (re)start. + None => true, + }; + + if should_restart { + self.remove_client(gateway_id); + if let Some((abort_handle, _)) = abort_handles.remove(&gateway_id) { info!( - "Aborting connection to Gateway {old}, it has changed in \ - the database" + "Aborting connection to Gateway id={gateway_id}, \ + connection-relevant fields have changed" ); abort_handle.abort(); - } else if old.enabled { - warn!( - "Cannot find Gateway {old} on the list of connected \ - gateways" - ); } - if new.enabled { + + // Only mark disconnected if the gateway was actually connected + if gateway.is_connected() { + if let Err(err) = gateway.touch_disconnected(&self.pool).await { + error!( + "Failed to update disconnection time for Gateway \ + id={gateway_id} after database change: {err}" + ); + } + } + + if gateway.enabled { + let snapshot = gateway.clone(); let abort_handle = self.run_handler( - new, + gateway, Arc::clone(&self.clients), certs_rx.clone(), )?; - abort_handles.insert(id, abort_handle); + abort_handles.insert(gateway_id, (abort_handle, snapshot)); } else { - debug!("Updated Gateway is disabled, so it won't be handled"); + debug!( + "Updated Gateway id={gateway_id} is disabled, so it \ + won't be handled" + ); + } + } else { + // Non-connection-relevant update (e.g. version bump from handler + // save). Refresh the stored snapshot so future comparisons use + // up-to-date baseline values. + if let Some((_, snapshot)) = abort_handles.get_mut(&gateway_id) { + *snapshot = gateway; } } - Some(id) + #[cfg(test)] + self.note_gateway_notification_for_tests(gateway_id); } TriggerOperation::Delete => { - let Some(old) = gateway_notification.old else { - continue; - }; - // Send purge request to Gateway. - let maybe_client = self.remove_client(old.id); + let maybe_client = self.remove_client(gateway_id); if let Some(mut client) = maybe_client { - debug!("Sending purge request to Gateway {old}"); + debug!("Sending purge request to Gateway id={gateway_id}"); if let Err(err) = client.purge(Request::new(())).await { - error!("Error sending purge request to Gateway {old}: {err}"); + error!( + "Error sending purge request to Gateway id={gateway_id}: \ + {err}" + ); } else { - info!("Sent purge request to Gateway {old}"); + info!("Sent purge request to Gateway id={gateway_id}"); } } else { warn!( - "Cannot find gRPC client for Gateway {old}; skipping purge \ - request" + "Cannot find gRPC client for Gateway id={gateway_id}; \ + skipping purge request" ); } // Kill the `GatewayHandler` and the connection. - if let Some(abort_handle) = abort_handles.remove(&old.id) { + if let Some((abort_handle, _)) = abort_handles.remove(&gateway_id) { info!( - "Aborting connection to Gateway {old}, it has disappeared from \ - the database" + "Aborting connection to Gateway id={gateway_id}, it has \ + disappeared from the database" ); abort_handle.abort(); - } else if old.enabled { + } else { warn!( - "Cannot find Gateway {old} on the list of connected gateways" + "Cannot find Gateway id={gateway_id} on the list of \ + connected gateways" ); } - Some(old.id) + #[cfg(test)] + self.note_gateway_notification_for_tests(gateway_id); } }; - - #[cfg(test)] - self.note_gateway_notification_for_tests(_maybe_gateway_id); } Err(err) => error!("Failed to de-serialize database notification object: {err}"), } @@ -518,6 +565,93 @@ impl GatewayManager { } } +/// Returns true if the change from `old` to `new` requires the gateway handler to be +/// restarted - i.e. if any field that directly affects the gRPC connection or TLS identity +/// has changed. +/// +/// Fields that do NOT trigger a restart (version, timestamps, audit fields, cert expiry) +/// are intentionally excluded so that the handler-internal `gateway.save()` call, which +/// bumps those fields, does not cause an infinite restart loop. +fn needs_restart(old: &Gateway, new: &Gateway) -> bool { + old.address != new.address + || old.port != new.port + || old.enabled != new.enabled + || old.core_client_cert_der != new.core_client_cert_der + || old.core_client_cert_key_der != new.core_client_cert_key_der +} + +#[cfg(test)] +mod unit_tests { + use chrono::Utc; + use defguard_common::db::{Id, models::gateway::Gateway}; + + use super::needs_restart; + + fn base_gateway() -> Gateway { + Gateway { + id: 1, + location_id: 1, + name: "test".to_string(), + address: "127.0.0.1".to_string(), + port: 50051, + connected_at: None, + disconnected_at: None, + certificate_serial: None, + certificate_expiry: None, + version: None, + enabled: true, + modified_at: Utc::now().naive_utc(), + modified_by: "test".to_string(), + core_client_cert_der: None, + core_client_cert_key_der: None, + core_client_cert_expiry: None, + } + } + + #[test] + fn test_needs_restart_detects_connection_relevant_field_changes() { + let base = base_gateway(); + + // Identical gateways - no restart needed. + assert!(!needs_restart(&base, &base.clone())); + + // Non-connection-relevant fields - no restart. + let mut no_restart = base.clone(); + no_restart.version = Some("2.0.0".to_string()); + no_restart.modified_by = "someone-else".to_string(); + no_restart.connected_at = Some(Utc::now().naive_utc()); + no_restart.disconnected_at = Some(Utc::now().naive_utc()); + no_restart.certificate_serial = Some("abc".to_string()); + no_restart.core_client_cert_expiry = Some(Utc::now().naive_utc()); + assert!(!needs_restart(&base, &no_restart)); + + // address change - restart required. + let mut changed = base.clone(); + changed.address = "10.0.0.1".to_string(); + assert!(needs_restart(&base, &changed)); + + // port change - restart required. + let mut changed = base.clone(); + changed.port = 9999; + assert!(needs_restart(&base, &changed)); + + // enabled change - restart required. + let mut changed = base.clone(); + changed.enabled = false; + assert!(needs_restart(&base, &changed)); + + // core_client_cert_der change - restart required. + let mut changed = base.clone(); + changed.core_client_cert_der = Some(vec![1, 2, 3]); + assert!(needs_restart(&base, &changed)); + + // core_client_cert_key_der change - restart required. + let mut changed = base.clone(); + changed.core_client_cert_key_der = Some(vec![4, 5, 6]); + assert!(needs_restart(&base, &changed)); + } +} + /// Shared set of outbound channels that gateway instances use to forward /// events, notifications, and side effects to Core components. #[derive(Clone)] diff --git a/crates/defguard_gateway_manager/src/tests/common/mod.rs b/crates/defguard_gateway_manager/src/tests/common/mod.rs index 9a4d007aca..4e70ce0fbc 100644 --- a/crates/defguard_gateway_manager/src/tests/common/mod.rs +++ b/crates/defguard_gateway_manager/src/tests/common/mod.rs @@ -33,7 +33,7 @@ use tokio::{ oneshot, watch, }, task::JoinHandle, - time::timeout, + time::{sleep, timeout}, }; use tokio_stream::{once, wrappers::UnboundedReceiverStream}; use tonic::{Request, Response, Status, Streaming, transport::Server}; @@ -576,7 +576,7 @@ impl HandlerTestContext { wait_for_gateway_connection_state(&self.pool, self.gateway.id, true).await; timeout(TEST_TIMEOUT, async { while self.events_tx().receiver_count() <= initial_event_receivers { - tokio::time::sleep(Duration::from_millis(20)).await; + sleep(Duration::from_millis(20)).await; } }) .await @@ -652,7 +652,7 @@ pub(crate) async fn wait_for_gateway_connection_state( return gateway; } - tokio::time::sleep(Duration::from_millis(20)).await; + sleep(Duration::from_millis(20)).await; } }) .await diff --git a/crates/defguard_gateway_manager/src/tests/gateway_manager/manager.rs b/crates/defguard_gateway_manager/src/tests/gateway_manager/manager.rs index fa82343b0e..27cfdd3d12 100644 --- a/crates/defguard_gateway_manager/src/tests/gateway_manager/manager.rs +++ b/crates/defguard_gateway_manager/src/tests/gateway_manager/manager.rs @@ -86,6 +86,9 @@ async fn test_noop_gateway_update_does_not_restart_handler( _: PgPoolOptions, options: PgConnectOptions, ) { + // A DB update that changes only non-connection-relevant fields (e.g. modified_by) + // should NOT cause the handler to be restarted. The Update notification is still + // received and counted, but the existing handler must remain connected. let mut context = ManagerTestContext::new(options).await; let network = create_network(&context.pool).await; let mut gateway = create_gateway(&context.pool, network.id).await; @@ -98,30 +101,24 @@ async fn test_noop_gateway_update_does_not_restart_handler( gateway = reload_gateway(&context.pool, gateway.id).await; let initial_spawn_attempts = context.handler_spawn_attempt_count(gateway.id); let initial_notification_count = context.gateway_notification_count(gateway.id); - let initial_connection_count = mock_gateway.connection_count(); gateway.modified_by = "manager-noop-update".to_string(); gateway .save(&context.pool) .await - .expect("failed to save no-op gateway update"); + .expect("failed to save gateway noop update"); + // The Update notification must be received and counted. context .wait_for_gateway_notification_count(gateway.id, initial_notification_count + 1) .await; + + // But no new handler spawn should have occurred. assert_eq!( context.handler_spawn_attempt_count(gateway.id), initial_spawn_attempts, - "no-op gateway update should not restart the handler" + "a non-connection-relevant update should not restart the handler" ); - assert_eq!( - mock_gateway.connection_count(), - initial_connection_count, - "no-op gateway update should not reconnect the handler" - ); - - let gateway_after = reload_gateway(&context.pool, gateway.id).await; - assert!(gateway_after.is_connected()); context.finish().await; } diff --git a/crates/defguard_grpc_tls/Cargo.toml b/crates/defguard_grpc_tls/Cargo.toml index 3d79cb5622..d07f472337 100644 --- a/crates/defguard_grpc_tls/Cargo.toml +++ b/crates/defguard_grpc_tls/Cargo.toml @@ -10,9 +10,11 @@ rust-version.workspace = true [dependencies] defguard_common.workspace = true http = "1.1" +hyper-rustls.workspace = true rustls = { version = "0.23", features = ["ring"] } thiserror.workspace = true tokio.workspace = true +tonic.workspace = true tower-service = "0.3" x509-parser = "0.18" tracing.workspace = true diff --git a/crates/defguard_grpc_tls/src/certs.rs b/crates/defguard_grpc_tls/src/certs.rs index e9f9f44aa5..446bbf959f 100644 --- a/crates/defguard_grpc_tls/src/certs.rs +++ b/crates/defguard_grpc_tls/src/certs.rs @@ -8,9 +8,10 @@ //! - A lightweight in-memory cache (refreshed periodically) avoids database access //! during the handshake and keeps verification synchronous. -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, sync::Arc, time::Duration}; -use defguard_common::db::Id; +use defguard_common::db::{Id, models::proxy::Proxy}; +use hyper_rustls::HttpsConnectorBuilder; use rustls::{ CertificateError, DistinguishedName, Error as RustlsError, RootCertStore, SignatureScheme, client::{ @@ -18,13 +19,18 @@ use rustls::{ danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, }, crypto, - pki_types::{CertificateDer, ServerName, UnixTime}, + pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}, }; use thiserror::Error; use tokio::sync::watch; +use tonic::transport::{Certificate, Channel, Endpoint, Identity, ServerTlsConfig}; use tracing::error; use x509_parser::parse_x509_certificate; +use crate::connector::HttpsSchemeConnector; + +const TEN_SECS: Duration = Duration::from_secs(10); + /// Errors that can occur while building a TLS config with a pinned verifier. #[derive(Debug, Error)] pub enum CertConfigError { @@ -138,11 +144,42 @@ fn root_store_from_ca(ca_cert_der: &[u8]) -> Result, + component_key_pem: impl AsRef<[u8]>, + ca_cert_pem: impl AsRef<[u8]>, +) -> Result { + let identity = Identity::from_pem(component_cert_pem.as_ref(), component_key_pem.as_ref()); + let ca = Certificate::from_pem(ca_cert_pem.as_ref()); + Ok(ServerTlsConfig::new() + .identity(identity) + .client_ca_root(ca) + .client_auth_optional(false)) +} + +/// Create a rustls client config that enforces the pinned component certificate serial +/// and presents the Core client certificate for mutual TLS authentication. +/// +/// `core_client_cert_der` and `core_client_cert_key_der` are the DER-encoded client +/// certificate and its private key that Core presents to the gateway/proxy during the +/// TLS handshake. The gateway/proxy verifies this cert against `ca_cert_der`. pub fn client_config( ca_cert_der: &[u8], certs_rx: watch::Receiver>>, component_id: Id, + core_client_cert_der: &[u8], + core_client_cert_key_der: &[u8], ) -> Result { let provider = Arc::new(crypto::ring::default_provider()); let roots = root_store_from_ca(ca_cert_der)?; @@ -153,10 +190,19 @@ pub fn client_config( ) .build() .map_err(|err| CertConfigError::TlsConfig(err.to_string()))?; + + let client_cert = CertificateDer::from(core_client_cert_der.to_vec()); + let client_key = PrivateKeyDer::try_from(core_client_cert_key_der.to_vec()) + .map_err(|err| CertConfigError::TlsConfig(format!("invalid client key DER: {err}")))?; + let builder = rustls::ClientConfig::builder_with_provider(provider) .with_safe_default_protocol_versions() .map_err(|err| CertConfigError::TlsConfig(err.to_string()))?; - let mut config = builder.with_root_certificates(roots).with_no_client_auth(); + let mut config = builder + .with_root_certificates(roots) + .with_client_auth_cert(vec![client_cert], client_key) + .map_err(|err| CertConfigError::TlsConfig(format!("client auth cert error: {err}")))?; + let verifier: Arc = verifier; config .dangerous() @@ -167,3 +213,53 @@ pub fn client_config( ))); Ok(config) } + +/// Build an mTLS [`Channel`] to a proxy using its stored per-component client certificate. +/// +/// * `proxy` - the full `Proxy` row from the database; `core_client_cert_der`, +/// `core_client_cert_key_der`, and `certificate_serial` must all be `Some`. +/// * `ca_cert_der` - the core CA certificate in DER form, used as the only trusted root. +/// * `certs_rx` - watch channel carrying the current `{ proxy_id → cert_serial }` map. +/// Pass a long-lived receiver for persistent connections (serial revocation is picked up +/// dynamically) or a one-shot channel seeded with the proxy's current serial for +/// short-lived calls. +/// +/// The returned channel uses an `http://` endpoint scheme; TLS is applied by the +/// internal [`HttpsSchemeConnector`](crate::connector::HttpsSchemeConnector). +pub fn proxy_mtls_channel( + proxy: &Proxy, + ca_cert_der: &[u8], + certs_rx: watch::Receiver>>, +) -> Result { + let cert_der = proxy.core_client_cert_der.as_deref().ok_or_else(|| { + CertConfigError::TlsConfig(format!( + "core client certificate not provisioned for proxy id={}", + proxy.id + )) + })?; + let key_der = proxy.core_client_cert_key_der.as_deref().ok_or_else(|| { + CertConfigError::TlsConfig(format!( + "core client certificate key not provisioned for proxy id={}", + proxy.id + )) + })?; + + let tls_config = client_config(ca_cert_der, certs_rx, proxy.id, cert_der, key_der)?; + + let connector = HttpsConnectorBuilder::new() + .with_tls_config(tls_config) + .https_only() + .enable_http2() + .build(); + let connector = HttpsSchemeConnector::new(connector); + + // Use http:// scheme - the HttpsSchemeConnector rewrites it to https:// internally. + let endpoint_str = format!("http://{}:{}", proxy.address, proxy.port); + let endpoint = Endpoint::from_shared(endpoint_str) + .map_err(|e| CertConfigError::TlsConfig(format!("invalid proxy endpoint URL: {e}")))? + .http2_keep_alive_interval(TEN_SECS) + .tcp_keepalive(Some(TEN_SECS)) + .keep_alive_while_idle(true); + + Ok(endpoint.connect_with_connector_lazy(connector)) +} diff --git a/crates/defguard_grpc_tls/src/lib.rs b/crates/defguard_grpc_tls/src/lib.rs index b7a37f7f97..2d3ab7fc2a 100644 --- a/crates/defguard_grpc_tls/src/lib.rs +++ b/crates/defguard_grpc_tls/src/lib.rs @@ -1,2 +1,3 @@ pub mod certs; pub mod connector; +pub mod server; diff --git a/crates/defguard_grpc_tls/src/server.rs b/crates/defguard_grpc_tls/src/server.rs new file mode 100644 index 0000000000..6405d5fb5a --- /dev/null +++ b/crates/defguard_grpc_tls/src/server.rs @@ -0,0 +1,58 @@ +//! Server-side mTLS utilities for gateway and proxy gRPC servers. + +use tonic::{ + Request, Status, + transport::server::{TcpConnectInfo, TlsConnectInfo}, +}; +use x509_parser::prelude::*; + +/// Returns a tonic interceptor closure that enforces the Core client certificate serial. +/// +/// On every incoming RPC the interceptor: +/// 1. Reads the peer certificate from [`TlsConnectInfo`] (populated by tonic's TLS stack). +/// 2. Parses its serial via `x509_parser`. +/// 3. Rejects the request with [`Status::unauthenticated`] if the serial does not match +/// `expected_serial` (case-insensitive, colon-separated hex comparison). +/// +/// When `expected_serial` is `None` the check is skipped entirely, which allows the +/// same service builder chain to be used in plain-HTTP (no-TLS) development mode. +/// +/// # Usage +/// +/// Place this interceptor **outermost** in the `ServiceBuilder` chain so that +/// authentication runs before any other middleware: +/// +/// ```rust,ignore +/// ServiceBuilder::new() +/// .layer(tonic::service::interceptor(certificate_serial_interceptor(serial))) +/// .layer(/* version layer */) +/// .service(/* gRPC service */) +/// ``` +pub fn certificate_serial_interceptor( + expected_serial: String, +) -> impl Fn(Request<()>) -> Result, Status> + Clone + Send + 'static { + move |req| { + let certs = req + .extensions() + .get::>() + .and_then(|info| info.peer_certs()) + .ok_or_else(|| Status::unauthenticated("Missing client certificate"))?; + + let der = certs + .first() + .ok_or_else(|| Status::unauthenticated("Empty client certificate chain"))?; + + let (_, cert) = parse_x509_certificate(der) + .map_err(|_| Status::unauthenticated("Invalid client certificate"))?; + + let peer_serial = cert.tbs_certificate.raw_serial_as_string(); + + if !peer_serial.eq_ignore_ascii_case(&expected_serial) { + return Err(Status::unauthenticated( + "Client certificate serial mismatch", + )); + } + + Ok(req) + } +} diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 8b8bc80314..f5c92b1ae1 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -18,6 +18,7 @@ use sqlx::{ postgres::{PgConnectOptions, PgPoolOptions}, }; use tera::Context; +use tokio::time::sleep; use super::{Attachment, mail::MailMessage, templates}; @@ -31,7 +32,7 @@ fn dg25_8_server_side_template_injection() { /// Delay, so send_and_forget() can process the message. async fn delay() { - tokio::time::sleep(Duration::from_secs(2)).await; + sleep(Duration::from_secs(2)).await; } /// Set SMTP settings from environment variables. diff --git a/crates/defguard_proxy_manager/src/certs.rs b/crates/defguard_proxy_manager/src/certs.rs index ac16d1e0ba..a427614a2c 100644 --- a/crates/defguard_proxy_manager/src/certs.rs +++ b/crates/defguard_proxy_manager/src/certs.rs @@ -24,7 +24,7 @@ pub(crate) async fn refresh_certs(pool: &PgPool, tx: &watch::Sender Result { - let mut url = self.url.clone(); - - // Using HTTP here because the connector upgrades to TLS internally. - url.set_scheme("http").map_err(|()| { - ProxyError::UrlError(format!("Failed to set HTTP scheme on URL {url}")) - })?; - let endpoint = Endpoint::from_shared(url.to_string())?; - let endpoint = endpoint - .http2_keep_alive_interval(TEN_SECS) - .tcp_keepalive(Some(TEN_SECS)) - .keep_alive_while_idle(true); - - Ok(endpoint) - } - - async fn connect_tls_channel( + async fn connect_channel_mtls( &self, - endpoint: &Endpoint, certs_rx: watch::Receiver>>, ) -> Result { let certs = Certificates::get(&self.pool) @@ -256,24 +238,28 @@ impl ProxyHandler { "Core CA is not setup, can't create a Proxy endpoint.".to_string(), ) })?; - let tls_config = tls_certs::client_config(&ca_cert_der, certs_rx, self.proxy_id) - .map_err(|err| ProxyError::TlsConfigError(err.to_string()))?; - let connector = HttpsConnectorBuilder::new() - .with_tls_config(tls_config) - .https_only() - .enable_http2() - .build(); - let connector = HttpsSchemeConnector::new(connector); - Ok(endpoint.connect_with_connector_lazy(connector)) + + // Load the Proxy model to retrieve the per-component Core client cert. + let proxy = Proxy::find_by_id(&self.pool, self.proxy_id) + .await + .map_err(ProxyError::SqlxError)? + .ok_or_else(|| { + ProxyError::MissingConfiguration(format!( + "Proxy id={} not found in DB, can't load Core client certificate", + self.proxy_id + )) + })?; + + proxy_mtls_channel(&proxy, &ca_cert_der, certs_rx) + .map_err(|e| ProxyError::TlsConfigError(e.to_string())) } #[cfg(not(test))] async fn connect_channel( &self, - endpoint: &Endpoint, certs_rx: watch::Receiver>>, ) -> Result { - self.connect_tls_channel(endpoint, certs_rx).await + self.connect_channel_mtls(certs_rx).await } /// Establishes and maintains a gRPC bidirectional stream to the proxy. @@ -289,23 +275,28 @@ impl ProxyHandler { ) -> Result<(), ProxyError> { let parsed_version = Version::parse(VERSION)?; loop { - let endpoint = self.endpoint()?; - - let channel = match self.connect_channel(&endpoint, certs_rx.clone()).await { + let channel = match self.connect_channel(certs_rx.clone()).await { Ok(ch) => ch, Err(err) => { error!( "Failed to create proxy channel for {}: {err}, retrying in {:?}", - endpoint.uri(), + self.url, self.retry_delay() ); self.mark_disconnected().await?; - sleep(self.retry_delay()).await; + let mut shutdown = self.shutdown_signal.lock().await; + select! { + _ = sleep(self.retry_delay()) => {} + _ = &mut *shutdown => { + debug!("Shutdown signal received during reconnect backoff (connect_channel failure), stopping"); + break; + } + } continue; } }; - debug!("Connecting to proxy at {}", endpoint.uri()); + debug!("Connecting to proxy at {}", self.url); let interceptor = ClientVersionInterceptor::new(parsed_version.clone()); let mut client = ProxyClient::with_interceptor(channel, interceptor); self.client = Some(client.clone()); @@ -324,7 +315,7 @@ impl ProxyHandler { error!( "Failed to connect to proxy @ {}, version check failed, retrying in \ {:?}: {err}", - endpoint.uri(), + self.url, self.retry_delay() ); // TODO push event @@ -332,7 +323,7 @@ impl ProxyHandler { err => { error!( "Failed to connect to proxy @ {}, retrying in {:?}: {err}", - endpoint.uri(), + self.url, self.retry_delay() ); } @@ -342,7 +333,14 @@ impl ProxyHandler { map.remove(&self.proxy_id); } self.mark_disconnected().await?; - sleep(self.retry_delay()).await; + let mut shutdown = self.shutdown_signal.lock().await; + select! { + _ = sleep(self.retry_delay()) => {} + _ = &mut *shutdown => { + debug!("Shutdown signal received during reconnect backoff (bidi failure), stopping"); + break; + } + } continue; } }; @@ -367,12 +365,19 @@ impl ProxyHandler { data.insert(&incompatible_components); // Sleep before trying to reconnect - sleep(self.retry_delay()).await; + let mut shutdown = self.shutdown_signal.lock().await; + select! { + _ = sleep(self.retry_delay()) => {} + _ = &mut *shutdown => { + debug!("Shutdown signal received during reconnect backoff (version incompatible), stopping"); + break; + } + } continue; } IncompatibleComponents::remove_proxy(&incompatible_components); - info!("Connected to proxy at {}", endpoint.uri()); + info!("Connected to proxy at {}", self.url); let mut resp_stream = response.into_inner(); // Send initial info with private cookies key. @@ -428,17 +433,17 @@ impl ProxyHandler { res = &mut *shutdown_signal.lock().await => { match res { Err(err) => { - error!("An error occurred when trying to wait for a shutdown signal for Proxy: {err}. Reconnecting to: {}", endpoint.uri()); + error!("An error occurred when trying to wait for a shutdown signal for Proxy: {err}. Reconnecting to: {}", self.url); } Ok(purge) => { - info!("Shutdown signal received, purge: {purge}, stopping proxy connection to {}", endpoint.uri()); + info!("Shutdown signal received, purge: {purge}, stopping proxy connection to {}", self.url); if purge { if let Some(client) = self.client.as_mut() { - debug!("Sending purge request to proxy {}", endpoint.uri()); + debug!("Sending purge request to proxy {}", self.url); if let Err(err) = client.purge(Request::new(())).await { - error!("Error sending purge request to proxy {}: {err}", endpoint.uri()); + error!("Error sending purge request to proxy {}: {err}", self.url); } else { - info!("Sent purge request to proxy {}", endpoint.uri()); + info!("Sent purge request to proxy {}", self.url); } } } @@ -1041,10 +1046,12 @@ impl ProxyHandler { async fn connect_channel( &self, - endpoint: &Endpoint, certs_rx: watch::Receiver>>, ) -> Result { if let Some(socket_path) = self.test_transport.socket_path().cloned() { + // Build a minimal endpoint for the Unix socket connector. + // The scheme and host are irrelevant - the connector ignores the URI. + let endpoint = Endpoint::from_shared(self.url.to_string())?; return Ok(endpoint.connect_with_connector_lazy(tower::service_fn( move |_: tonic::transport::Uri| { let socket_path = socket_path.clone(); @@ -1057,7 +1064,7 @@ impl ProxyHandler { ))); } - self.connect_tls_channel(endpoint, certs_rx).await + self.connect_channel_mtls(certs_rx).await } /// Single-iteration version of `run()` for use in tests. @@ -1072,12 +1079,11 @@ impl ProxyHandler { certs_rx: watch::Receiver>>, ) -> Result<(), ProxyError> { let parsed_version = Version::parse(VERSION)?; - let endpoint = self.endpoint()?; - let channel = self.connect_channel(&endpoint, certs_rx).await?; + let channel = self.connect_channel(certs_rx).await?; debug!( "Connecting to proxy at {} (test, single iteration)", - endpoint.uri() + self.url ); let interceptor = ClientVersionInterceptor::new(parsed_version); let mut client = ProxyClient::with_interceptor(channel, interceptor); @@ -1112,7 +1118,7 @@ impl ProxyHandler { } IncompatibleComponents::remove_proxy(&incompatible_components); - info!("Connected to proxy at {} (test)", endpoint.uri()); + info!("Connected to proxy at {} (test)", self.url); let mut resp_stream = response.into_inner(); let initial_info = InitialInfo { diff --git a/crates/defguard_proxy_manager/src/tests/common/mod.rs b/crates/defguard_proxy_manager/src/tests/common/mod.rs index 404c536022..bbc07ca072 100644 --- a/crates/defguard_proxy_manager/src/tests/common/mod.rs +++ b/crates/defguard_proxy_manager/src/tests/common/mod.rs @@ -41,7 +41,7 @@ use tokio::{ oneshot, watch, }, task::JoinHandle, - time::timeout, + time::{sleep, timeout}, }; use tokio_stream::{once, wrappers::UnboundedReceiverStream}; use tonic::{Request, Response, Status, Streaming, transport::Server}; @@ -54,6 +54,8 @@ pub(crate) const CORE_RESPONSE_TIMEOUT: Duration = Duration::from_millis(200); pub(crate) const PROXY_CONNECT_DELAY: Duration = Duration::from_millis(20); +pub(crate) const RECEIVE_TIMEOUT: Duration = Duration::from_secs(5); + /// Minimum proxy version that passes `is_proxy_version_supported()`. const MOCK_PROXY_VERSION: defguard_version::Version = defguard_version::Version::new(2, 0, 0); @@ -606,7 +608,7 @@ impl ManagerTestContext { ); let manager_task = tokio::spawn(async move { manager.run().await }); - // No PgListener in proxy manager — just yield to let the manager start. + // No PgListener in proxy manager - just yield to let the manager start. tokio::task::yield_now().await; self.manager_task = Some(manager_task); @@ -662,7 +664,7 @@ pub(crate) async fn wait_for_proxy_connection_state( if proxy.is_connected() == expected_connected { return proxy; } - tokio::time::sleep(PROXY_CONNECT_DELAY).await; + sleep(PROXY_CONNECT_DELAY).await; } }) .await @@ -695,7 +697,7 @@ pub(crate) fn build_proxy_with_enabled(enabled: bool) -> Proxy { } // --------------------------------------------------------------------------- -// MockOidcProvider — a minimal OIDC identity provider for tests +// MockOidcProvider - a minimal OIDC identity provider for tests // --------------------------------------------------------------------------- /// Shared state injected into axum route handlers. diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/acme.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/acme.rs index e9bae41f3e..6104f3b12e 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/acme.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/acme.rs @@ -3,15 +3,15 @@ use defguard_proto::proxy::{ AcmeCertificate as AcmeCertPayload, CoreRequest, core_request, core_response, }; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -use tokio::time::{Duration, timeout}; +use tokio::time::timeout; use super::support::complete_proxy_handshake; use crate::tests::common::{ - HandlerTestContext, ManagerTestContext, MockProxyHarness, create_proxy, + HandlerTestContext, ManagerTestContext, MockProxyHarness, RECEIVE_TIMEOUT, create_proxy, }; /// A minimal but syntactically valid PEM certificate block (content is -/// arbitrary bytes — the handler stores it verbatim without parsing). +/// arbitrary bytes - the handler stores it verbatim without parsing). const TEST_CERT_PEM: &str = "-----BEGIN CERTIFICATE-----\nMIIBkTCB+wIJ\n-----END CERTIFICATE-----\n"; const TEST_KEY_PEM: &str = @@ -154,7 +154,7 @@ async fn test_acme_certificate_overwrites_existing(_: PgPoolOptions, options: Pg /// in `handler_tx_map`. After processing `AcmeCertificate`, it broadcasts /// `HttpsCerts` to ALL registered handlers, which forward it to their /// respective proxy streams. This test verifies that every connected mock -/// proxy receives the `HttpsCerts` response — including proxies other than the +/// proxy receives the `HttpsCerts` response - including proxies other than the /// one that sent the certificate. #[sqlx::test] async fn test_acme_certificate_broadcasts_to_connected_proxy( @@ -182,13 +182,13 @@ async fn test_acme_certificate_broadcasts_to_connected_proxy( TEST_ACCOUNT_JSON, )); - // The handler must broadcast HttpsCerts to ALL registered proxies — both + // The handler must broadcast HttpsCerts to ALL registered proxies - both // the sender (proxy A) and the bystander (proxy B). for (label, mock) in [ ("proxy A (sender)", &mut mock_a), ("proxy B (bystander)", &mut mock_b), ] { - let response = timeout(Duration::from_secs(5), mock.recv_outbound()) + let response = timeout(RECEIVE_TIMEOUT, mock.recv_outbound()) .await .unwrap_or_else(|_| panic!("timed out waiting for HttpsCerts broadcast on {label}")); diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs index 39867b0c20..25825e1927 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/enrollment.rs @@ -221,7 +221,7 @@ async fn test_activate_user_already_activated_returns_error( let token = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; start_enrollment_session(&mut context, &token.id).await; - // First activation — must succeed. + // First activation - must succeed. let first = send_activate_user(&mut context, &token.id, STRONG_PASSWORD, None).await; match &first.payload { Some(core_response::Payload::Empty(())) => {} @@ -234,7 +234,7 @@ async fn test_activate_user_already_activated_returns_error( let token2 = create_enrollment_token(&context.pool, user.id, Some(user.id)).await; start_enrollment_session(&mut context, &token2.id).await; - // Second activation — must fail with InvalidArgument. + // Second activation - must fail with InvalidArgument. let second = send_activate_user(&mut context, &token2.id, STRONG_PASSWORD, None).await; let code = assert_error_response(&second); assert_eq!( @@ -393,7 +393,7 @@ async fn test_existing_device_wrong_user_returns_error( let _ = user_a; // suppress unused warning // Enrollment token belonging to user_b, NOT user_a (device owner). - // No admin needed — this test only checks that an error is returned; + // No admin needed - this test only checks that an error is returned; // the session validation will fail before the welcome-page template renders. let wrong_token = create_enrollment_token(&context.pool, user_b.id, None).await; @@ -678,7 +678,7 @@ async fn test_register_mobile_auth_invalid_device_pubkey( /// `device_pub_key` (not `auth_pub_key`), and WireGuard keys are 32 bytes so /// the check always passes for syntactically valid WireGuard keys. The first /// error path that exercises `auth_pub_key` rejection would require a key -/// whose WireGuard decode fails — but a valid WireGuard key passes both +/// whose WireGuard decode fails - but a valid WireGuard key passes both /// `Device::validate_pubkey` and `BiometricAuth::validate_pubkey`. The /// interesting third error path is therefore "key valid but no device found". #[sqlx::test] @@ -829,7 +829,7 @@ async fn test_activate_ldap_user_sets_ldap_remote_enrollment_completed( } /// When `ldap_remote_enrollment_enabled` is set, a non-LDAP user who completes -/// activation must NOT have `ldap_remote_enrollment_completed` set — the flag is +/// activation must NOT have `ldap_remote_enrollment_completed` set - the flag is /// LDAP-specific and must remain `false`. #[sqlx::test] async fn test_activate_non_ldap_user_does_not_set_ldap_remote_enrollment_completed( diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs index 57b946312a..73f17a464b 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/mfa.rs @@ -1,5 +1,3 @@ -use std::time::Duration; - use defguard_common::db::Id; use defguard_core::grpc::GatewayEvent; use defguard_proto::{ @@ -17,9 +15,8 @@ use super::support::{ send_mfa_finish_no_recv, send_mfa_finish_raw, send_mfa_start, send_token_validation, setup_user_email_mfa, setup_user_totp_mfa, }; -use crate::tests::common::HandlerTestContext; +use crate::tests::common::{HandlerTestContext, RECEIVE_TIMEOUT}; -const EVENT_RECEIVE_TIMEOUT: Duration = Duration::from_secs(5); const WRONG_REQUEST_ID: u64 = 9991; const AWAIT_ID: u64 = 8000; @@ -56,7 +53,7 @@ async fn test_mfa_start_fails_for_unknown_location(_: PgPoolOptions, options: Pg let mut context = HandlerTestContext::new(options).await; complete_proxy_handshake(&mut context).await; - // Create a device so the pubkey lookup succeeds — the handler checks the + // Create a device so the pubkey lookup succeeds - the handler checks the // location_id first, but using a real pubkey avoids masking the error. let (_, device) = create_user_with_device(&context.pool).await; @@ -140,8 +137,8 @@ async fn test_mfa_finish_succeeds_with_totp_code(_: PgPoolOptions, options: PgCo assert!(session.preshared_key.is_some()); // Verify GatewayEvent::MfaSessionAuthorized was broadcast. - // Use the already-subscribed receiver — subscribing after send_mfa_finish would miss the event. - let event = timeout(EVENT_RECEIVE_TIMEOUT, gateway_rx.recv()) + // Use the already-subscribed receiver - subscribing after send_mfa_finish would miss the event. + let event = timeout(RECEIVE_TIMEOUT, gateway_rx.recv()) .await .expect("timed out waiting for GatewayEvent::MfaSessionAuthorized") .expect("gateway event channel closed"); @@ -235,7 +232,7 @@ async fn test_mfa_start_fails_when_email_mfa_not_enabled( let network = create_mfa_network(&context.pool).await; // device is created after the network so add_to_all_networks picks it up let (_, device) = create_user_with_device(&context.pool).await; - // user.email_mfa_enabled is false by default — no setup call + // user.email_mfa_enabled is false by default - no setup call context.mock_proxy().send_request(CoreRequest { id: 1, @@ -284,7 +281,7 @@ async fn test_mfa_finish_succeeds_and_creates_session(_: PgPoolOptions, options: let network = create_mfa_network(&context.pool).await; let (mut user, device) = create_user_with_device(&context.pool).await; - // Setup email MFA — the code is the same one that start_client_mfa_login + // Setup email MFA - the code is the same one that start_client_mfa_login // will regenerate internally, so we can generate it once here. let code = setup_user_email_mfa(&context.pool, &mut user).await; @@ -316,7 +313,7 @@ async fn test_mfa_finish_succeeds_and_creates_session(_: PgPoolOptions, options: assert!(session.preshared_key.is_some()); // Verify GatewayEvent::MfaSessionAuthorized was broadcast - let event = timeout(EVENT_RECEIVE_TIMEOUT, gateway_rx.recv()) + let event = timeout(RECEIVE_TIMEOUT, gateway_rx.recv()) .await .expect("timed out waiting for GatewayEvent::MfaSessionAuthorized") .expect("gateway event channel closed"); @@ -387,7 +384,7 @@ async fn test_mfa_finish_fails_with_wrong_code(_: PgPoolOptions, options: PgConn ) .await; - // Send a clearly wrong code — use _raw so we can inspect the error response + // Send a clearly wrong code - use _raw so we can inspect the error response let response = send_mfa_finish_raw(&mut context, &token, Some("000000")).await; let code = assert_error_response(&response); // invalid code → InvalidArgument or Unauthenticated @@ -451,7 +448,7 @@ async fn test_mfa_await_remote_receives_psk_after_finish( ) .await; - // Send AwaitRemoteMfaFinish first — no immediate response expected + // Send AwaitRemoteMfaFinish first - no immediate response expected context.mock_proxy().send_request(CoreRequest { id: AWAIT_ID, device_info: None, @@ -476,7 +473,7 @@ async fn test_mfa_await_remote_receives_psk_after_finish( send_mfa_finish_no_recv(&mut context, &token, Some(&code)).await; // Two responses should arrive: one ClientMfaFinish and one - // AwaitRemoteMfaFinish — order is not guaranteed. + // AwaitRemoteMfaFinish - order is not guaranteed. let r1 = context.mock_proxy_mut().recv_outbound().await; let r2 = context.mock_proxy_mut().recv_outbound().await; @@ -579,7 +576,7 @@ async fn test_mfa_finish_replaces_existing_session_disconnects_old( let mut got_disconnected = false; let mut got_authorized = false; for _ in 0..2 { - let event = timeout(EVENT_RECEIVE_TIMEOUT, gw_rx2.recv()) + let event = timeout(RECEIVE_TIMEOUT, gw_rx2.recv()) .await .expect("timed out waiting for gateway event after second MFA finish") .expect("gateway event channel closed"); diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs index cb152ba301..a1bea483e2 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs @@ -241,7 +241,7 @@ async fn test_auth_info_requires_oidc_provider(_: PgPoolOptions, options: PgConn complete_proxy_handshake(&mut context).await; set_test_license_business(); - // No OIDC provider is inserted — but we still need a valid public proxy URL + // No OIDC provider is inserted - but we still need a valid public proxy URL // so that edge_callback_url() does not fail before the provider lookup. set_public_proxy_url(&context.pool, "http://proxy.example.com").await; @@ -323,7 +323,7 @@ async fn test_mfa_oidc_full_flow(_: PgPoolOptions, options: PgConnectOptions) { response.payload.as_ref().map(std::mem::discriminant) ); - // ---- Step 3: ClientMfaFinish (no TOTP code — session is OIDC-completed) ---- + // ---- Step 3: ClientMfaFinish (no TOTP code - session is OIDC-completed) ---- let (_, psk) = send_mfa_finish(&mut context, &mfa_token, None).await; assert!( !psk.is_empty(), diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs index 715f636261..70e3d64a8d 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/password_reset.rs @@ -2,6 +2,7 @@ use defguard_common::db::models::User; use defguard_core::events::{BidiStreamEventType, PasswordResetEvent}; use defguard_proto::proxy::core_response; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use tokio::time::timeout; use super::support::{ STRONG_PASSWORD, assert_error_response, complete_proxy_handshake, create_enrollment_token, @@ -64,7 +65,7 @@ async fn test_password_reset_start_returns_deadline(_: PgPoolOptions, options: P assert!(deadline > 0, "deadline_timestamp must be positive"); // A BidiStreamEvent::PasswordReset(PasswordResetStarted) must have been emitted. - let event = tokio::time::timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) + let event = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) .await .expect("timed out waiting for BidiStreamEvent") .expect("bidi_events_rx closed"); @@ -104,7 +105,7 @@ async fn test_password_reset_completes_successfully(_: PgPoolOptions, options: P ), "start must succeed" ); - let _ = tokio::time::timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; + let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; // Reset the password. const NEW_PASSWORD: &str = "NewPass2!"; @@ -134,7 +135,7 @@ async fn test_password_reset_completes_successfully(_: PgPoolOptions, options: P ); // A BidiStreamEvent::PasswordReset(PasswordResetCompleted) must have been emitted. - let event = tokio::time::timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) + let event = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()) .await .expect("timed out waiting for BidiStreamEvent") .expect("bidi_events_rx closed"); @@ -176,7 +177,7 @@ async fn test_password_reset_weak_password_returns_error( ), "start must succeed" ); - let _ = tokio::time::timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; + let _ = timeout(TEST_TIMEOUT, context.bidi_events_rx.recv()).await; // Submit a weak password. let response = send_password_reset(&mut context, &token.id, "weak").await; diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs index 51192362a1..10d7b2026f 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs @@ -54,7 +54,7 @@ async fn test_polling_requires_business_license(_: PgPoolOptions, options: PgCon let mut context = HandlerTestContext::new(options).await; complete_proxy_handshake(&mut context).await; - // Explicitly clear any license — polling should be refused. + // Explicitly clear any license - polling should be refused. clear_test_license(); let (_user, device) = create_user_with_device(&context.pool).await; diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs index 9536271c8c..82aae8c3f5 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/support.rs @@ -2,7 +2,7 @@ use std::{ mem::discriminant, str::FromStr, sync::atomic::{AtomicU16, AtomicU64, Ordering}, - time::{Duration, SystemTime}, + time::SystemTime, }; use defguard_common::{ @@ -45,9 +45,7 @@ use sqlx::PgPool; use tokio::{sync::mpsc::UnboundedReceiver, time::timeout}; use tonic::Code; -use crate::tests::common::{HandlerTestContext, MockOidcProvider}; - -const BIDI_RECEIVE_TIMEOUT: Duration = Duration::from_secs(5); +use crate::tests::common::{HandlerTestContext, MockOidcProvider, RECEIVE_TIMEOUT}; /// A strong password satisfying all `check_password_strength` requirements: /// ≥8 chars, digit, upper, lower, special character. @@ -174,7 +172,7 @@ pub(crate) async fn create_network(pool: &PgPool) -> WireguardNetwork { /// Pre-generated valid 32-byte WireGuard public keys (base64, 44 chars each). /// Used by `create_device_for_user` so that `Device::validate_pubkey` passes. -/// 64 entries — enough headroom so the per-function counter modulo never wraps +/// 64 entries - enough headroom so the per-function counter modulo never wraps /// within a single test and causes unique-key constraint violations. static DEVICE_PUBKEYS: &[&str] = &[ "HCk2Q1BdaneEkZ6ruMXS3+z5BhMgLTpHVGFue4iVoq8=", @@ -327,7 +325,7 @@ pub(crate) async fn create_polling_token(pool: &PgPool, device_id: Id) -> String /// after `complete_proxy_handshake` to open the enrollment session. /// /// The function sends a single `EnrollmentStartRequest` with the given token -/// ID and waits for the `EnrollmentStartResponse` (or any payload — panicking +/// ID and waits for the `EnrollmentStartResponse` (or any payload - panicking /// if the stream closes without a response). pub(crate) async fn start_enrollment_session(context: &mut HandlerTestContext, token_id: &str) { static ENROLL_CTR: AtomicU64 = AtomicU64::new(1000); @@ -416,7 +414,7 @@ pub(crate) async fn setup_user_email_mfa(pool: &PgPool, user: &mut User) -> user.new_email_secret(pool).await.expect("new_email_secret"); user.enable_email_mfa(pool).await.expect("enable_email_mfa"); // generate_email_mfa_code uses the in-memory secret; note that - // start_client_mfa_login also calls generate_email_mfa_code internally — + // start_client_mfa_login also calls generate_email_mfa_code internally - // the two calls will produce the same code because the in-memory secret // hasn't changed. But we need the code *after* the start call, so the // caller should call this helper before start and pass the code to finish. @@ -565,7 +563,7 @@ pub(crate) async fn send_mfa_finish_no_recv( /// Send `ClientMfaFinish` and return the raw `CoreResponse`. /// -/// Like `send_mfa_finish` but does not panic on error — the caller is +/// Like `send_mfa_finish` but does not panic on error - the caller is /// responsible for inspecting `response.payload`. Use this for error-path /// tests where an error response is expected. pub(crate) async fn send_mfa_finish_raw( @@ -621,7 +619,7 @@ pub(crate) async fn send_token_validation(context: &mut HandlerTestContext, toke pub(crate) async fn expect_bidi_mfa_success( bidi_rx: &mut UnboundedReceiver, ) -> Id { - let event = timeout(BIDI_RECEIVE_TIMEOUT, bidi_rx.recv()) + let event = timeout(RECEIVE_TIMEOUT, bidi_rx.recv()) .await .expect("timed out waiting for BidiStreamEvent DesktopClientMfa(Success)") .expect("bidi event channel closed"); diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/manager.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/manager.rs index 97f03fadcb..4566375664 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/manager.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/manager.rs @@ -40,7 +40,7 @@ async fn test_manager_starts_all_enabled_proxies_on_startup( context.finish().await; } -/// Two enabled proxies at startup — both complete their handshake and both +/// Two enabled proxies at startup - both complete their handshake and both /// appear as connected in the DB. Verifies that the manager spawns independent /// handler tasks and that they do not interfere with each other. #[sqlx::test] @@ -57,7 +57,7 @@ async fn test_two_proxies_connect_independently(_: PgPoolOptions, options: PgCon context.start().await; - // Both handshakes must complete — order is not guaranteed. + // Both handshakes must complete - order is not guaranteed. complete_manager_proxy_handshake(&mut mock_a).await; complete_manager_proxy_handshake(&mut mock_b).await; @@ -105,7 +105,7 @@ async fn test_start_connection_adds_proxy_at_runtime(_: PgPoolOptions, options: let mut mock_b = MockProxyHarness::start().await; context.register_proxy_mock(&proxy_b, &mock_b); - // Third proxy: disabled — manager must not start it. + // Third proxy: disabled - manager must not start it. let proxy_c = create_proxy_with_enabled(&context.pool, false).await; let mut mock_c = MockProxyHarness::start().await; context.register_proxy_mock(&proxy_c, &mock_c); @@ -167,7 +167,7 @@ async fn test_start_connection_adds_proxy_at_runtime(_: PgPoolOptions, options: } /// One proxy's stream closes and reconnects to a replacement mock server at the -/// same socket path. The other proxy must remain connected throughout — the +/// same socket path. The other proxy must remain connected throughout - the /// reconnect must be fully isolated to the affected handler task. #[sqlx::test] async fn test_one_proxy_reconnects_while_other_stays_connected( @@ -177,7 +177,7 @@ async fn test_one_proxy_reconnects_while_other_stays_connected( let mut context = ManagerTestContext::new(options).await; context.set_retry_delay(FAST_RETRY_DELAY); - // Proxy A: reconnects — use a fixed socket path so we can start a replacement. + // Proxy A: reconnects - use a fixed socket path so we can start a replacement. let proxy_a = create_proxy(&context.pool).await; let socket_a = mock_proxy_socket_path(); context.register_proxy_socket_path( @@ -185,7 +185,7 @@ async fn test_one_proxy_reconnects_while_other_stays_connected( socket_a.clone(), ); - // Proxy B: stable — standard mock. + // Proxy B: stable - standard mock. let proxy_b = create_proxy(&context.pool).await; let mut mock_b = MockProxyHarness::start().await; context.register_proxy_mock(&proxy_b, &mock_b); @@ -195,7 +195,7 @@ async fn test_one_proxy_reconnects_while_other_stays_connected( .wait_for_handler_spawn_attempt_count(proxy_a.id, 1) .await; - // First mock for proxy A — will be closed to trigger a reconnect. + // First mock for proxy A - will be closed to trigger a reconnect. let mut mock_a1 = MockProxyHarness::start_at(socket_a.clone()).await; mock_a1.wait_for_connection_count(1).await; complete_manager_proxy_handshake(&mut mock_a1).await; @@ -207,7 +207,7 @@ async fn test_one_proxy_reconnects_while_other_stays_connected( let initial_spawn_count_a = context.handler_spawn_attempt_count(proxy_a.id); - // Close proxy A's stream — triggers internal retry loop in handler A. + // Close proxy A's stream - triggers internal retry loop in handler A. mock_a1.close_stream(); wait_for_proxy_connection_state(&context.pool, proxy_a.id, false).await; @@ -217,7 +217,7 @@ async fn test_one_proxy_reconnects_while_other_stays_connected( complete_manager_proxy_handshake(&mut mock_a2).await; wait_for_proxy_connection_state(&context.pool, proxy_a.id, true).await; - // Handler A reused its existing task — no new supervisor spawned. + // Handler A reused its existing task - no new supervisor spawned. assert_eq!( context.handler_spawn_attempt_count(proxy_a.id), initial_spawn_count_a, @@ -296,7 +296,7 @@ async fn test_shutdown_control_message_disconnects_without_purge( complete_manager_proxy_handshake(&mut mock_proxy).await; wait_for_proxy_connection_state(&context.pool, proxy.id, true).await; - // Send ShutdownConnection — purge() RPC must NOT be called. + // Send ShutdownConnection - purge() RPC must NOT be called. context .proxy_control_tx .send(ProxyControlMessage::ShutdownConnection(proxy.id)) @@ -338,7 +338,7 @@ async fn test_purge_control_message_calls_purge_rpc(_: PgPoolOptions, options: P wait_for_proxy_connection_state(&context.pool, proxy_a.id, true).await; wait_for_proxy_connection_state(&context.pool, proxy_b.id, true).await; - // Send Purge targeting proxy A only — purge() RPC MUST be called on A. + // Send Purge targeting proxy A only - purge() RPC MUST be called on A. context .proxy_control_tx .send(ProxyControlMessage::Purge(proxy_a.id)) @@ -353,7 +353,7 @@ async fn test_purge_control_message_calls_purge_rpc(_: PgPoolOptions, options: P "proxy A should be disconnected after Purge" ); - // Proxy B must be completely unaffected — not purged, still connected. + // Proxy B must be completely unaffected - not purged, still connected. assert_eq!( mock_b.purge_count(), 0, @@ -388,7 +388,7 @@ async fn test_manager_retries_after_stream_close_single_supervisor( .wait_for_handler_spawn_attempt_count(proxy.id, 1) .await; - // First mock server — accept one connection, then close the stream. + // First mock server - accept one connection, then close the stream. let mut mock_proxy = MockProxyHarness::start_at(socket_path.clone()).await; mock_proxy.wait_for_connection_count(1).await; complete_manager_proxy_handshake(&mut mock_proxy).await; @@ -421,7 +421,7 @@ async fn test_manager_retries_after_stream_close_single_supervisor( /// /// 1. Start the manager with two enabled proxies (both mocked and connected). /// 2. Verify both report as connected in the DB. -/// 3. Send `ShutdownConnection` for the second proxy — exactly what +/// 3. Send `ShutdownConnection` for the second proxy - exactly what /// `trim_gateways_and_edges` would send after calling /// `Proxy::leave_one_enabled`. /// 4. Assert the second proxy is now disconnected in the DB. @@ -475,7 +475,7 @@ async fn test_license_expiry_shuts_down_excess_proxy_only( "ShutdownConnection must not trigger a purge RPC on the excess proxy" ); - // The retained proxy must still be connected — license expiry must not + // The retained proxy must still be connected - license expiry must not // affect proxies that are allowed to remain. let after_keep = wait_for_proxy_connection_state(&context.pool, proxy_keep.id, true).await; assert!( diff --git a/crates/defguard_setup/src/auto_adoption.rs b/crates/defguard_setup/src/auto_adoption.rs index 805c0a0cdf..eea96fbb0d 100644 --- a/crates/defguard_setup/src/auto_adoption.rs +++ b/crates/defguard_setup/src/auto_adoption.rs @@ -5,7 +5,9 @@ use std::{ }; use anyhow::Context; -use defguard_certs::{CertificateAuthority, CertificateInfo, Csr, PemLabel, der_to_pem}; +use defguard_certs::{ + CertificateAuthority, CertificateInfo, CoreClientCert, Csr, PemLabel, der_to_pem, +}; use defguard_common::{ VERSION, auth::claims::{Claims, ClaimsType}, @@ -26,7 +28,7 @@ use defguard_core::{ version::{MIN_GATEWAY_VERSION, MIN_PROXY_VERSION}, }; use defguard_proto::{ - common::{CertificateInfo as ProtoCertificateInfo, DerPayload as ProtoDerPayload}, + common::{CertBundle, CertificateInfo as ProtoCertificateInfo}, gateway::gateway_setup_client::GatewaySetupClient, proxy::proxy_setup_client::ProxySetupClient, }; @@ -34,7 +36,7 @@ use defguard_version::{Version, client::ClientVersionInterceptor}; use ipnetwork::IpNetwork; use reqwest::Url; use sqlx::PgPool; -use tokio::sync::mpsc::UnboundedReceiver; +use tokio::{sync::mpsc::UnboundedReceiver, time::timeout}; use tonic::{ Request, Status, service::Interceptor, @@ -188,7 +190,7 @@ fn merge_failure_logs( message: impl Into, log_buffer: &SetupLogBuffer, log_rx: &mut UnboundedReceiver, -) -> (bool, Vec, Option) { +) -> (bool, Vec, Option) { let msg = message.into(); error!("{msg}"); let mut logs = collect_core_logs(log_buffer); @@ -200,11 +202,21 @@ fn logs_to_persist(success: bool, logs: Vec) -> Vec { if success { Vec::new() } else { logs } } +/// Carries the result of a successful component adoption attempt. +/// +/// Bundles the parsed certificate metadata with the Core gRPC client +/// certificate materials so that both can be persisted in the same DB +/// transaction without re-issuing the client cert. +struct ComponentAdoptionResult { + cert_info: CertificateInfo, + core_client: CoreClientCert, +} + async fn run_edge_adoption_attempt( pool: &PgPool, host: &str, port: u16, -) -> (bool, Vec, Option) { +) -> (bool, Vec, Option) { let log_buffer = Arc::new(Mutex::new(VecDeque::new())); let certs = match Certificates::get_or_default(pool).await { Ok(c) => c, @@ -236,7 +248,7 @@ async fn run_edge_adoption_attempt_scoped( log_buffer: SetupLogBuffer, ca_cert_der: Vec, ca_key_der: Vec, -) -> (bool, Vec, Option) { +) -> (bool, Vec, Option) { debug!("Starting edge adoption attempt host={host} port={port}"); let (log_tx, mut log_rx) = tokio::sync::mpsc::unbounded_channel::(); let endpoint_str = format!("http://{host}:{port}"); @@ -333,27 +345,26 @@ async fn run_edge_adoption_attempt_scoped( auth_interceptor.clone().call(req) }); - let response_with_metadata = - match tokio::time::timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { - Ok(Ok(response)) => response, - Ok(Err(err)) => { - return merge_failure_logs( - format!("Failed to start edge setup stream: {err}"), - &log_buffer, - &mut log_rx, - ); - } - Err(_) => { - return merge_failure_logs( - format!( - "Timed out connecting to edge setup endpoint after {} seconds", - STARTUP_ADOPTION_TIMEOUT.as_secs() - ), - &log_buffer, - &mut log_rx, - ); - } - }; + let response_with_metadata = match timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { + Ok(Ok(response)) => response, + Ok(Err(err)) => { + return merge_failure_logs( + format!("Failed to start edge setup stream: {err}"), + &log_buffer, + &mut log_rx, + ); + } + Err(_) => { + return merge_failure_logs( + format!( + "Timed out connecting to edge setup endpoint after {} seconds", + STARTUP_ADOPTION_TIMEOUT.as_secs() + ), + &log_buffer, + &mut log_rx, + ); + } + }; debug!("Successfully connected to Edge setup stream"); let edge_version = response_with_metadata @@ -457,7 +468,7 @@ async fn run_edge_adoption_attempt_scoped( } }; - let cert = match ca.sign_csr(&csr) { + let cert = match ca.sign_server_cert(&csr) { Ok(cert) => cert, Err(err) => { return merge_failure_logs( @@ -469,12 +480,23 @@ async fn run_edge_adoption_attempt_scoped( }; debug!("CSR signed for proxy hostname={hostname}; sending certificate"); - if let Err(err) = client - .send_cert(ProtoDerPayload { - der_data: cert.der().to_vec(), - }) - .await - { + let core_client = match ca.issue_core_client_cert(hostname) { + Ok(c) => c, + Err(err) => { + return merge_failure_logs( + format!("Failed to issue Core client certificate for proxy: {err}"), + &log_buffer, + &mut log_rx, + ); + } + }; + + let bundle = CertBundle { + component_cert_der: cert.der().to_vec(), + ca_cert_der: ca_cert_der.clone(), + core_client_cert_der: core_client.cert_der.clone(), + }; + if let Err(err) = client.send_cert(bundle).await { return merge_failure_logs( format!("Failed to send certificate to proxy: {err}"), &log_buffer, @@ -503,14 +525,21 @@ async fn run_edge_adoption_attempt_scoped( logs = vec!["No runtime logs received from edge component".to_string()]; } - (true, logs, Some(cert_info)) + ( + true, + logs, + Some(ComponentAdoptionResult { + cert_info, + core_client, + }), + ) } async fn run_gateway_adoption_attempt( pool: &PgPool, host: &str, port: u16, -) -> (bool, Vec, Option) { +) -> (bool, Vec, Option) { let log_buffer = Arc::new(Mutex::new(VecDeque::new())); let certs = match Certificates::get_or_default(pool).await { Ok(c) => c, @@ -542,7 +571,7 @@ async fn run_gateway_adoption_attempt_scoped( log_buffer: SetupLogBuffer, ca_cert_der: Vec, ca_key_der: Vec, -) -> (bool, Vec, Option) { +) -> (bool, Vec, Option) { debug!("Starting gateway adoption attempt host={host} port={port}"); let (log_tx, mut log_rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -642,27 +671,26 @@ async fn run_gateway_adoption_attempt_scoped( }, ); - let response_with_metadata = - match tokio::time::timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { - Ok(Ok(response)) => response, - Ok(Err(err)) => { - return merge_failure_logs( - format!("Failed to start gateway setup stream: {err}"), - &log_buffer, - &mut log_rx, - ); - } - Err(_) => { - return merge_failure_logs( - format!( - "Timed out connecting to gateway setup endpoint after {} seconds", - STARTUP_ADOPTION_TIMEOUT.as_secs() - ), - &log_buffer, - &mut log_rx, - ); - } - }; + let response_with_metadata = match timeout(STARTUP_ADOPTION_TIMEOUT, client.start(())).await { + Ok(Ok(response)) => response, + Ok(Err(err)) => { + return merge_failure_logs( + format!("Failed to start gateway setup stream: {err}"), + &log_buffer, + &mut log_rx, + ); + } + Err(_) => { + return merge_failure_logs( + format!( + "Timed out connecting to gateway setup endpoint after {} seconds", + STARTUP_ADOPTION_TIMEOUT.as_secs() + ), + &log_buffer, + &mut log_rx, + ); + } + }; debug!("Successfully connected to Gateway setup stream"); let gateway_version = response_with_metadata @@ -766,7 +794,7 @@ async fn run_gateway_adoption_attempt_scoped( } }; - let cert = match ca.sign_csr(&csr) { + let cert = match ca.sign_server_cert(&csr) { Ok(cert) => cert, Err(err) => { return merge_failure_logs( @@ -778,12 +806,23 @@ async fn run_gateway_adoption_attempt_scoped( }; debug!("CSR signed for gateway hostname={hostname}; sending certificate"); - if let Err(err) = client - .send_cert(ProtoDerPayload { - der_data: cert.der().to_vec(), - }) - .await - { + let core_client = match ca.issue_core_client_cert(hostname) { + Ok(c) => c, + Err(err) => { + return merge_failure_logs( + format!("Failed to issue Core client certificate for gateway: {err}"), + &log_buffer, + &mut log_rx, + ); + } + }; + + let bundle = CertBundle { + component_cert_der: cert.der().to_vec(), + ca_cert_der: ca_cert_der.clone(), + core_client_cert_der: core_client.cert_der.clone(), + }; + if let Err(err) = client.send_cert(bundle).await { return merge_failure_logs( format!("Failed to send certificate to gateway: {err}"), &log_buffer, @@ -812,7 +851,14 @@ async fn run_gateway_adoption_attempt_scoped( logs = vec!["No runtime logs received from gateway component".to_string()]; } - (true, logs, Some(cert_info)) + ( + true, + logs, + Some(ComponentAdoptionResult { + cert_info, + core_client, + }), + ) } // Default WireGuard network address and port used when auto-adopting a gateway without an @@ -839,9 +885,16 @@ async fn process_startup_auto_adoption( if status { match component { SetupAutoAdoptionComponent::Gateway => { - if let Some(cert_info) = cert_info { - if let Err(err) = - create_network_and_gateway(pool, &host, port, GATEWAY_NAME, cert_info).await + if let Some(result) = cert_info { + if let Err(err) = create_network_and_gateway( + pool, + &host, + port, + GATEWAY_NAME, + result.cert_info, + result.core_client, + ) + .await { warn!( "Gateway adoption TLS handshake succeeded but failed to persist \ @@ -851,8 +904,17 @@ async fn process_startup_auto_adoption( } } SetupAutoAdoptionComponent::Edge => { - if let Some(cert_info) = cert_info { - if let Err(err) = create_proxy(pool, &host, port, PROXY_NAME, cert_info).await { + if let Some(result) = cert_info { + if let Err(err) = create_proxy( + pool, + &host, + port, + PROXY_NAME, + result.cert_info, + result.core_client, + ) + .await + { warn!( "Edge adoption TLS handshake succeeded but failed to persist \ proxy record: {err}" @@ -889,6 +951,7 @@ async fn create_network_and_gateway( grpc_port: u16, common_name: &str, cert_info: CertificateInfo, + core_client: CoreClientCert, ) -> Result<(), anyhow::Error> { // Re-use or create the network location. let network = if let Some(existing) = WireguardNetwork::find_by_name(pool, common_name) @@ -967,8 +1030,11 @@ id={} for new gateway", i32::from(grpc_port), "Automatic setup", ); - gateway.certificate = Some(cert_info.serial); + gateway.certificate_serial = Some(cert_info.serial); gateway.certificate_expiry = Some(cert_info.not_after); + gateway.core_client_cert_der = Some(core_client.cert_der); + gateway.core_client_cert_key_der = Some(core_client.key_der); + gateway.core_client_cert_expiry = Some(core_client.expiry); gateway .save(pool) @@ -991,6 +1057,7 @@ async fn create_proxy( port: u16, common_name: &str, cert_info: CertificateInfo, + core_client: CoreClientCert, ) -> Result<(), anyhow::Error> { if let Some(existing) = Proxy::find_by_address_port(pool, host, i32::from(port)) .await @@ -1005,8 +1072,11 @@ async fn create_proxy( } let mut proxy = Proxy::new(common_name, host, i32::from(port), "Automatic setup"); - proxy.certificate = Some(cert_info.serial); + proxy.certificate_serial = Some(cert_info.serial); proxy.certificate_expiry = Some(cert_info.not_after); + proxy.core_client_cert_der = Some(core_client.cert_der); + proxy.core_client_cert_key_der = Some(core_client.key_der); + proxy.core_client_cert_expiry = Some(core_client.expiry); proxy .save(pool) diff --git a/crates/defguard_setup/tests/auto_adoption_wizard.rs b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs similarity index 95% rename from crates/defguard_setup/tests/auto_adoption_wizard.rs rename to crates/defguard_setup/tests/integration/auto_adoption_wizard.rs index f5cc34c914..deaec3553c 100644 --- a/crates/defguard_setup/tests/auto_adoption_wizard.rs +++ b/crates/defguard_setup/tests/integration/auto_adoption_wizard.rs @@ -7,7 +7,9 @@ use defguard_common::{ models::{ Settings, WireguardNetwork, settings::initialize_current_settings, - setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, + setup_auto_adoption::{ + AutoAdoptionWizardState, AutoAdoptionWizardStep, SetupAutoAdoptionComponent, + }, wireguard::{LocationMfaMode, ServiceLocationMode}, wizard::{ActiveWizard, Wizard}, }, @@ -23,12 +25,12 @@ use reqwest::{ }; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use tokio::time::timeout; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; -mod common; -use common::make_setup_test_client; +use crate::common::SESSION_COOKIE_NAME; -const SESSION_COOKIE_NAME: &str = "defguard_session"; +use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; fn init_tracing_once() { static ONCE: Once = Once::new(); @@ -212,8 +214,7 @@ async fn test_auto_adoption_full_flow(_: PgPoolOptions, options: PgConnectOption assert!(wizard.completed); assert_eq!(wizard.active_wizard, ActiveWizard::None); - let shutdown_signal = - tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + let shutdown_signal = timeout(SHUTDOWN_TIMEOUT, shutdown_rx).await; assert!( matches!(shutdown_signal, Ok(Ok(()))), "Setup server should have sent shutdown signal after finish" @@ -377,7 +378,7 @@ async fn test_auto_adoption_vpn_settings_missing_network( .expect("Failed to create admin"); assert_eq!(resp.status(), StatusCode::CREATED); - // Set URL settings (requires auth — cookie jar carries session) + // Set URL settings (requires auth - cookie jar carries session) let resp = client .post("/api/v1/initial_setup/auto_wizard/internal_url_settings") .json(&json!({ @@ -436,7 +437,7 @@ async fn test_attempt_auto_adoption_requires_both_flags( _: PgPoolOptions, options: PgConnectOptions, ) { - let pool = defguard_common::db::setup_pool(options).await; + let pool = setup_pool(options).await; initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); @@ -473,7 +474,7 @@ async fn test_attempt_auto_adoption_persists_actionable_edge_failure_logs( ) { init_tracing_once(); - let pool = defguard_common::db::setup_pool(options).await; + let pool = setup_pool(options).await; initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); @@ -496,7 +497,7 @@ async fn test_attempt_auto_adoption_persists_actionable_edge_failure_logs( let edge_result = state .adoption_result - .get(&defguard_common::db::models::setup_auto_adoption::SetupAutoAdoptionComponent::Edge) + .get(&SetupAutoAdoptionComponent::Edge) .expect("Expected edge adoption result"); assert!(!edge_result.success, "Edge auto-adoption should fail"); @@ -530,7 +531,7 @@ async fn test_attempt_auto_adoption_persists_actionable_gateway_failure_logs( ) { init_tracing_once(); - let pool = defguard_common::db::setup_pool(options).await; + let pool = setup_pool(options).await; initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); @@ -553,7 +554,7 @@ async fn test_attempt_auto_adoption_persists_actionable_gateway_failure_logs( let gateway_result = state .adoption_result - .get(&defguard_common::db::models::setup_auto_adoption::SetupAutoAdoptionComponent::Gateway) + .get(&SetupAutoAdoptionComponent::Gateway) .expect("Expected gateway adoption result"); assert!(!gateway_result.success, "Gateway auto-adoption should fail"); diff --git a/crates/defguard_setup/tests/auto_wizard_url_settings.rs b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs similarity index 98% rename from crates/defguard_setup/tests/auto_wizard_url_settings.rs rename to crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs index f5cb72be56..289558ded8 100644 --- a/crates/defguard_setup/tests/auto_wizard_url_settings.rs +++ b/crates/defguard_setup/tests/integration/auto_wizard_url_settings.rs @@ -23,14 +23,14 @@ use reqwest::{ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -mod common; -use common::make_setup_test_client; +use crate::common::{SESSION_COOKIE_NAME, TestClient}; -const SESSION_COOKIE_NAME: &str = "defguard_session"; +use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; +use tokio::{sync::oneshot, time::timeout}; async fn bootstrap_wizard_to_url_settings( pool: &sqlx::PgPool, -) -> (common::TestClient, tokio::sync::oneshot::Receiver<()>) { +) -> (TestClient, oneshot::Receiver<()>) { Wizard::init(pool, true, &DefGuardConfig::new_test_config()) .await .expect("Failed to init wizard"); @@ -58,7 +58,7 @@ fn generate_test_cert_pem(common_name: &str) -> (String, String) { let san = vec![common_name.to_string()]; let dn = vec![(DnType::CommonName, common_name)]; let csr = Csr::new(&key_pair, &san, dn).unwrap(); - let cert = ca.sign_csr(&csr).unwrap(); + let cert = ca.sign_server_cert(&csr).unwrap(); let cert_pem = der_to_pem(cert.der(), PemLabel::Certificate).unwrap(); let key_pem = der_to_pem(key_pair.serialize_der().as_slice(), PemLabel::PrivateKey).unwrap(); (cert_pem, key_pem) @@ -541,6 +541,6 @@ async fn test_auto_adoption_full_flow_new_url_steps(_: PgPoolOptions, options: P assert!(wizard.completed); assert_eq!(wizard.active_wizard, ActiveWizard::None); - let shutdown = tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + let shutdown = timeout(SHUTDOWN_TIMEOUT, shutdown_rx).await; assert!(matches!(shutdown, Ok(Ok(())))); } diff --git a/crates/defguard_setup/tests/common/mod.rs b/crates/defguard_setup/tests/integration/common.rs similarity index 95% rename from crates/defguard_setup/tests/common/mod.rs rename to crates/defguard_setup/tests/integration/common.rs index e656507720..684028bd76 100644 --- a/crates/defguard_setup/tests/common/mod.rs +++ b/crates/defguard_setup/tests/integration/common.rs @@ -1,6 +1,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::Arc, + time::Duration, }; use axum::serve; @@ -26,7 +27,9 @@ use semver::Version; use sqlx::PgPool; use tokio::{net::TcpListener, sync::oneshot, task::JoinHandle}; -#[allow(dead_code)] +pub const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(1); +pub const SESSION_COOKIE_NAME: &str = "defguard_session"; + pub const TEST_SECRET_KEY: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; @@ -84,7 +87,6 @@ impl TestClient { } } -#[allow(dead_code)] pub async fn make_setup_test_client(pool: PgPool) -> (TestClient, oneshot::Receiver<()>) { let (setup_shutdown_tx, setup_shutdown_rx) = oneshot::channel::<()>(); let app = build_setup_webapp( @@ -99,7 +101,6 @@ pub async fn make_setup_test_client(pool: PgPool) -> (TestClient, oneshot::Recei (TestClient::new(app, listener), setup_shutdown_rx) } -#[allow(dead_code)] pub async fn make_migration_test_client( pool: PgPool, ) -> ( @@ -114,7 +115,7 @@ pub async fn make_migration_test_client( setup_shutdown_tx, ); // We must keep `webapp` alive to prevent its event receiver channels from - // being dropped — if they are dropped the `emit_event` call in the auth + // being dropped - if they are dropped the `emit_event` call in the auth // handler will fail with "channel closed". let router = webapp.router.clone(); let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0); @@ -127,7 +128,6 @@ pub async fn make_migration_test_client( /// Initialise settings with a known secret key so `build_migration_webapp` can /// call `secret_key_required()` without panicking. Also initialises SERVER_CONFIG /// so the auth handler can call `server_config()`. -#[allow(dead_code)] pub async fn init_settings_with_secret_key(pool: &PgPool) { initialize_current_settings(pool) .await @@ -146,7 +146,6 @@ pub async fn init_settings_with_secret_key(pool: &PgPool) { /// Creates an admin group + admin user and returns the user. /// `User::is_admin()` checks group membership, not a column flag. -#[allow(dead_code)] pub async fn seed_admin_user(pool: &PgPool, username: &str, password: &str) -> User { let mut admin_group = Group::new("admins"); admin_group.is_admin = true; diff --git a/crates/defguard_setup/tests/initial_setup.rs b/crates/defguard_setup/tests/integration/initial_setup.rs similarity index 97% rename from crates/defguard_setup/tests/initial_setup.rs rename to crates/defguard_setup/tests/integration/initial_setup.rs index 0b2eef1185..08816b2b3f 100644 --- a/crates/defguard_setup/tests/initial_setup.rs +++ b/crates/defguard_setup/tests/integration/initial_setup.rs @@ -31,12 +31,12 @@ use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use tokio::{ net::TcpListener, sync::{Notify, oneshot}, + time::timeout, }; -mod common; -use common::make_setup_test_client; +use crate::common::SESSION_COOKIE_NAME; -const SESSION_COOKIE_NAME: &str = "defguard_session"; +use super::common::{SHUTDOWN_TIMEOUT, make_setup_test_client}; async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { let step = InitialSetupState::get(pool) @@ -468,8 +468,7 @@ async fn test_finish_setup(_: PgPoolOptions, options: PgConnectOptions) { assert_setup_step(&pool, InitialSetupStep::Finished).await; - let shutdown_signal = - tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + let shutdown_signal = timeout(SHUTDOWN_TIMEOUT, shutdown_rx).await; assert!(matches!(shutdown_signal, Ok(Ok(())))); } @@ -628,13 +627,9 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .expect("Session not created"); assert_eq!(session.user_id, admin_user.id); - let shutdown_signal = tokio::time::timeout( - std::time::Duration::from_secs(1), - shutdown_notify.notified(), - ) - .await; + let shutdown_signal = timeout(SHUTDOWN_TIMEOUT, shutdown_notify.notified()).await; assert!(shutdown_signal.is_ok()); - let server_result = tokio::time::timeout(std::time::Duration::from_secs(1), server_task).await; + let server_result = timeout(SHUTDOWN_TIMEOUT, server_task).await; assert!(matches!(server_result, Ok(Ok(())))); } diff --git a/crates/defguard_setup/tests/integration/main.rs b/crates/defguard_setup/tests/integration/main.rs new file mode 100644 index 0000000000..29d12a976e --- /dev/null +++ b/crates/defguard_setup/tests/integration/main.rs @@ -0,0 +1,8 @@ +mod auto_adoption_wizard; +mod auto_wizard_url_settings; +mod common; +mod initial_setup; +mod migration_wizard; +mod session_info; +mod wizard_init; +mod wizard_state; diff --git a/crates/defguard_setup/tests/migration_wizard.rs b/crates/defguard_setup/tests/integration/migration_wizard.rs similarity index 97% rename from crates/defguard_setup/tests/migration_wizard.rs rename to crates/defguard_setup/tests/integration/migration_wizard.rs index 53a8ddfa26..d7f9d72c4d 100644 --- a/crates/defguard_setup/tests/migration_wizard.rs +++ b/crates/defguard_setup/tests/integration/migration_wizard.rs @@ -16,8 +16,10 @@ use reqwest::{ use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -mod common; -use common::{init_settings_with_secret_key, make_migration_test_client, seed_admin_user}; +use super::common::{ + SHUTDOWN_TIMEOUT, init_settings_with_secret_key, make_migration_test_client, seed_admin_user, +}; +use tokio::time::timeout; async fn assert_migration_step(pool: &sqlx::PgPool, expected_variant: &str) { let state = MigrationWizardState::get(pool) @@ -170,8 +172,7 @@ async fn test_migration_full_flow(_: PgPoolOptions, options: PgConnectOptions) { "Migration wizard state should be cleared after finish" ); - let shutdown_signal = - tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; + let shutdown_signal = timeout(SHUTDOWN_TIMEOUT, shutdown_rx).await; assert!( matches!(shutdown_signal, Ok(Ok(()))), "Migration server should have sent shutdown signal after finish" diff --git a/crates/defguard_setup/tests/session_info.rs b/crates/defguard_setup/tests/integration/session_info.rs similarity index 98% rename from crates/defguard_setup/tests/session_info.rs rename to crates/defguard_setup/tests/integration/session_info.rs index b9a7308fb2..ce18355595 100644 --- a/crates/defguard_setup/tests/session_info.rs +++ b/crates/defguard_setup/tests/integration/session_info.rs @@ -14,8 +14,9 @@ use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -mod common; -use common::{init_settings_with_secret_key, make_migration_test_client, make_setup_test_client}; +use super::common::{ + init_settings_with_secret_key, make_migration_test_client, make_setup_test_client, +}; #[sqlx::test] async fn test_session_info_setup_server(_: PgPoolOptions, options: PgConnectOptions) { diff --git a/crates/defguard_setup/tests/wizard_init.rs b/crates/defguard_setup/tests/integration/wizard_init.rs similarity index 100% rename from crates/defguard_setup/tests/wizard_init.rs rename to crates/defguard_setup/tests/integration/wizard_init.rs diff --git a/crates/defguard_setup/tests/wizard_state.rs b/crates/defguard_setup/tests/integration/wizard_state.rs similarity index 86% rename from crates/defguard_setup/tests/wizard_state.rs rename to crates/defguard_setup/tests/integration/wizard_state.rs index 30a806ad2c..492675b0b7 100644 --- a/crates/defguard_setup/tests/wizard_state.rs +++ b/crates/defguard_setup/tests/integration/wizard_state.rs @@ -3,7 +3,7 @@ use defguard_common::{ db::{ models::{ settings::initialize_current_settings, - setup_auto_adoption::AutoAdoptionWizardStep, + setup_auto_adoption::{AutoAdoptionWizardState, AutoAdoptionWizardStep}, wireguard::{LocationMfaMode, ServiceLocationMode, WireguardNetwork}, wizard::{ActiveWizard, Wizard}, }, @@ -14,8 +14,7 @@ use reqwest::StatusCode; use serde_json::json; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; -mod common; -use common::make_setup_test_client; +use super::common::make_setup_test_client; #[sqlx::test] async fn test_wizard_state_initial(_: PgPoolOptions, options: PgConnectOptions) { @@ -209,11 +208,10 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt .expect("Failed to parse wizard state"); assert_eq!(state["active_wizard"], "auto_adoption"); - let auto_state = - defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) - .await - .expect("Failed to get auto adoption state") - .unwrap_or_default(); + let auto_state = AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .unwrap_or_default(); assert_eq!(auto_state.step, AutoAdoptionWizardStep::UrlSettings); let resp = client @@ -227,11 +225,10 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt .expect("Failed to set internal URL settings"); assert_eq!(resp.status(), StatusCode::CREATED); - let auto_state = - defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) - .await - .expect("Failed to get auto adoption state") - .expect("Auto adoption state should be set"); + let auto_state = AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); assert_eq!(auto_state.step, AutoAdoptionWizardStep::ExternalUrlSettings); let resp = client @@ -245,11 +242,10 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt .expect("Failed to set external URL settings"); assert_eq!(resp.status(), StatusCode::CREATED); - let auto_state = - defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) - .await - .expect("Failed to get auto adoption state") - .expect("Auto adoption state should be set"); + let auto_state = AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); assert_eq!(auto_state.step, AutoAdoptionWizardStep::VpnSettings); let resp = client @@ -266,11 +262,10 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt .expect("Failed to set VPN settings"); assert_eq!(resp.status(), StatusCode::CREATED); - let auto_state = - defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) - .await - .expect("Failed to get auto adoption state") - .expect("Auto adoption state should be set"); + let auto_state = AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); assert_eq!(auto_state.step, AutoAdoptionWizardStep::MfaSettings); let resp = client @@ -281,11 +276,10 @@ async fn test_wizard_state_auto_adoption(_: PgPoolOptions, options: PgConnectOpt .expect("Failed to set MFA settings"); assert_eq!(resp.status(), StatusCode::CREATED); - let auto_state = - defguard_common::db::models::setup_auto_adoption::AutoAdoptionWizardState::get(&pool) - .await - .expect("Failed to get auto adoption state") - .expect("Auto adoption state should be set"); + let auto_state = AutoAdoptionWizardState::get(&pool) + .await + .expect("Failed to get auto adoption state") + .expect("Auto adoption state should be set"); assert_eq!(auto_state.step, AutoAdoptionWizardStep::Summary); let resp = client diff --git a/flake.lock b/flake.lock index 4a5a6b9510..924c8016e6 100644 --- a/flake.lock +++ b/flake.lock @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1776395632, - "narHash": "sha256-Mi1uF5f2FsdBIvy+v7MtsqxD3Xjhd0ARJdwoqqqPtJo=", + "lastModified": 1776654897, + "narHash": "sha256-Vqi4AiJVCcBGn/RmBtRCgyH5rCxqm/w0xV9diJWF1Ic=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "8087ff1f47fff983a1fba70fa88b759f2fd8ae97", + "rev": "25d75be8139815a53560745fa060909777495105", "type": "github" }, "original": { diff --git a/migrations/20260414120000_[2.0.0]_core_grpc_cert.down.sql b/migrations/20260414120000_[2.0.0]_core_grpc_cert.down.sql new file mode 100644 index 0000000000..03174799bb --- /dev/null +++ b/migrations/20260414120000_[2.0.0]_core_grpc_cert.down.sql @@ -0,0 +1,22 @@ +ALTER TABLE gateway RENAME COLUMN certificate_serial TO certificate; +ALTER TABLE proxy RENAME COLUMN certificate_serial TO certificate; + +ALTER TABLE gateway + DROP COLUMN core_client_cert_der, + DROP COLUMN core_client_cert_key_der, + DROP COLUMN core_client_cert_expiry; + +ALTER TABLE proxy + DROP COLUMN core_client_cert_der, + DROP COLUMN core_client_cert_key_der, + DROP COLUMN core_client_cert_expiry; + +-- Restore the full row_change() function. +CREATE OR REPLACE FUNCTION row_change() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify(TG_TABLE_NAME || '_change', + json_build_object('operation', TG_OP, 'old', row_to_json(OLD), 'new', row_to_json(NEW))::text + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; diff --git a/migrations/20260414120000_[2.0.0]_core_grpc_cert.up.sql b/migrations/20260414120000_[2.0.0]_core_grpc_cert.up.sql new file mode 100644 index 0000000000..32968154f3 --- /dev/null +++ b/migrations/20260414120000_[2.0.0]_core_grpc_cert.up.sql @@ -0,0 +1,27 @@ +ALTER TABLE gateway RENAME COLUMN certificate TO certificate_serial; +ALTER TABLE proxy RENAME COLUMN certificate TO certificate_serial; + +ALTER TABLE gateway + ADD COLUMN core_client_cert_der bytea DEFAULT NULL, + ADD COLUMN core_client_cert_key_der bytea DEFAULT NULL, + ADD COLUMN core_client_cert_expiry timestamp without time zone NULL; + +ALTER TABLE proxy + ADD COLUMN core_client_cert_der bytea DEFAULT NULL, + ADD COLUMN core_client_cert_key_der bytea DEFAULT NULL, + ADD COLUMN core_client_cert_expiry timestamp without time zone NULL; + +-- Switch to a lightweight notification payload (id + operation only) to avoid +-- exceeding PostgreSQL's 8000-byte pg_notify limit when bytea cert columns are populated. +CREATE OR REPLACE FUNCTION row_change() RETURNS trigger AS $$ +BEGIN + PERFORM pg_notify( + TG_TABLE_NAME || '_change', + json_build_object( + 'operation', TG_OP, + 'id', COALESCE(NEW.id, OLD.id) + )::text + ); + RETURN NULL; +END; +$$ LANGUAGE plpgsql; diff --git a/proto b/proto index 7adfe3bfd1..37bed3af78 160000 --- a/proto +++ b/proto @@ -1 +1 @@ -Subproject commit 7adfe3bfd1b7b701e58d25ddadd0c0c7a4a3e046 +Subproject commit 37bed3af781d157e7ff808686273f261ec546dac diff --git a/web/package.json b/web/package.json index 19b5cdfbc7..bb5f6e8d9f 100644 --- a/web/package.json +++ b/web/package.json @@ -24,12 +24,12 @@ "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@tanstack/react-form": "^1.29.0", - "@tanstack/react-query": "^5.99.0", - "@tanstack/react-router": "^1.168.22", + "@tanstack/react-query": "^5.99.2", + "@tanstack/react-router": "^1.168.23", "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.23", + "@tanstack/react-virtual": "^3.13.24", "@uidotdev/usehooks": "^2.4.1", - "axios": "^1.15.0", + "axios": "^1.15.1", "byte-size": "^9.0.1", "clsx": "^2.1.1", "dayjs": "^1.11.20", @@ -58,7 +58,7 @@ "@inlang/paraglide-js": "2.16.0", "@tanstack/devtools-vite": "^0.6.0", "@tanstack/react-devtools": "^0.10.2", - "@tanstack/react-query-devtools": "^5.99.0", + "@tanstack/react-query-devtools": "^5.99.2", "@tanstack/react-router-devtools": "^1.166.13", "@tanstack/router-plugin": "^1.167.22", "@types/byte-size": "^8.1.2", @@ -79,7 +79,7 @@ "stylelint-config-standard-scss": "^17.0.0", "stylelint-scss": "^7.0.0", "typescript": "~5.9.3", - "vite": "^8.0.8", + "vite": "^8.0.9", "vite-plugin-image-optimizer": "^2.0.3", "vitest": "^4.1.4" } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 1390ecfb31..a9af772222 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -33,23 +33,23 @@ importers: specifier: ^1.29.0 version: 1.29.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': - specifier: ^5.99.0 - version: 5.99.0(react@19.2.5) + specifier: ^5.99.2 + version: 5.99.2(react@19.2.5) '@tanstack/react-router': - specifier: ^1.168.22 - version: 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^1.168.23 + version: 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-virtual': - specifier: ^3.13.23 - version: 3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@uidotdev/usehooks': specifier: ^2.4.1 version: 2.4.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) axios: - specifier: ^1.15.0 - version: 1.15.0 + specifier: ^1.15.1 + version: 1.15.1 byte-size: specifier: ^9.0.1 version: 9.0.1 @@ -122,19 +122,19 @@ importers: version: 2.4.12 '@tanstack/devtools-vite': specifier: ^0.6.0 - version: 0.6.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + version: 0.6.0(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) '@tanstack/react-devtools': specifier: ^0.10.2 version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.9) '@tanstack/react-query-devtools': - specifier: ^5.99.0 - version: 5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5) + specifier: ^5.99.2 + version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5) '@tanstack/react-router-devtools': specifier: ^1.166.13 - version: 1.166.13(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/router-plugin': specifier: ^1.167.22 - version: 1.167.22(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + version: 1.167.22(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -158,7 +158,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^6.0.1 - version: 6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + version: 6.0.1(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) '@vitest/ui': specifier: ^4.1.4 version: 4.1.4(vitest@4.1.4) @@ -190,14 +190,14 @@ importers: specifier: ~5.9.3 version: 5.9.3 vite: - specifier: ^8.0.8 - version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + specifier: ^8.0.9 + version: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + version: 2.0.3(sharp@0.34.5)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) vitest: specifier: ^4.1.4 - version: 4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + version: 4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) packages: @@ -800,8 +800,8 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} - '@oxc-project/types@0.124.0': - resolution: {integrity: sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==} + '@oxc-project/types@0.126.0': + resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} '@parcel/watcher-android-arm64@2.5.6': resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} @@ -920,103 +920,103 @@ packages: react-redux: optional: true - '@rolldown/binding-android-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==} + '@rolldown/binding-android-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-rhY3k7Bsae9qQfOtph2Pm2jZEA+s8Gmjoz4hhmx70K9iMQ/ddeae+xhRQcM5IuVx5ry1+bGfkvMn7D6MJggVSA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==} + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-rNz0yK078yrNn3DrdgN+PKiMOW8HfQ92jQiXxwX8yW899ayV00MLVdaCNeVBhG/TbH3ouYVObo8/yrkiectkcQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@rolldown/binding-darwin-x64@1.0.0-rc.15': - resolution: {integrity: sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==} + '@rolldown/binding-darwin-x64@1.0.0-rc.16': + resolution: {integrity: sha512-r/OmdR00HmD4i79Z//xO06uEPOq5hRXdhw7nzkxQxwSavs3PSHa1ijntdpOiZ2mzOQ3fVVu8C1M19FoNM+dMUQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': - resolution: {integrity: sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==} + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': + resolution: {integrity: sha512-KcRE5w8h0OnjUatG8pldyD14/CQ5Phs1oxfR+3pKDjboHRo9+MkqQaiIZlZRpsxC15paeXme/I127tUa9TXJ6g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': - resolution: {integrity: sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==} + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': + resolution: {integrity: sha512-bT0guA1bpxEJ/ZhTRniQf7rNF8ybvXOuWbNIeLABaV5NGjx4EtOWBTSRGWFU9ZWVkPOZ+HNFP8RMcBokBiZ0Kg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==} + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-+tHktCHWV8BDQSjemUqm/Jl/TPk3QObCTIjmdDy/nlupcujZghmKK2962LYrqFpWu+ai01AN/REOH3NEpqvYQg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==} + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': + resolution: {integrity: sha512-3fPzdREH806oRLxpTWW1Gt4tQHs0TitZFOECB2xzCFLPKnSOy90gwA7P29cksYilFO6XVRY1kzga0cL2nRjKPg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==} + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-EKwI1tSrLs7YVw+JPJT/G2dJQ1jl9qlTTTEG0V2Ok/RdOenRfBw2PQdLPyjhIu58ocdBfP7vIRN/pvMsPxs/AQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==} + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-Uknladnb3Sxqu6SEcqBldQyJUpk8NleooZEc0MbRBJ4inEhRYWZX0NJu12vNf2mqAq7gsofAxHrGghiUYjhaLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': - resolution: {integrity: sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==} + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': + resolution: {integrity: sha512-FIb8+uG49sZBtLTn+zt1AJ20TqVcqWeSIyoVt0or7uAWesgKaHbiBh6OpA/k9v0LTt+PTrb1Lao133kP4uVxkg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': - resolution: {integrity: sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==} + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': + resolution: {integrity: sha512-RuERhF9/EgWxZEXYWCOaViUWHIboceK4/ivdtQ3R0T44NjLkIIlGIAVAuCddFxsZ7vnRHtNQUrt2vR2n2slB2w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': - resolution: {integrity: sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==} + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': + resolution: {integrity: sha512-mXcXnvd9GpazCxeUCCnZ2+YF7nut+ZOEbE4GtaiPtyY6AkhZWbK70y1KK3j+RDhjVq5+U8FySkKRb/+w0EeUwA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': - resolution: {integrity: sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==} - engines: {node: '>=14.0.0'} + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': + resolution: {integrity: sha512-3Q2KQxnC8IJOLqXmUMoYwyIPZU9hzRbnHaoV3Euz+VVnjZKcY8ktnNP8T9R4/GGQtb27C/UYKABxesKWb8lsvQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==} + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': + resolution: {integrity: sha512-tj7XRemQcOcFwv7qhpUxMTBbI5mWMlE4c1Omhg5+h8GuLXzyj8HviYgR+bB2DMDgRqUE+jiDleqSCRjx4aYk/Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': - resolution: {integrity: sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==} + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': + resolution: {integrity: sha512-PH5DRZT+F4f2PTXRXR8uJxnBq2po/xFtddyabTJVJs/ZYVHqXPEgNIr35IHTEa6bpa0Q8Awg+ymkTaGnKITw4g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + '@rolldown/pluginutils@1.0.0-rc.16': + resolution: {integrity: sha512-45+YtqxLYKDWQouLKCrpIZhke+nXxhsw+qAHVzHDVwttyBlHNBVs2K25rDXrZzhpTp9w1FlAlvweV1H++fdZoA==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -1139,11 +1139,11 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.99.0': - resolution: {integrity: sha512-3Jv3WQG0BCcH7G+7lf/bP8QyBfJOXeY+T08Rin3GZ1bshvwlbPt7NrDHMEzGdKIOmOzvIQmxjk28YEQX60k7pQ==} + '@tanstack/query-core@5.99.2': + resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} - '@tanstack/query-devtools@5.99.0': - resolution: {integrity: sha512-m4ufXaJ8FjWXw7xDtyzE/6fkZAyQFg9WrbMrUpt8ZecRJx58jiFOZ2lxZMphZdIpAnIeto/S8stbwLKLusyckQ==} + '@tanstack/query-devtools@5.99.2': + resolution: {integrity: sha512-TEF1d+RYO9l8oeCwgzmOHIgKwAzXQmw2s/ny2bW8qeg2OMkkLjALfVEivgCMR3OL/jVdMmeTPX56WrV+uvYJFg==} '@tanstack/react-devtools@0.10.2': resolution: {integrity: sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA==} @@ -1163,14 +1163,14 @@ packages: '@tanstack/react-start': optional: true - '@tanstack/react-query-devtools@5.99.0': - resolution: {integrity: sha512-CqqX7LCU9yOfCY/vBURSx2YSD83ryfX+QkfkaKionTfg1s2Hdm572Ro99gW3QPoJjzvsj1HM4pnN4nbDy3MXKA==} + '@tanstack/react-query-devtools@5.99.2': + resolution: {integrity: sha512-8txkK9A9XBNTB8RoxVgfp6W3qwBr25tNP10L4yu3KuyhAdEvccECfIRzesSwMVk/wpVVioAr+hbMtUkMMF+WVw==} peerDependencies: - '@tanstack/react-query': ^5.99.0 + '@tanstack/react-query': ^5.99.2 react: ^18 || ^19 - '@tanstack/react-query@5.99.0': - resolution: {integrity: sha512-OY2bCqPemT1LlqJ8Y2CUau4KELnIhhG9Ol3ZndPbdnB095pRbPo1cHuXTndg8iIwtoHTgwZjyaDnQ0xD0mYwAw==} + '@tanstack/react-query@5.99.2': + resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} peerDependencies: react: ^18 || ^19 @@ -1186,8 +1186,8 @@ packages: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.168.22': - resolution: {integrity: sha512-W2LyfkfJtDCf//jOjZeUBWwOVl8iDRVTECpGHa2M28MT3T5/VVnjgicYNHR/ax0Filk1iU67MRjcjHheTYvK1Q==} + '@tanstack/react-router@1.168.23': + resolution: {integrity: sha512-+GblieDnutG6oipJJPNtRJjrWF8QTZEG/l0532+BngFkVK48oHNOcvIkSoAFYftK1egAwM7KBxXsb0Ou+X6/MQ==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1206,8 +1206,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-virtual@3.13.23': - resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==} + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1264,8 +1264,8 @@ packages: resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} - '@tanstack/virtual-core@3.13.23': - resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} '@tanstack/virtual-file-routes@1.161.7': resolution: {integrity: sha512-olW33+Cn+bsCsZKPwEGhlkqS6w3M2slFv11JIobdnCFKMLG97oAI2kWKdx5/zsywTL8flpnoIgaZZPlQTFYhdQ==} @@ -1474,8 +1474,8 @@ packages: peerDependencies: postcss: ^8.1.0 - axios@1.15.0: - resolution: {integrity: sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==} + axios@1.15.1: + resolution: {integrity: sha512-WOG+Jj8ZOvR0a3rAn+Tuf1UQJRxw5venr6DgdbJzngJE3qG7X0kL83CZGpdHMxEm+ZK3seAbvFsw4FfOfP9vxg==} babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -1483,8 +1483,8 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - baseline-browser-mapping@2.10.19: - resolution: {integrity: sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==} + baseline-browser-mapping@2.10.20: + resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1935,8 +1935,8 @@ packages: resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} engines: {node: '>=20'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} hast-util-from-parse5@8.0.3: @@ -2559,8 +2559,8 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - rolldown@1.0.0-rc.15: - resolution: {integrity: sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==} + rolldown@1.0.0-rc.16: + resolution: {integrity: sha512-rzi5WqKzEZw3SooTt7cgm4eqIoujPIyGcJNGFL7iPEuajQw7vxMHUkXylu4/vhCkJGXsgRmxqMKXUpT6FEgl0g==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -2944,8 +2944,8 @@ packages: svgo: optional: true - vite@8.0.8: - resolution: {integrity: sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==} + vite@8.0.9: + resolution: {integrity: sha512-t7g7GVRpMXjNpa67HaVWI/8BWtdVIQPCL2WoozXXA7LBGEFK4AkkKkHx2hAQf5x1GZSlcmEDPkVLSGahxnEEZw==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true peerDependencies: @@ -3589,7 +3589,7 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 - '@oxc-project/types@0.124.0': {} + '@oxc-project/types@0.126.0': {} '@parcel/watcher-android-arm64@2.5.6': optional: true @@ -3680,56 +3680,56 @@ snapshots: react: 19.2.5 react-redux: 9.2.0(@types/react@19.2.14)(react@19.2.5)(redux@5.0.1) - '@rolldown/binding-android-arm64@1.0.0-rc.15': + '@rolldown/binding-android-arm64@1.0.0-rc.16': optional: true - '@rolldown/binding-darwin-arm64@1.0.0-rc.15': + '@rolldown/binding-darwin-arm64@1.0.0-rc.16': optional: true - '@rolldown/binding-darwin-x64@1.0.0-rc.15': + '@rolldown/binding-darwin-x64@1.0.0-rc.16': optional: true - '@rolldown/binding-freebsd-x64@1.0.0-rc.15': + '@rolldown/binding-freebsd-x64@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.15': + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-arm64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-x64-gnu@1.0.0-rc.15': + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.16': optional: true - '@rolldown/binding-linux-x64-musl@1.0.0-rc.15': + '@rolldown/binding-linux-x64-musl@1.0.0-rc.16': optional: true - '@rolldown/binding-openharmony-arm64@1.0.0-rc.15': + '@rolldown/binding-openharmony-arm64@1.0.0-rc.16': optional: true - '@rolldown/binding-wasm32-wasi@1.0.0-rc.15': + '@rolldown/binding-wasm32-wasi@1.0.0-rc.16': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true - '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.16': optional: true - '@rolldown/binding-win32-x64-msvc@1.0.0-rc.15': + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.16': optional: true - '@rolldown/pluginutils@1.0.0-rc.15': {} + '@rolldown/pluginutils@1.0.0-rc.16': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -3828,7 +3828,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.6.0(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.6.0(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -3840,7 +3840,7 @@ snapshots: chalk: 5.6.2 launch-editor: 2.13.2 picomatch: 4.0.4 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - supports-color @@ -3872,9 +3872,9 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.99.0': {} + '@tanstack/query-core@5.99.2': {} - '@tanstack/query-devtools@5.99.0': {} + '@tanstack/query-devtools@5.99.2': {} '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(solid-js@1.9.9)': dependencies: @@ -3897,20 +3897,20 @@ snapshots: transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.99.0(@tanstack/react-query@5.99.0(react@19.2.5))(react@19.2.5)': + '@tanstack/react-query-devtools@5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-devtools': 5.99.0 - '@tanstack/react-query': 5.99.0(react@19.2.5) + '@tanstack/query-devtools': 5.99.2 + '@tanstack/react-query': 5.99.2(react@19.2.5) react: 19.2.5 - '@tanstack/react-query@5.99.0(react@19.2.5)': + '@tanstack/react-query@5.99.2(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.99.0 + '@tanstack/query-core': 5.99.2 react: 19.2.5 - '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router-devtools@1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/react-router': 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/router-devtools-core': 1.167.3(@tanstack/router-core@1.168.15)(csstype@3.2.3) react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -3919,7 +3919,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: '@tanstack/history': 1.161.6 '@tanstack/react-store': 0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -3941,9 +3941,9 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@tanstack/react-virtual@3.13.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/virtual-core': 3.13.23 + '@tanstack/virtual-core': 3.14.0 react: 19.2.5 react-dom: 19.2.5(react@19.2.5) @@ -3975,7 +3975,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.167.22(@tanstack/react-router@1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': + '@tanstack/router-plugin@1.167.22(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3991,8 +3991,8 @@ snapshots: unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.168.22(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + '@tanstack/react-router': 1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -4014,7 +4014,7 @@ snapshots: '@tanstack/table-core@8.21.3': {} - '@tanstack/virtual-core@3.13.23': {} + '@tanstack/virtual-core@3.14.0': {} '@tanstack/virtual-file-routes@1.161.7': {} @@ -4111,10 +4111,10 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@6.0.1(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': + '@vitejs/plugin-react@6.0.1(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.7 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) '@vitest/expect@4.1.4': dependencies: @@ -4125,13 +4125,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': + '@vitest/mocker@4.1.4(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) '@vitest/pretty-format@4.1.4': dependencies: @@ -4160,7 +4160,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vitest: 4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + vitest: 4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) '@vitest/utils@4.1.4': dependencies: @@ -4213,7 +4213,7 @@ snapshots: postcss: 8.5.10 postcss-value-parser: 4.2.0 - axios@1.15.0: + axios@1.15.1: dependencies: follow-redirects: 1.16.0 form-data: 4.0.5 @@ -4232,7 +4232,7 @@ snapshots: bail@2.0.2: {} - baseline-browser-mapping@2.10.19: {} + baseline-browser-mapping@2.10.20: {} binary-extensions@2.3.0: {} @@ -4242,7 +4242,7 @@ snapshots: browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.19 + baseline-browser-mapping: 2.10.20 caniuse-lite: 1.0.30001788 electron-to-chromium: 1.5.340 node-releases: 2.0.37 @@ -4450,7 +4450,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.3 es-toolkit@1.45.1: {} @@ -4546,7 +4546,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.3 mime-types: 2.1.35 fraction.js@5.3.4: {} @@ -4579,7 +4579,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.3 math-intrinsics: 1.1.0 get-proto@1.0.1: @@ -4636,7 +4636,7 @@ snapshots: dependencies: hookified: 1.15.1 - hasown@2.0.2: + hasown@2.0.3: dependencies: function-bind: 1.1.2 @@ -5350,26 +5350,26 @@ snapshots: reusify@1.1.0: {} - rolldown@1.0.0-rc.15: + rolldown@1.0.0-rc.16: dependencies: - '@oxc-project/types': 0.124.0 - '@rolldown/pluginutils': 1.0.0-rc.15 + '@oxc-project/types': 0.126.0 + '@rolldown/pluginutils': 1.0.0-rc.16 optionalDependencies: - '@rolldown/binding-android-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-arm64': 1.0.0-rc.15 - '@rolldown/binding-darwin-x64': 1.0.0-rc.15 - '@rolldown/binding-freebsd-x64': 1.0.0-rc.15 - '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.15 - '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.15 - '@rolldown/binding-linux-x64-musl': 1.0.0-rc.15 - '@rolldown/binding-openharmony-arm64': 1.0.0-rc.15 - '@rolldown/binding-wasm32-wasi': 1.0.0-rc.15 - '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.15 - '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.15 + '@rolldown/binding-android-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-arm64': 1.0.0-rc.16 + '@rolldown/binding-darwin-x64': 1.0.0-rc.16 + '@rolldown/binding-freebsd-x64': 1.0.0-rc.16 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.16 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.16 + '@rolldown/binding-linux-x64-musl': 1.0.0-rc.16 + '@rolldown/binding-openharmony-arm64': 1.0.0-rc.16 + '@rolldown/binding-wasm32-wasi': 1.0.0-rc.16 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.16 + '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.16 run-parallel@1.2.0: dependencies: @@ -5855,20 +5855,20 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)): + vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)): dependencies: ansi-colors: 4.1.3 pathe: 2.0.3 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) optionalDependencies: sharp: 0.34.5 - vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0): + vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 postcss: 8.5.10 - rolldown: 1.0.0-rc.15 + rolldown: 1.0.0-rc.16 tinyglobby: 0.2.16 optionalDependencies: '@types/node': 25.6.0 @@ -5877,10 +5877,10 @@ snapshots: sass: 1.99.0 tsx: 4.21.0 - vitest@4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)): + vitest@4.1.4(@types/node@25.6.0)(@vitest/ui@4.1.4)(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)): dependencies: '@vitest/expect': 4.1.4 - '@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) + '@vitest/mocker': 4.1.4(vite@8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0)) '@vitest/pretty-format': 4.1.4 '@vitest/runner': 4.1.4 '@vitest/snapshot': 4.1.4 @@ -5897,7 +5897,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) + vite: 8.0.9(@types/node@25.6.0)(esbuild@0.27.7)(sass@1.99.0)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0