feat: sync profile before connection and apply current policy to enrollment responses

Add applyCurrentPolicy function to resolve and apply policy destinations to enrollment responses with fallback to 172.16.10.0/24 when no destinations exist. Replace withDebugProfile calls with applyCurrentPolicy in GetLatestEnrollmentByUser and GetEnrollmentByDeviceID. Extract sync_current_session helper function to deduplicate profile sync logic between sync_profile and connect_tunnel commands. Update connect
This commit is contained in:
2026-03-18 08:56:59 +01:00
parent e3bd6d3b96
commit 137fb1d3e7
3 changed files with 111 additions and 78 deletions

View File

@@ -113,7 +113,7 @@ func (s *Service) GetLatestEnrollmentByUser(ctx context.Context, userID uuid.UUI
if err != nil {
return EnrollmentResponse{}, err
}
return withDebugProfile(enrollment), nil
return s.applyCurrentPolicy(ctx, enrollment)
}
func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUID) (EnrollmentResponse, error) {
@@ -121,7 +121,7 @@ func (s *Service) GetEnrollmentByDeviceID(ctx context.Context, deviceID uuid.UUI
if err != nil {
return EnrollmentResponse{}, err
}
return withDebugProfile(enrollment), nil
return s.applyCurrentPolicy(ctx, enrollment)
}
func (s *Service) GetConnectionStatus(ctx context.Context, userID uuid.UUID) (ConnectionStatus, error) {
@@ -151,7 +151,7 @@ func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
}
func withDebugProfile(enrollment EnrollmentResponse) EnrollmentResponse {
profileAllowedIPs := mergeProfileAllowedIPs(enrollment.Peer.AllowedIPs, enrollment.Peer.DNSServers)
profileAllowedIPs := enrollment.Peer.AllowedIPs
enrollment.Profile = ProfileView{
Format: "wireguard",
Content: profile.BuildWireGuardConfig(profile.BuildInput{
@@ -167,6 +167,27 @@ func withDebugProfile(enrollment EnrollmentResponse) EnrollmentResponse {
return enrollment
}
func (s *Service) applyCurrentPolicy(ctx context.Context, enrollment EnrollmentResponse) (EnrollmentResponse, error) {
destinations, err := s.policyService.ResolveDestinations(ctx, enrollment.Device.UserID, &enrollment.Device.ID)
if err != nil {
return EnrollmentResponse{}, err
}
if len(destinations) == 0 {
destinations = []string{"172.16.10.0/24"}
}
enrollment.Resources = nil
for _, destination := range destinations {
enrollment.Resources = append(enrollment.Resources, Resource{
Type: "cidr",
Value: destination,
Label: destination,
})
}
enrollment.Peer.AllowedIPs = mergeProfileAllowedIPs(destinations, enrollment.Peer.DNSServers)
return withDebugProfile(enrollment), nil
}
func mergeProfileAllowedIPs(destinations []string, dnsServers []string) []string {
seen := make(map[string]struct{}, len(destinations)+len(dnsServers))
merged := make([]string, 0, len(destinations)+len(dnsServers))

View File

@@ -260,77 +260,18 @@ fn clear_session(app: AppHandle, state: State<'_, AppState>) -> Result<(), Strin
#[tauri::command]
async fn sync_profile(app: AppHandle, state: State<'_, AppState>) -> Result<EnrollmentResult, String> {
let existing = {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
session.clone().ok_or_else(|| "No enrolled profile is available yet".to_string())?
};
let client = Client::builder()
.use_rustls_tls()
.build()
.map_err(|err| err.to_string())?;
let response = client
.get(format!("{}/api/v1/me/profile", existing.server_url.trim_end_matches('/')))
.bearer_auth(&existing.access_token)
.send()
.await
.map_err(|err| format!("Profile sync failed: {}", err))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<unable to read response body>".into());
return Err(format!("Profile sync failed with status {}: {}", status, body));
}
let enroll = response
.json::<EnrollResponse>()
.await
.map_err(|err| format!("Unable to decode profile sync response: {}", err))?;
let profile_content = materialize_profile(&enroll.profile.content, &existing.private_key);
let profile_path = write_profile(&app, &profile_content)?;
let result = EnrollmentResult {
assigned_ip: enroll.peer.assigned_ip,
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
last_sync_time: now_label(),
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
};
let session_state = SessionState {
access_token: existing.access_token,
refresh_token: existing.refresh_token,
server_url: existing.server_url,
profile_path: result.profile_path.clone(),
private_key: existing.private_key,
enrollment: result.clone(),
};
write_session_state(&app, &session_state)?;
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
*session = Some(session_state);
drop(session);
let session_state = sync_current_session(&app).await?;
refresh_tray_menu(&app);
Ok(result)
Ok(session_state.enrollment)
}
#[tauri::command]
fn connect_tunnel(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
let profile_path = {
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
let session = session.as_ref().ok_or_else(|| "No enrolled profile is available yet".to_string())?;
session.profile_path.clone()
};
let result = tunnel_manager::connect(&app, std::path::Path::new(&profile_path));
async fn connect_tunnel(app: AppHandle) -> Result<EnrollmentResult, String> {
let session_state = sync_current_session(&app).await?;
let result = tunnel_manager::connect(&app, std::path::Path::new(&session_state.profile_path));
refresh_tray_menu(&app);
result
result?;
Ok(session_state.enrollment)
}
#[tauri::command]
@@ -464,6 +405,67 @@ fn current_metrics(app: &AppHandle) -> Result<TunnelMetrics, String> {
})
}
async fn sync_current_session(app: &AppHandle) -> Result<SessionState, String> {
let existing = {
let state = app.state::<AppState>();
let session = state.session.lock().map_err(|_| "Unable to read client state".to_string())?;
session.clone().ok_or_else(|| "No enrolled profile is available yet".to_string())?
};
let client = Client::builder()
.use_rustls_tls()
.build()
.map_err(|err| err.to_string())?;
let response = client
.get(format!("{}/api/v1/me/profile", existing.server_url.trim_end_matches('/')))
.bearer_auth(&existing.access_token)
.send()
.await
.map_err(|err| format!("Profile sync failed: {}", err))?;
if !response.status().is_success() {
let status = response.status();
let body = response
.text()
.await
.unwrap_or_else(|_| "<unable to read response body>".into());
return Err(format!("Profile sync failed with status {}: {}", status, body));
}
let enroll = response
.json::<EnrollResponse>()
.await
.map_err(|err| format!("Unable to decode profile sync response: {}", err))?;
let profile_content = materialize_profile(&enroll.profile.content, &existing.private_key);
let profile_path = write_profile(app, &profile_content)?;
let result = EnrollmentResult {
assigned_ip: enroll.peer.assigned_ip,
resources: enroll.resources.into_iter().map(|resource| resource.value).collect(),
profile_revision: enroll.peer.profile_revision,
gateway_endpoint: enroll.peer.gateway.endpoint,
profile_path: profile_path.display().to_string(),
last_sync_time: now_label(),
tunnel_strategy: tunnel_manager::current_tunnel_strategy().into(),
};
let session_state = SessionState {
access_token: existing.access_token,
refresh_token: existing.refresh_token,
server_url: existing.server_url,
profile_path: result.profile_path.clone(),
private_key: existing.private_key,
enrollment: result.clone(),
};
write_session_state(app, &session_state)?;
let state = app.state::<AppState>();
let mut session = state.session.lock().map_err(|_| "Unable to store client state".to_string())?;
*session = Some(session_state.clone());
Ok(session_state)
}
fn update_tray_menu(app: &AppHandle, metrics: TunnelMetrics) -> Result<(), String> {
let state = app.state::<AppState>();
let tray = state.tray.lock().map_err(|_| "Unable to update tray state".to_string())?;
@@ -510,15 +512,20 @@ fn toggle_tray_connection(app: &AppHandle) {
Err(_) => return,
};
let result = if metrics.active {
tunnel_manager::disconnect(app, std::path::Path::new(&profile_path))
} else {
tunnel_manager::connect(app, std::path::Path::new(&profile_path))
};
if result.is_ok() {
refresh_tray_menu(app);
if metrics.active {
if tunnel_manager::disconnect(app, std::path::Path::new(&profile_path)).is_ok() {
refresh_tray_menu(app);
}
return;
}
let app_handle = app.clone();
tauri::async_runtime::spawn(async move {
if let Ok(session_state) = sync_current_session(&app_handle).await {
let _ = tunnel_manager::connect(&app_handle, std::path::Path::new(&session_state.profile_path));
refresh_tray_menu(&app_handle);
}
});
}
fn restore_webview_window(window: &WebviewWindow) {

View File

@@ -183,7 +183,12 @@ export function App() {
async function toggleConnection() {
const command = connected ? "disconnect_tunnel" : "connect_tunnel";
try {
await invoke(command);
if (!connected) {
const syncedState = await invoke<EnrollmentState>("connect_tunnel");
setState(syncedState);
} else {
await invoke(command);
}
const active = await waitForTunnelStatus(!connected);
setConnected(active);
if (!connected && !active) {