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

@@ -0,0 +1,10 @@
FROM golang:1.23-alpine AS builder
WORKDIR /src
COPY access-proxy/ ./
RUN go build -o /out/nexavpn-access-proxy ./main.go
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=builder /out/nexavpn-access-proxy /usr/local/bin/nexavpn-access-proxy
ENTRYPOINT ["nexavpn-access-proxy"]

View File

@@ -0,0 +1,3 @@
module nexavpn/access-proxy
go 1.23

328
deploy/access-proxy/main.go Normal file
View File

@@ -0,0 +1,328 @@
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"io"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
type syncBundle struct {
Peers []peerConfig `json:"peers"`
}
type peerConfig struct {
AssignedIP string `json:"assigned_ip"`
AllowedServices []allowedService `json:"allowed_services"`
}
type allowedService struct {
Name string `json:"name"`
Domain string `json:"domain"`
ProxyIP string `json:"proxy_ip"`
AccessProxyIP string `json:"access_proxy_ip"`
}
type proxyState struct {
mu sync.RWMutex
allowed map[string]map[string]allowedService
}
func main() {
state := &proxyState{
allowed: make(map[string]map[string]allowedService),
}
ctx := context.Background()
if err := refreshConfig(ctx, state); err != nil {
log.Printf("initial config refresh failed: %v", err)
}
go func() {
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := refreshConfig(ctx, state); err != nil {
log.Printf("config refresh failed: %v", err)
}
}
}()
httpAddr := envOrDefault("NEXAVPN_ACCESS_PROXY_HTTP_ADDR", ":8088")
httpsAddr := envOrDefault("NEXAVPN_ACCESS_PROXY_HTTPS_ADDR", ":8448")
go func() {
log.Printf("HTTP access proxy listening on %s", httpAddr)
if err := http.ListenAndServe(httpAddr, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleHTTP(state, w, r)
})); err != nil {
log.Fatalf("http server failed: %v", err)
}
}()
log.Printf("HTTPS access proxy listening on %s", httpsAddr)
if err := serveTLSProxy(httpsAddr, state); err != nil {
log.Fatalf("https proxy failed: %v", err)
}
}
func handleHTTP(state *proxyState, w http.ResponseWriter, r *http.Request) {
clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
http.Error(w, "invalid client address", http.StatusForbidden)
return
}
host := normalizeHost(r.Host)
service, ok := state.lookup(clientIP, host)
if !ok {
http.Error(w, "service not allowed", http.StatusForbidden)
return
}
targetURL := &url.URL{
Scheme: "http",
Host: net.JoinHostPort(service.ProxyIP, "80"),
}
proxy := httputil.NewSingleHostReverseProxy(targetURL)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
req.Host = host
req.Header.Set("Host", host)
req.Header.Set("X-Forwarded-Host", host)
}
proxy.ErrorHandler = func(rw http.ResponseWriter, _ *http.Request, proxyErr error) {
http.Error(rw, proxyErr.Error(), http.StatusBadGateway)
}
proxy.ServeHTTP(w, r)
}
func serveTLSProxy(addr string, state *proxyState) error {
listener, err := net.Listen("tcp", addr)
if err != nil {
return err
}
defer listener.Close()
for {
conn, err := listener.Accept()
if err != nil {
return err
}
go handleTLSConn(conn, state)
}
}
func handleTLSConn(clientConn net.Conn, state *proxyState) {
defer clientConn.Close()
clientIP, _, err := net.SplitHostPort(clientConn.RemoteAddr().String())
if err != nil {
return
}
reader := bufio.NewReader(clientConn)
hello, host, err := readClientHello(reader)
if err != nil {
return
}
service, ok := state.lookup(clientIP, host)
if !ok {
return
}
upstreamConn, err := net.DialTimeout("tcp", net.JoinHostPort(service.ProxyIP, "443"), 10*time.Second)
if err != nil {
return
}
defer upstreamConn.Close()
if _, err := upstreamConn.Write(hello); err != nil {
return
}
errCh := make(chan error, 2)
go proxyCopy(errCh, upstreamConn, reader)
go proxyCopy(errCh, clientConn, upstreamConn)
<-errCh
}
func proxyCopy(errCh chan<- error, dst io.Writer, src io.Reader) {
_, err := io.Copy(dst, src)
errCh <- err
}
func readClientHello(reader *bufio.Reader) ([]byte, string, error) {
header, err := reader.Peek(5)
if err != nil {
return nil, "", err
}
if header[0] != 22 {
return nil, "", errors.New("not a tls client hello")
}
recordLen := int(header[3])<<8 | int(header[4])
full, err := reader.Peek(5 + recordLen)
if err != nil {
return nil, "", err
}
host, err := extractSNI(full)
if err != nil {
return nil, "", err
}
return append([]byte(nil), full...), host, nil
}
func extractSNI(packet []byte) (string, error) {
if len(packet) < 43 {
return "", errors.New("tls packet too short")
}
sessionIDLen := int(packet[43])
offset := 44 + sessionIDLen
if len(packet) < offset+2 {
return "", errors.New("missing cipher suites")
}
cipherLen := int(packet[offset])<<8 | int(packet[offset+1])
offset += 2 + cipherLen
if len(packet) < offset+1 {
return "", errors.New("missing compression methods")
}
compressionLen := int(packet[offset])
offset += 1 + compressionLen
if len(packet) < offset+2 {
return "", errors.New("missing extensions")
}
extensionsLen := int(packet[offset])<<8 | int(packet[offset+1])
offset += 2
end := offset + extensionsLen
if len(packet) < end {
return "", errors.New("invalid extensions length")
}
for offset+4 <= end {
extensionType := int(packet[offset])<<8 | int(packet[offset+1])
extensionLen := int(packet[offset+2])<<8 | int(packet[offset+3])
offset += 4
if offset+extensionLen > end {
return "", errors.New("invalid extension")
}
if extensionType == 0 {
if extensionLen < 5 {
return "", errors.New("invalid sni extension")
}
serverNameLen := int(packet[offset+3])<<8 | int(packet[offset+4])
if offset+5+serverNameLen > end {
return "", errors.New("invalid sni length")
}
return normalizeHost(string(packet[offset+5 : offset+5+serverNameLen])), nil
}
offset += extensionLen
}
return "", errors.New("sni not found")
}
func refreshConfig(ctx context.Context, state *proxyState) error {
gatewayID, err := resolveGatewayID()
if err != nil {
return err
}
syncURL := strings.TrimRight(envOrDefault("NEXAVPN_GATEWAY_SYNC_URL", "http://127.0.0.1:8080/api/v1/gateway-agent"), "/") + "/" + gatewayID + "/sync"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, syncURL, nil)
if err != nil {
return err
}
req.Header.Set("X-Gateway-Bootstrap-Token", os.Getenv("GATEWAY_BOOTSTRAP_TOKEN"))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return errors.New("sync request failed with status " + resp.Status)
}
var bundle syncBundle
if err := json.NewDecoder(resp.Body).Decode(&bundle); err != nil {
return err
}
allowed := make(map[string]map[string]allowedService)
for _, peer := range bundle.Peers {
hostMap := make(map[string]allowedService)
for _, service := range peer.AllowedServices {
hostMap[normalizeHost(service.Domain)] = service
}
allowed[stripCIDR(peer.AssignedIP)] = hostMap
}
state.mu.Lock()
state.allowed = allowed
state.mu.Unlock()
return nil
}
func (s *proxyState) lookup(clientIP string, host string) (allowedService, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
services, ok := s.allowed[clientIP]
if !ok {
return allowedService{}, false
}
service, ok := services[normalizeHost(host)]
return service, ok
}
func resolveGatewayID() (string, error) {
if value := strings.TrimSpace(os.Getenv("NEXAVPN_GATEWAY_ID")); value != "" {
return value, nil
}
stateFile := envOrDefault("NEXAVPN_GATEWAY_ID_FILE", "/var/lib/nexavpn/gateway-id")
raw, err := os.ReadFile(filepath.Clean(stateFile))
if err != nil {
return "", err
}
return strings.TrimSpace(string(raw)), nil
}
func envOrDefault(key string, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func stripCIDR(value string) string {
if index := strings.IndexByte(value, '/'); index >= 0 {
return value[:index]
}
return value
}
func normalizeHost(host string) string {
host = strings.TrimSpace(strings.ToLower(host))
host = strings.TrimSuffix(host, ".")
if strings.Contains(host, ":") {
if parsedHost, _, err := net.SplitHostPort(host); err == nil {
host = parsedHost
}
}
return host
}