diff --git a/admin-web/src/api/client.ts b/admin-web/src/api/client.ts
index 93fff36..e539f01 100644
--- a/admin-web/src/api/client.ts
+++ b/admin-web/src/api/client.ts
@@ -225,6 +225,10 @@ export const api = {
method: "POST",
body: JSON.stringify({})
}),
+ deleteDevice: (deviceId: string) =>
+ request<{ ok: boolean }>(`/admin/devices/${deviceId}`, {
+ method: "DELETE"
+ }),
rotateDevice: (deviceId: string) =>
request<{ ok: boolean }>(`/admin/devices/${deviceId}/rotate`, {
method: "POST",
diff --git a/admin-web/src/features/devices/DevicesPage.tsx b/admin-web/src/features/devices/DevicesPage.tsx
index ea621ba..5d4cc58 100644
--- a/admin-web/src/features/devices/DevicesPage.tsx
+++ b/admin-web/src/features/devices/DevicesPage.tsx
@@ -46,6 +46,13 @@ export function DevicesPage() {
mutationFn: api.revokeDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
});
+ const deleteMutation = useMutation({
+ mutationFn: api.deleteDevice,
+ onSuccess: () => {
+ void queryClient.invalidateQueries({ queryKey: ["devices"] });
+ void queryClient.invalidateQueries({ queryKey: ["device-profile"] });
+ }
+ });
const rotateMutation = useMutation({
mutationFn: api.rotateDevice,
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ["devices"] })
@@ -85,6 +92,7 @@ export function DevicesPage() {
+
diff --git a/backend/internal/device/handler.go b/backend/internal/device/handler.go
index 97cc16c..6241e49 100644
--- a/backend/internal/device/handler.go
+++ b/backend/internal/device/handler.go
@@ -196,3 +196,26 @@ func (h *Handler) Rotate(w http.ResponseWriter, r *http.Request) {
}
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
}
+
+func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
+ deviceID, err := uuid.Parse(chi.URLParam(r, "id"))
+ if err != nil {
+ apiutil.Error(w, http.StatusBadRequest, "invalid_device_id", "invalid device id")
+ return
+ }
+ if err := h.service.Delete(r.Context(), deviceID); err != nil {
+ apiutil.Error(w, http.StatusInternalServerError, "device_delete_failed", "unable to delete device")
+ return
+ }
+ if claims, ok := requestctx.ClaimsFromContext(r.Context()); ok {
+ _ = h.audit.Record(r.Context(), audit.Entry{
+ ActorUserID: &claims.UserID,
+ EntityType: "device",
+ EntityID: &deviceID,
+ EventType: "admin.device.deleted",
+ Status: "success",
+ Message: "admin deleted device",
+ })
+ }
+ apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
+}
diff --git a/backend/internal/device/repository.go b/backend/internal/device/repository.go
index b9cffb5..851256f 100644
--- a/backend/internal/device/repository.go
+++ b/backend/internal/device/repository.go
@@ -22,6 +22,7 @@ type Repository interface {
GetSelectedProfileID(ctx context.Context, deviceID uuid.UUID) (*uuid.UUID, error)
SetSelectedProfileID(ctx context.Context, deviceID uuid.UUID, profileID uuid.UUID) error
Revoke(ctx context.Context, deviceID uuid.UUID) error
+ Delete(ctx context.Context, deviceID uuid.UUID) error
Rotate(ctx context.Context, deviceID uuid.UUID) error
}
@@ -292,6 +293,44 @@ func (r *PGRepository) Revoke(ctx context.Context, deviceID uuid.UUID) error {
return tx.Commit(ctx)
}
+func (r *PGRepository) Delete(ctx context.Context, deviceID uuid.UUID) error {
+ tx, err := r.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+
+ if _, err := tx.Exec(ctx, `
+ update devices
+ set status = 'deleted', deleted_at = now(), revoked_at = coalesce(revoked_at, now()), updated_at = now()
+ where id = $1 and deleted_at is null
+ `, deviceID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `
+ update wireguard_peers
+ set deleted_at = now(), updated_at = now()
+ where device_id = $1 and deleted_at is null
+ `, deviceID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `
+ update ip_allocations
+ set status = 'released', released_at = coalesce(released_at, now()), updated_at = now()
+ where device_id = $1 and status = 'allocated'
+ `, deviceID); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `
+ delete from settings
+ where category = 'device_access_profile' and key = $1
+ `, deviceID.String()); err != nil {
+ return err
+ }
+
+ return tx.Commit(ctx)
+}
+
func (r *PGRepository) Rotate(ctx context.Context, deviceID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
update wireguard_peers
diff --git a/backend/internal/device/service.go b/backend/internal/device/service.go
index a2fa16c..e54cc86 100644
--- a/backend/internal/device/service.go
+++ b/backend/internal/device/service.go
@@ -189,6 +189,10 @@ func (s *Service) Revoke(ctx context.Context, deviceID uuid.UUID) error {
return s.repo.Revoke(ctx, deviceID)
}
+func (s *Service) Delete(ctx context.Context, deviceID uuid.UUID) error {
+ return s.repo.Delete(ctx, deviceID)
+}
+
func (s *Service) Rotate(ctx context.Context, deviceID uuid.UUID) error {
return s.repo.Rotate(ctx, deviceID)
}
diff --git a/backend/internal/httpserver/router.go b/backend/internal/httpserver/router.go
index 7d52883..1ea5444 100644
--- a/backend/internal/httpserver/router.go
+++ b/backend/internal/httpserver/router.go
@@ -67,6 +67,7 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Get("/devices/{id}/profile", handlers.Device.GetProfileByDeviceID)
r.Post("/devices/{id}/revoke", handlers.Device.Revoke)
r.Post("/devices/{id}/rotate", handlers.Device.Rotate)
+ r.Delete("/devices/{id}", handlers.Device.Delete)
r.Get("/groups", handlers.Group.List)
r.Post("/groups", handlers.Group.Create)
r.Patch("/groups/{id}", handlers.Group.Update)
diff --git a/deploy/scripts/gateway-entrypoint.sh b/deploy/scripts/gateway-entrypoint.sh
index 536b99e..dfa266b 100644
--- a/deploy/scripts/gateway-entrypoint.sh
+++ b/deploy/scripts/gateway-entrypoint.sh
@@ -119,6 +119,9 @@ EOF
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} udp dport 53 accept"
echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${dns_server} tcp dport 53 accept"
done
+ printf '%s' "${peer}" | jq -r '.allowed_destinations[]?' | while read -r destination; do
+ echo " iifname \"${IFACE}\" ip saddr ${ASSIGNED_IP} ip daddr ${destination} accept"
+ done
printf '%s' "${peer}" | jq -c '.allowed_services[]?' | while read -r service; do
SERVICE_PROXY_IP="$(printf '%s' "${service}" | jq -r '.access_proxy_ip')"
printf '%s' "${service}" | jq -r '.ports[]?' | while read -r service_port; do