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:
78
backend/internal/servicecatalog/handler.go
Normal file
78
backend/internal/servicecatalog/handler.go
Normal 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})
|
||||
}
|
||||
152
backend/internal/servicecatalog/repository.go
Normal file
152
backend/internal/servicecatalog/repository.go
Normal 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
|
||||
}
|
||||
35
backend/internal/servicecatalog/service.go
Normal file
35
backend/internal/servicecatalog/service.go
Normal 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)
|
||||
}
|
||||
34
backend/internal/servicecatalog/types.go
Normal file
34
backend/internal/servicecatalog/types.go
Normal 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"`
|
||||
}
|
||||
Reference in New Issue
Block a user