feat: add service catalog management with policy integration for domain-based resource access control

Add ServiceCatalogItem type and services CRUD API endpoints (list, create, update, delete). Extend Policy type to include services array with domain, upstream_ip, proxy_ip, and ports metadata.

Add ServicesPage component with table view and create/edit modals for managing service definitions. Include service name, domain, proxy, and upstream columns with port parsing logic.

Integrate service selection
This commit is contained in:
2026-03-18 13:09:54 +01:00
parent 0ac93dfeb6
commit 6cf49ff3e0
25 changed files with 1375 additions and 99 deletions

View File

@@ -94,7 +94,8 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
wp.public_key,
set_masklen(wp.assigned_ip, 32)::text,
coalesce(array_agg(distinct pd.destination::text) filter (where pd.destination is not null), '{}'),
coalesce(g.dns_servers, '{}')::text[]
coalesce(g.dns_servers, '{}')::text[],
s.value->>'profile_id'
from devices d
join wireguard_peers wp on wp.device_id = d.id and wp.deleted_at is null
join gateways g on g.id = d.gateway_id
@@ -115,7 +116,7 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
or p.id::text = s.value->>'profile_id'
)
where d.gateway_id = $1 and d.deleted_at is null and d.status = 'active'
group by d.id, wp.public_key, wp.assigned_ip, g.dns_servers
group by d.id, wp.public_key, wp.assigned_ip, g.dns_servers, s.value
`, gatewayID)
if err != nil {
return wireguard.GatewayBundle{}, err
@@ -125,37 +126,70 @@ func (r *PGRepository) BuildSyncBundle(ctx context.Context, gatewayID uuid.UUID)
for rows.Next() {
var peer wireguard.Peer
var deviceID uuid.UUID
if err := rows.Scan(&deviceID, &peer.PublicKey, &peer.AssignedIP, &peer.AllowedDestinations, &peer.DNSServers); err != nil {
var selectedProfileID *string
if err := rows.Scan(&deviceID, &peer.PublicKey, &peer.AssignedIP, &peer.AllowedDestinations, &peer.DNSServers, &selectedProfileID); err != nil {
return wireguard.GatewayBundle{}, err
}
peer.DeviceID = deviceID.String()
peer.WebProxyTargets = alwaysAllowWebProxyTargets()
services, err := r.listAllowedServices(ctx, deviceID, selectedProfileID)
if err != nil {
return wireguard.GatewayBundle{}, err
}
peer.AllowedServices = services
bundle.Peers = append(bundle.Peers, peer)
}
return bundle, rows.Err()
}
func alwaysAllowWebProxyTargets() []string {
raw := os.Getenv("NEXAVPN_ALWAYS_ALLOW_WEB_PROXY_IPS")
if strings.TrimSpace(raw) == "" {
return nil
func (r *PGRepository) listAllowedServices(ctx context.Context, deviceID uuid.UUID, selectedProfileID *string) ([]wireguard.AllowedService, error) {
rows, err := r.db.Query(ctx, `
select distinct
s.name,
s.domain,
host(s.upstream_ip),
host(s.proxy_ip),
s.ports
from devices d
left join group_memberships gm on gm.user_id = d.user_id
join policy_targets pt on (
(pt.target_type = 'device' and pt.target_id = d.id) or
(pt.target_type = 'user' and pt.target_id = d.user_id) or
(pt.target_type = 'group' and pt.target_id = gm.group_id)
)
join policies p on p.id = pt.policy_id
and p.deleted_at is null
and p.is_active = true
and p.effect = 'allow'
join policy_services ps on ps.policy_id = p.id
join services s on s.id = ps.service_id and s.deleted_at is null and s.is_active = true
where d.id = $1
and ($2::text is null or p.id::text = $2::text)
order by s.name asc
`, deviceID, selectedProfileID)
if err != nil {
return nil, err
}
defer rows.Close()
seen := make(map[string]struct{})
targets := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
value := strings.TrimSpace(part)
if value == "" {
continue
var items []wireguard.AllowedService
for rows.Next() {
var item wireguard.AllowedService
if err := rows.Scan(&item.Name, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports); err != nil {
return nil, err
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
targets = append(targets, value)
item.AccessProxyIP = effectiveAccessProxyIP(item.ProxyIP)
items = append(items, item)
}
return targets
return items, rows.Err()
}
func effectiveAccessProxyIP(proxyIP string) string {
override := strings.TrimSpace(os.Getenv("NEXAVPN_ACCESS_PROXY_IP"))
if override != "" {
return override
}
return proxyIP
}
func (r *PGRepository) Update(ctx context.Context, gatewayID uuid.UUID, input UpdateRequest) (Gateway, error) {