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

@@ -16,6 +16,7 @@ import (
"nexavpn/backend/internal/httpserver"
"nexavpn/backend/internal/ipam"
"nexavpn/backend/internal/policy"
"nexavpn/backend/internal/servicecatalog"
"nexavpn/backend/internal/user"
)
@@ -31,12 +32,16 @@ func New(cfg config.Config) (*App, error) {
if err != nil {
return nil, err
}
if err := db.EnsureSchema(ctx, pool); err != nil {
return nil, err
}
authRepo := auth.NewPGRepository(pool)
authService := auth.NewService(authRepo, cfg.JWTSecret, cfg.JWTIssuer, cfg.AccessTokenTTL, cfg.RefreshTokenTTL)
userService := user.NewService(user.NewPGRepository(pool))
groupService := group.NewService(group.NewPGRepository(pool))
serviceCatalogService := servicecatalog.NewService(servicecatalog.NewPGRepository(pool))
policyService := policy.NewService(policy.NewPGRepository(pool))
gatewayService := gateway.NewService(gateway.NewPGRepository(pool))
deviceService := device.NewService(device.NewPGRepository(pool), policyService, gatewayService, ipam.NewService())
@@ -47,6 +52,7 @@ func New(cfg config.Config) (*App, error) {
User: user.NewHandler(userService, auditService),
Device: device.NewHandler(deviceService, auditService),
Group: group.NewHandler(groupService, auditService),
Service: servicecatalog.NewHandler(serviceCatalogService),
Policy: policy.NewHandler(policyService, auditService),
Gateway: gateway.NewHandler(gatewayService, cfg.GatewayBootstrapToken),
Audit: audit.NewHandler(auditService),

View File

@@ -9,3 +9,35 @@ import (
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
return pgxpool.New(ctx, databaseURL)
}
func EnsureSchema(ctx context.Context, db *pgxpool.Pool) error {
_, err := db.Exec(ctx, `
create table if not exists services (
id uuid primary key default gen_random_uuid(),
name text not null unique,
description text not null default '',
domain text not null,
upstream_ip inet not null,
proxy_ip inet not null,
ports integer[] not null default '{80,443}',
is_active boolean not null default true,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
deleted_at timestamptz
);
create table if not exists policy_services (
id uuid primary key default gen_random_uuid(),
policy_id uuid not null references policies(id) on delete cascade,
service_id uuid not null references services(id) on delete cascade,
created_at timestamptz not null default now(),
unique(policy_id, service_id)
);
create index if not exists idx_services_domain on services(domain) where deleted_at is null;
create unique index if not exists idx_services_domain_unique on services(lower(domain)) where deleted_at is null;
create index if not exists idx_policy_services_policy_id on policy_services(policy_id);
create index if not exists idx_policy_services_service_id on policy_services(service_id);
`)
return err
}

View File

@@ -71,7 +71,11 @@ func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequ
if len(selectedDestinations) == 0 {
selectedDestinations = destinations
}
profileAllowedIPs := mergeProfileAllowedIPs(selectedDestinations, selectedGateway.DNSServers, alwaysAllowWebProxyTargets())
selectedServices := servicesForSelectedProfile(availableProfiles, selectedProfileID)
profileAllowedIPs := mergeProfileAllowedIPs(
append(selectedDestinations, proxyRoutesForServices(selectedServices)...),
selectedGateway.DNSServers,
)
enrollment.Peer = PeerView{
AssignedIP: assignedIP,
@@ -85,7 +89,7 @@ func (s *Service) Enroll(ctx context.Context, userID uuid.UUID, input EnrollRequ
},
ProfileRevision: 1,
}
enrollment.Resources = resourcesFromDestinations(selectedDestinations)
enrollment.Resources = resourcesFromProfile(selectedDestinations, selectedServices)
enrollment.AvailableProfiles = availableProfiles
enrollment.SelectedProfileID = selectedProfileID
@@ -215,10 +219,14 @@ func (s *Service) applyCurrentPolicy(ctx context.Context, enrollment EnrollmentR
selectedDestinations = []string{"172.16.10.0/24"}
}
enrollment.Resources = resourcesFromDestinations(selectedDestinations)
selectedServices := servicesForSelectedProfile(availableProfiles, selectedProfileID)
enrollment.Resources = resourcesFromProfile(selectedDestinations, selectedServices)
enrollment.AvailableProfiles = availableProfiles
enrollment.SelectedProfileID = selectedProfileID
enrollment.Peer.AllowedIPs = mergeProfileAllowedIPs(selectedDestinations, enrollment.Peer.DNSServers, alwaysAllowWebProxyTargets())
enrollment.Peer.AllowedIPs = mergeProfileAllowedIPs(
append(selectedDestinations, proxyRoutesForServices(selectedServices)...),
enrollment.Peer.DNSServers,
)
return withDebugProfile(enrollment), nil
}
@@ -230,12 +238,25 @@ func (s *Service) resolveAccessProfiles(ctx context.Context, userID uuid.UUID, d
availableProfiles := make([]AccessProfile, 0, len(profiles))
for _, profile := range profiles {
services := make([]AccessService, 0, len(profile.Services))
for _, service := range profile.Services {
services = append(services, AccessService{
ID: service.ID,
Name: service.Name,
Description: service.Description,
Domain: service.Domain,
UpstreamIP: service.UpstreamIP,
ProxyIP: service.ProxyIP,
Ports: service.Ports,
})
}
availableProfiles = append(availableProfiles, AccessProfile{
ID: profile.ID,
Name: profile.Name,
Description: profile.Description,
FullTunnel: profile.FullTunnel,
Destinations: profile.Destinations,
Services: services,
})
}
@@ -273,9 +294,64 @@ func resourcesFromDestinations(destinations []string) []Resource {
return resources
}
func mergeProfileAllowedIPs(destinations []string, dnsServers []string, webProxyTargets []string) []string {
seen := make(map[string]struct{}, len(destinations)+len(dnsServers)+len(webProxyTargets))
merged := make([]string, 0, len(destinations)+len(dnsServers)+len(webProxyTargets))
func resourcesFromProfile(destinations []string, services []AccessService) []Resource {
resources := resourcesFromDestinations(destinations)
for _, service := range services {
resources = append(resources, Resource{
Type: "service",
Value: service.Domain,
Label: service.Name,
Domain: service.Domain,
})
}
return resources
}
func servicesForSelectedProfile(profiles []AccessProfile, selectedProfileID *uuid.UUID) []AccessService {
if selectedProfileID == nil {
if len(profiles) == 0 {
return nil
}
return profiles[0].Services
}
for _, profile := range profiles {
if profile.ID == *selectedProfileID {
return profile.Services
}
}
return nil
}
func proxyRoutesForServices(services []AccessService) []string {
seen := make(map[string]struct{}, len(services))
routes := make([]string, 0, len(services))
for _, service := range services {
route := dnsServerRoute(effectiveServiceProxyIP(service.ProxyIP))
if route == "" {
continue
}
if _, ok := seen[route]; ok {
continue
}
seen[route] = struct{}{}
routes = append(routes, route)
}
return routes
}
func effectiveServiceProxyIP(proxyIP string) string {
override := strings.TrimSpace(os.Getenv("NEXAVPN_ACCESS_PROXY_IP"))
if override != "" {
return override
}
return proxyIP
}
func mergeProfileAllowedIPs(destinations []string, dnsServers []string) []string {
seen := make(map[string]struct{}, len(destinations)+len(dnsServers))
merged := make([]string, 0, len(destinations)+len(dnsServers))
for _, destination := range destinations {
destination = strings.TrimSpace(destination)
@@ -301,18 +377,6 @@ func mergeProfileAllowedIPs(destinations []string, dnsServers []string, webProxy
merged = append(merged, route)
}
for _, target := range webProxyTargets {
route := dnsServerRoute(target)
if route == "" {
continue
}
if _, exists := seen[route]; exists {
continue
}
seen[route] = struct{}{}
merged = append(merged, route)
}
return merged
}
@@ -326,25 +390,3 @@ func dnsServerRoute(value string) string {
}
return value + "/32"
}
func alwaysAllowWebProxyTargets() []string {
raw := os.Getenv("NEXAVPN_ALWAYS_ALLOW_WEB_PROXY_IPS")
if strings.TrimSpace(raw) == "" {
return nil
}
seen := make(map[string]struct{})
targets := make([]string, 0)
for _, part := range strings.Split(raw, ",") {
value := strings.TrimSpace(part)
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
targets = append(targets, value)
}
return targets
}

View File

@@ -34,6 +34,7 @@ type Resource struct {
Type string `json:"type"`
Value string `json:"value"`
Label string `json:"label"`
Domain string `json:"domain,omitempty"`
}
type EnrollmentResponse struct {
@@ -51,6 +52,17 @@ type AccessProfile struct {
Description string `json:"description"`
FullTunnel bool `json:"full_tunnel"`
Destinations []string `json:"destinations"`
Services []AccessService `json:"services"`
}
type AccessService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Domain string `json:"domain"`
UpstreamIP string `json:"upstream_ip"`
ProxyIP string `json:"proxy_ip"`
Ports []int `json:"ports"`
}
type PeerView struct {

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) {

View File

@@ -12,6 +12,7 @@ import (
"nexavpn/backend/internal/gateway"
"nexavpn/backend/internal/group"
"nexavpn/backend/internal/policy"
"nexavpn/backend/internal/servicecatalog"
"nexavpn/backend/internal/user"
)
@@ -19,6 +20,7 @@ type Handlers struct {
Auth *auth.Handler
User *user.Handler
Device *device.Handler
Service *servicecatalog.Handler
Policy *policy.Handler
Gateway *gateway.Handler
Group *group.Handler
@@ -68,6 +70,10 @@ func NewRouter(jwtSecret string, handlers Handlers) http.Handler {
r.Post("/groups", handlers.Group.Create)
r.Patch("/groups/{id}", handlers.Group.Update)
r.Delete("/groups/{id}", handlers.Group.Delete)
r.Get("/services", handlers.Service.List)
r.Post("/services", handlers.Service.Create)
r.Patch("/services/{id}", handlers.Service.Update)
r.Delete("/services/{id}", handlers.Service.Delete)
r.Get("/policies", handlers.Policy.List)
r.Post("/policies", handlers.Policy.Create)
r.Patch("/policies/{id}", handlers.Policy.Update)

View File

@@ -53,6 +53,11 @@ func (r *PGRepository) List(ctx context.Context) ([]Policy, error) {
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Priority, &item.Effect, &item.FullTunnel, &item.IsActive, &item.Destinations); err != nil {
return nil, err
}
services, err := r.listServices(ctx, item.ID)
if err != nil {
return nil, err
}
item.Services = services
targets, err := r.listTargets(ctx, item.ID)
if err != nil {
return nil, err
@@ -88,6 +93,15 @@ func (r *PGRepository) Create(ctx context.Context, input CreateRequest, createdB
}
}
for _, serviceID := range input.ServiceIDs {
if _, err := tx.Exec(ctx, `
insert into policy_services (id, policy_id, service_id)
values ($1, $2, $3)
`, uuid.New(), policyID, serviceID); err != nil {
return Policy{}, err
}
}
for _, target := range input.Targets {
if _, err := tx.Exec(ctx, `
insert into policy_targets (id, policy_id, target_type, target_id)
@@ -141,6 +155,20 @@ func (r *PGRepository) Update(ctx context.Context, policyID uuid.UUID, input Upd
}
}
if input.ServiceIDs != nil {
if _, err := tx.Exec(ctx, `delete from policy_services where policy_id = $1`, policyID); err != nil {
return Policy{}, err
}
for _, serviceID := range input.ServiceIDs {
if _, err := tx.Exec(ctx, `
insert into policy_services (id, policy_id, service_id)
values ($1, $2, $3)
`, uuid.New(), policyID, serviceID); err != nil {
return Policy{}, err
}
}
}
if input.Targets != nil {
if _, err := tx.Exec(ctx, `delete from policy_targets where policy_id = $1`, policyID); err != nil {
return Policy{}, err
@@ -169,26 +197,49 @@ func (r *PGRepository) Delete(ctx context.Context, policyID uuid.UUID) error {
func (r *PGRepository) ResolveDestinations(ctx context.Context, userID uuid.UUID, deviceID *uuid.UUID) ([]string, error) {
query := `
select distinct pd.destination::text
from policies p
join policy_destinations pd on pd.policy_id = p.id
join policy_targets pt on pt.policy_id = p.id
where p.deleted_at is null
and p.is_active = true
and p.effect = 'allow'
and (
(pt.target_type = 'user' and pt.target_id = $1)
or (pt.target_type = 'group' and exists (
select 1 from group_memberships gm
where gm.group_id = pt.target_id and gm.user_id = $1
))
select distinct destination
from (
select pd.destination::text as destination
from policies p
join policy_destinations pd on pd.policy_id = p.id
join policy_targets pt on pt.policy_id = p.id
where p.deleted_at is null
and p.is_active = true
and p.effect = 'allow'
and (
(pt.target_type = 'user' and pt.target_id = $1)
or (pt.target_type = 'group' and exists (
select 1 from group_memberships gm
where gm.group_id = pt.target_id and gm.user_id = $1
))
`
args := []any{userID}
if deviceID != nil {
query += ` or (pt.target_type = 'device' and pt.target_id = $2)`
args = append(args, *deviceID)
}
query += `)`
query += `)
union
select host(s.proxy_ip)::text || '/32' as destination
from policies p
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
join policy_targets pt on pt.policy_id = p.id
where p.deleted_at is null
and p.is_active = true
and p.effect = 'allow'
and (
(pt.target_type = 'user' and pt.target_id = $1)
or (pt.target_type = 'group' and exists (
select 1 from group_memberships gm
where gm.group_id = pt.target_id and gm.user_id = $1
))
`
if deviceID != nil {
query += ` or (pt.target_type = 'device' and pt.target_id = $2)`
}
query += `)
) destinations`
rows, err := r.db.Query(ctx, query, args...)
if err != nil {
@@ -249,6 +300,11 @@ func (r *PGRepository) ListSelectableProfiles(ctx context.Context, userID uuid.U
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.FullTunnel, &item.Destinations); err != nil {
return nil, err
}
services, err := r.listServices(ctx, item.ID)
if err != nil {
return nil, err
}
item.Services = services
profiles = append(profiles, item)
}
return profiles, rows.Err()
@@ -296,3 +352,34 @@ func (r *PGRepository) listTargets(ctx context.Context, policyID uuid.UUID) ([]T
return items, rows.Err()
}
func (r *PGRepository) listServices(ctx context.Context, policyID uuid.UUID) ([]PolicyService, error) {
rows, err := r.db.Query(ctx, `
select
s.id,
s.name,
s.domain,
host(s.upstream_ip),
host(s.proxy_ip),
s.ports,
s.description
from policy_services ps
join services s on s.id = ps.service_id
where ps.policy_id = $1 and s.deleted_at is null
order by s.name asc
`, policyID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []PolicyService
for rows.Next() {
var item PolicyService
if err := rows.Scan(&item.ID, &item.Name, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports, &item.Description); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}

View File

@@ -17,9 +17,20 @@ type Policy struct {
FullTunnel bool `json:"full_tunnel"`
IsActive bool `json:"is_active"`
Destinations []string `json:"destinations"`
Services []PolicyService `json:"services"`
Targets []Target `json:"targets"`
}
type PolicyService struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Domain string `json:"domain"`
UpstreamIP string `json:"upstream_ip"`
ProxyIP string `json:"proxy_ip"`
Ports []int `json:"ports"`
Description string `json:"description"`
}
type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
@@ -27,6 +38,7 @@ type CreateRequest struct {
Effect string `json:"effect"`
FullTunnel bool `json:"full_tunnel"`
Destinations []string `json:"destinations"`
ServiceIDs []uuid.UUID `json:"service_ids"`
Targets []Target `json:"targets"`
}
@@ -38,6 +50,7 @@ type UpdateRequest struct {
FullTunnel *bool `json:"full_tunnel"`
IsActive *bool `json:"is_active"`
Destinations []string `json:"destinations"`
ServiceIDs []uuid.UUID `json:"service_ids"`
Targets []Target `json:"targets"`
}
@@ -47,4 +60,5 @@ type SelectableProfile struct {
Description string `json:"description"`
FullTunnel bool `json:"full_tunnel"`
Destinations []string `json:"destinations"`
Services []PolicyService `json:"services"`
}

View File

@@ -0,0 +1,78 @@
package servicecatalog
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"nexavpn/backend/internal/apiutil"
)
type Handler struct {
service *CatalogService
}
func NewHandler(service *CatalogService) *Handler {
return &Handler{service: service}
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
items, err := h.service.List(r.Context())
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "services_list_failed", "unable to list services")
return
}
apiutil.JSON(w, http.StatusOK, items)
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
var input CreateRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
item, err := h.service.Create(r.Context(), input)
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "service_create_failed", "unable to create service")
return
}
apiutil.JSON(w, http.StatusCreated, item)
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
serviceID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_service_id", "invalid service id")
return
}
var input UpdateRequest
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_json", "invalid request body")
return
}
item, err := h.service.Update(r.Context(), serviceID, input)
if err != nil {
apiutil.Error(w, http.StatusInternalServerError, "service_update_failed", "unable to update service")
return
}
apiutil.JSON(w, http.StatusOK, item)
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
serviceID, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
apiutil.Error(w, http.StatusBadRequest, "invalid_service_id", "invalid service id")
return
}
if err := h.service.Delete(r.Context(), serviceID); err != nil {
apiutil.Error(w, http.StatusInternalServerError, "service_delete_failed", "unable to delete service")
return
}
apiutil.JSON(w, http.StatusOK, map[string]any{"ok": true})
}

View File

@@ -0,0 +1,152 @@
package servicecatalog
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
type Repository interface {
List(ctx context.Context) ([]Service, error)
Create(ctx context.Context, input CreateRequest) (Service, error)
Update(ctx context.Context, serviceID uuid.UUID, input UpdateRequest) (Service, error)
Delete(ctx context.Context, serviceID uuid.UUID) error
ByIDs(ctx context.Context, ids []uuid.UUID) ([]Service, error)
}
type PGRepository struct {
db *pgxpool.Pool
}
func NewPGRepository(db *pgxpool.Pool) *PGRepository {
return &PGRepository{db: db}
}
func (r *PGRepository) List(ctx context.Context) ([]Service, error) {
rows, err := r.db.Query(ctx, `
select id, name, description, domain, host(upstream_ip), host(proxy_ip), ports, is_active
from services
where deleted_at is null
order by name asc
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Service
for rows.Next() {
var item Service
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports, &item.IsActive); err != nil {
return nil, err
}
items = append(items, item)
}
return items, rows.Err()
}
func (r *PGRepository) Create(ctx context.Context, input CreateRequest) (Service, error) {
row := r.db.QueryRow(ctx, `
insert into services (id, name, description, domain, upstream_ip, proxy_ip, ports, is_active)
values ($1, $2, $3, $4, $5::inet, $6::inet, $7::integer[], coalesce($8, true))
returning id, name, description, domain, host(upstream_ip), host(proxy_ip), ports, is_active
`, uuid.New(), input.Name, input.Description, input.Domain, input.UpstreamIP, input.ProxyIP, normalizePorts(input.Ports), input.IsActive)
var item Service
err := row.Scan(&item.ID, &item.Name, &item.Description, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports, &item.IsActive)
return item, err
}
func (r *PGRepository) Update(ctx context.Context, serviceID uuid.UUID, input UpdateRequest) (Service, error) {
var ports *[]int
if input.Ports != nil {
normalized := normalizePorts(input.Ports)
ports = &normalized
}
row := r.db.QueryRow(ctx, `
update services
set
name = coalesce($2, name),
description = coalesce($3, description),
domain = coalesce($4, domain),
upstream_ip = coalesce($5::inet, upstream_ip),
proxy_ip = coalesce($6::inet, proxy_ip),
ports = coalesce($7::integer[], ports),
is_active = coalesce($8, is_active),
updated_at = now()
where id = $1 and deleted_at is null
returning id, name, description, domain, host(upstream_ip), host(proxy_ip), ports, is_active
`, serviceID, input.Name, input.Description, input.Domain, input.UpstreamIP, input.ProxyIP, ports, input.IsActive)
var item Service
err := row.Scan(&item.ID, &item.Name, &item.Description, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports, &item.IsActive)
return item, err
}
func (r *PGRepository) Delete(ctx context.Context, serviceID uuid.UUID) error {
_, err := r.db.Exec(ctx, `
update services
set deleted_at = now(), updated_at = now()
where id = $1 and deleted_at is null
`, serviceID)
return err
}
func (r *PGRepository) ByIDs(ctx context.Context, ids []uuid.UUID) ([]Service, error) {
if len(ids) == 0 {
return nil, nil
}
rows, err := r.db.Query(ctx, `
select id, name, description, domain, host(upstream_ip), host(proxy_ip), ports, is_active
from services
where deleted_at is null and id = any($1::uuid[])
order by name asc
`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Service
for rows.Next() {
var item Service
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &item.Domain, &item.UpstreamIP, &item.ProxyIP, &item.Ports, &item.IsActive); err != nil {
return nil, err
}
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(items) == 0 {
return nil, errors.New("services not found")
}
return items, nil
}
func normalizePorts(ports []int) []int {
if len(ports) == 0 {
return []int{80, 443}
}
seen := make(map[int]struct{}, len(ports))
result := make([]int, 0, len(ports))
for _, port := range ports {
if port <= 0 {
continue
}
if _, ok := seen[port]; ok {
continue
}
seen[port] = struct{}{}
result = append(result, port)
}
if len(result) == 0 {
return []int{80, 443}
}
return result
}

View File

@@ -0,0 +1,35 @@
package servicecatalog
import (
"context"
"github.com/google/uuid"
)
type CatalogService struct {
repo Repository
}
func NewService(repo Repository) *CatalogService {
return &CatalogService{repo: repo}
}
func (s *CatalogService) List(ctx context.Context) ([]Service, error) {
return s.repo.List(ctx)
}
func (s *CatalogService) Create(ctx context.Context, input CreateRequest) (Service, error) {
return s.repo.Create(ctx, input)
}
func (s *CatalogService) Update(ctx context.Context, serviceID uuid.UUID, input UpdateRequest) (Service, error) {
return s.repo.Update(ctx, serviceID, input)
}
func (s *CatalogService) Delete(ctx context.Context, serviceID uuid.UUID) error {
return s.repo.Delete(ctx, serviceID)
}
func (s *CatalogService) ByIDs(ctx context.Context, ids []uuid.UUID) ([]Service, error) {
return s.repo.ByIDs(ctx, ids)
}

View File

@@ -0,0 +1,34 @@
package servicecatalog
import "github.com/google/uuid"
type Service struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Domain string `json:"domain"`
UpstreamIP string `json:"upstream_ip"`
ProxyIP string `json:"proxy_ip"`
Ports []int `json:"ports"`
IsActive bool `json:"is_active"`
}
type CreateRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Domain string `json:"domain"`
UpstreamIP string `json:"upstream_ip"`
ProxyIP string `json:"proxy_ip"`
Ports []int `json:"ports"`
IsActive *bool `json:"is_active,omitempty"`
}
type UpdateRequest struct {
Name *string `json:"name"`
Description *string `json:"description"`
Domain *string `json:"domain"`
UpstreamIP *string `json:"upstream_ip"`
ProxyIP *string `json:"proxy_ip"`
Ports []int `json:"ports"`
IsActive *bool `json:"is_active"`
}

View File

@@ -1,12 +1,21 @@
package wireguard
type Peer struct {
DeviceID string `json:"device_id"`
PublicKey string `json:"public_key"`
AssignedIP string `json:"assigned_ip"`
AllowedDestinations []string `json:"allowed_destinations"`
DNSServers []string `json:"dns_servers"`
WebProxyTargets []string `json:"web_proxy_targets"`
DeviceID string `json:"device_id"`
PublicKey string `json:"public_key"`
AssignedIP string `json:"assigned_ip"`
AllowedDestinations []string `json:"allowed_destinations"`
DNSServers []string `json:"dns_servers"`
AllowedServices []AllowedService `json:"allowed_services"`
}
type AllowedService struct {
Name string `json:"name"`
Domain string `json:"domain"`
UpstreamIP string `json:"upstream_ip"`
ProxyIP string `json:"proxy_ip"`
AccessProxyIP string `json:"access_proxy_ip"`
Ports []int `json:"ports"`
}
type GatewayBundle struct {