diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9882838..dc3bc0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: version: 1.66.0 - name: Generate protobuf bindings run: >- - buf generate buf.build/agynio/api --include-imports + buf generate "https://github.com/agynio/api.git#branch=noa/ziti-debug-state,subdir=proto" --include-imports --path agynio/api/expose/v1 --path agynio/api/runner/v1 --path agynio/api/ziti_management/v1 diff --git a/Dockerfile b/Dockerfile index d328ca6..08a1bae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ go mod download COPY buf.gen.yaml buf.yaml ./ -RUN buf generate buf.build/agynio/api --include-imports \ +RUN buf generate "https://github.com/agynio/api.git#branch=noa/ziti-debug-state,subdir=proto" --include-imports \ --path agynio/api/expose/v1 \ --path agynio/api/ziti_management/v1 \ --path agynio/api/runners/v1 \ diff --git a/charts/expose/values.yaml b/charts/expose/values.yaml index c66cb99..394c82e 100644 --- a/charts/expose/values.yaml +++ b/charts/expose/values.yaml @@ -61,17 +61,26 @@ service: port: 50051 targetPort: grpc protocol: TCP + - name: http + port: 8080 + targetPort: http + protocol: TCP containerPorts: - name: grpc containerPort: 50051 protocol: TCP + - name: http + containerPort: 8080 + protocol: TCP command: [] args: [] env: - name: GRPC_ADDRESS value: ":50051" + - name: HTTP_ADDRESS + value: ":8080" - name: DATABASE_URL value: "" - name: ZITI_MANAGEMENT_ADDRESS @@ -84,6 +93,10 @@ env: value: "authorization:50051" - name: RECONCILIATION_INTERVAL value: "30s" + - name: EXPOSE_DEBUG_ENDPOINTS + value: "0" + - name: EXPOSE_DEBUG_TOKEN + value: "" envFrom: [] extraEnvVars: [] extraEnvVarsCM: "" diff --git a/cmd/expose-service/main.go b/cmd/expose-service/main.go index 5449816..b028ba8 100644 --- a/cmd/expose-service/main.go +++ b/cmd/expose-service/main.go @@ -6,6 +6,7 @@ import ( "fmt" "log" "net" + "net/http" "os" "os/signal" "syscall" @@ -87,6 +88,22 @@ func run() error { grpcServer := grpc.NewServer() exposev1.RegisterExposeServiceServer(grpcServer, server.New(storeClient, zitiClient, runnersClient, authorizationClient)) + var httpServer *http.Server + if cfg.DebugEndpointsEnabled { + debugLis, err := net.Listen("tcp", cfg.HTTPAddress) + if err != nil { + return fmt.Errorf("listen for debug http on %s: %w", cfg.HTTPAddress, err) + } + httpServer = &http.Server{ + Handler: server.NewDebugHTTPServer(storeClient, zitiClient, cfg.DebugToken).Handler(), + } + go func() { + if err := httpServer.Serve(debugLis); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Printf("debug http server error: %v", err) + } + }() + } + lis, err := net.Listen("tcp", cfg.GRPCAddress) if err != nil { return fmt.Errorf("listen on %s: %w", cfg.GRPCAddress, err) @@ -95,11 +112,19 @@ func run() error { go func() { <-ctx.Done() grpcServer.GracefulStop() + if httpServer != nil { + if err := httpServer.Shutdown(context.Background()); err != nil { + log.Printf("debug http server shutdown error: %v", err) + } + } }() go reconciler.New(storeClient, zitiClient, runnersClient, notificationsClient, cfg.ReconciliationInterval).Run(ctx) log.Printf("ExposeService listening on %s", cfg.GRPCAddress) + if cfg.DebugEndpointsEnabled { + log.Printf("Expose debug HTTP listening on %s", cfg.HTTPAddress) + } if err := grpcServer.Serve(lis); err != nil { if errors.Is(err, grpc.ErrServerStopped) { diff --git a/internal/config/config.go b/internal/config/config.go index b570d65..9c9f2a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,12 +8,15 @@ import ( type Config struct { GRPCAddress string + HTTPAddress string DatabaseURL string ZitiManagementAddress string RunnersAddress string NotificationsAddress string AuthorizationAddress string ReconciliationInterval time.Duration + DebugEndpointsEnabled bool + DebugToken string } func FromEnv() (Config, error) { @@ -22,6 +25,10 @@ func FromEnv() (Config, error) { if cfg.GRPCAddress == "" { cfg.GRPCAddress = ":50051" } + cfg.HTTPAddress = os.Getenv("HTTP_ADDRESS") + if cfg.HTTPAddress == "" { + cfg.HTTPAddress = ":8080" + } cfg.DatabaseURL = os.Getenv("DATABASE_URL") if cfg.DatabaseURL == "" { return Config{}, fmt.Errorf("DATABASE_URL must be set") @@ -47,9 +54,19 @@ func FromEnv() (Config, error) { return Config{}, err } cfg.ReconciliationInterval = interval + cfg.DebugEndpointsEnabled = boolFromEnv("EXPOSE_DEBUG_ENDPOINTS") + cfg.DebugToken = os.Getenv("EXPOSE_DEBUG_TOKEN") + if cfg.DebugEndpointsEnabled && cfg.DebugToken == "" { + return Config{}, fmt.Errorf("EXPOSE_DEBUG_TOKEN must be set when EXPOSE_DEBUG_ENDPOINTS is enabled") + } return cfg, nil } +func boolFromEnv(key string) bool { + value := os.Getenv(key) + return value == "1" || value == "true" || value == "TRUE" || value == "yes" || value == "YES" +} + func durationFromEnv(key string, defaultValue time.Duration) (time.Duration, error) { value := os.Getenv(key) if value == "" { diff --git a/internal/reconciler/reconciler_test.go b/internal/reconciler/reconciler_test.go index e43a259..e9a1b20 100644 --- a/internal/reconciler/reconciler_test.go +++ b/internal/reconciler/reconciler_test.go @@ -132,6 +132,10 @@ func (m *mockZitiMgmt) DeleteService(ctx context.Context, req *zitimanagementv1. return m.deleteSvc(ctx, req) } +func (m *mockZitiMgmt) DebugServiceState(context.Context, *zitimanagementv1.DebugServiceStateRequest, ...grpc.CallOption) (*zitimanagementv1.DebugServiceStateResponse, error) { + return nil, status.Error(codes.Unimplemented, "not implemented") +} + func (m *mockZitiMgmt) CreateDeviceIdentity(context.Context, *zitimanagementv1.CreateDeviceIdentityRequest, ...grpc.CallOption) (*zitimanagementv1.CreateDeviceIdentityResponse, error) { return nil, status.Error(codes.Unimplemented, "not implemented") } diff --git a/internal/server/debug.go b/internal/server/debug.go new file mode 100644 index 0000000..57e0793 --- /dev/null +++ b/internal/server/debug.go @@ -0,0 +1,271 @@ +package server + +import ( + "context" + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "strings" + + zitimanagementv1 "github.com/agynio/expose/.gen/go/agynio/api/ziti_management/v1" + "github.com/agynio/expose/internal/store" + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + debugExposurePathPrefix = "/debug/ziti/exposures/" + debugTokenHeader = "X-Expose-Debug-Token" +) + +type DebugHTTPServer struct { + store ExposureStore + zitiMgmt zitimanagementv1.ZitiManagementServiceClient + token string +} + +type DebugExposureState struct { + ExposureID string `json:"exposure_id"` + ServiceName string `json:"service_name"` + ZitiServiceID string `json:"ziti_service_id"` + ZitiBindPolicyID string `json:"ziti_bind_policy_id"` + ZitiDialPolicyID string `json:"ziti_dial_policy_id"` + ZitiService DebugZitiService `json:"ziti_service"` + Configs []DebugConfig `json:"configs"` + BindPolicy *DebugServicePolicy `json:"bind_policy,omitempty"` + DialPolicy *DebugServicePolicy `json:"dial_policy,omitempty"` + OtherPolicies []DebugServicePolicy `json:"other_policies,omitempty"` + Terminators []DebugTerminator `json:"terminators"` +} + +type DebugZitiService struct { + ID string `json:"id"` + Name string `json:"name"` + RoleAttributes []string `json:"role_attributes"` +} + +type DebugConfig struct { + ID string `json:"id"` + Name string `json:"name"` + ConfigTypeID string `json:"config_type_id"` + ConfigTypeName string `json:"config_type_name"` + Data json.RawMessage `json:"data"` +} + +type DebugServicePolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + IdentityRoles []string `json:"identity_roles"` + ServiceRoles []string `json:"service_roles"` +} + +type DebugTerminator struct { + ID string `json:"id"` + Identity string `json:"identity"` + RouterID string `json:"router_id"` + RouterName string `json:"router_name"` + Precedence string `json:"precedence"` + Cost int32 `json:"cost"` + DynamicCost int32 `json:"dynamic_cost"` + Binding string `json:"binding"` + Address string `json:"address"` +} + +func NewDebugHTTPServer(store ExposureStore, zitiMgmt zitimanagementv1.ZitiManagementServiceClient, token string) *DebugHTTPServer { + return &DebugHTTPServer{store: store, zitiMgmt: zitiMgmt, token: token} +} + +func (s *DebugHTTPServer) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc(debugExposurePathPrefix, s.handleExposure) + return mux +} + +func (s *DebugHTTPServer) handleExposure(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeDebugError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + if !s.authorized(r.Header.Get(debugTokenHeader)) { + writeDebugError(w, http.StatusUnauthorized, "unauthorized") + return + } + exposureID, err := parseDebugExposureID(r.URL.Path) + if err != nil { + writeDebugError(w, http.StatusBadRequest, err.Error()) + return + } + state, err := s.debugExposureState(r.Context(), exposureID) + if err != nil { + writeDebugStatusError(w, err) + return + } + writeDebugJSON(w, http.StatusOK, state) +} + +func (s *DebugHTTPServer) authorized(value string) bool { + return subtle.ConstantTimeCompare([]byte(value), []byte(s.token)) == 1 +} + +func (s *DebugHTTPServer) debugExposureState(ctx context.Context, exposureID uuid.UUID) (DebugExposureState, error) { + exposure, err := s.store.GetExposure(ctx, exposureID) + if err != nil { + return DebugExposureState{}, toStatusError(err) + } + serviceName := exposureServiceName(exposure.ID) + resp, err := s.zitiMgmt.DebugServiceState(ctx, debugServiceStateRequest(exposure.OpenZitiServiceID, serviceName)) + if err != nil { + return DebugExposureState{}, err + } + return toDebugExposureState(exposure, serviceName, resp) +} + +func debugServiceStateRequest(serviceID, serviceName string) *zitimanagementv1.DebugServiceStateRequest { + if strings.TrimSpace(serviceID) != "" { + return &zitimanagementv1.DebugServiceStateRequest{ + ServiceIdentifier: &zitimanagementv1.DebugServiceStateRequest_ZitiServiceId{ZitiServiceId: serviceID}, + } + } + return &zitimanagementv1.DebugServiceStateRequest{ + ServiceIdentifier: &zitimanagementv1.DebugServiceStateRequest_ZitiServiceName{ZitiServiceName: serviceName}, + } +} + +func parseDebugExposureID(path string) (uuid.UUID, error) { + value := strings.TrimPrefix(path, debugExposurePathPrefix) + if value == "" || strings.Contains(value, "/") { + return uuid.UUID{}, errors.New("invalid exposure id") + } + id, err := uuid.Parse(value) + if err != nil { + return uuid.UUID{}, errors.New("invalid exposure id") + } + return id, nil +} + +func toDebugExposureState(exposure store.Exposure, serviceName string, resp *zitimanagementv1.DebugServiceStateResponse) (DebugExposureState, error) { + configs, err := toDebugConfigs(resp.GetConfigs()) + if err != nil { + return DebugExposureState{}, err + } + state := DebugExposureState{ + ExposureID: exposure.ID.String(), + ServiceName: serviceName, + ZitiServiceID: resp.GetZitiServiceId(), + ZitiBindPolicyID: exposure.OpenZitiBindPolicyID, + ZitiDialPolicyID: exposure.OpenZitiDialPolicyID, + ZitiService: DebugZitiService{ + ID: resp.GetZitiServiceId(), + Name: resp.GetZitiServiceName(), + RoleAttributes: append([]string(nil), resp.GetRoleAttributes()...), + }, + Configs: configs, + Terminators: toDebugTerminators(resp.GetTerminators()), + } + state.BindPolicy, state.DialPolicy, state.OtherPolicies = splitDebugPolicies( + resp.GetServicePolicies(), + exposure.OpenZitiBindPolicyID, + exposure.OpenZitiDialPolicyID, + ) + return state, nil +} + +func toDebugConfigs(configs []*zitimanagementv1.DebugConfig) ([]DebugConfig, error) { + items := make([]DebugConfig, len(configs)) + for i, config := range configs { + data := json.RawMessage(config.GetJson()) + if len(data) == 0 { + data = json.RawMessage("null") + } + if !json.Valid(data) { + return nil, status.Error(codes.Internal, "ziti config debug json is invalid") + } + items[i] = DebugConfig{ + ID: config.GetId(), + Name: config.GetName(), + ConfigTypeID: config.GetConfigTypeId(), + ConfigTypeName: config.GetConfigTypeName(), + Data: data, + } + } + return items, nil +} + +func splitDebugPolicies(policies []*zitimanagementv1.DebugServicePolicy, bindID, dialID string) (*DebugServicePolicy, *DebugServicePolicy, []DebugServicePolicy) { + var bindPolicy *DebugServicePolicy + var dialPolicy *DebugServicePolicy + otherPolicies := make([]DebugServicePolicy, 0) + for _, policy := range policies { + converted := toDebugPolicy(policy) + switch policy.GetId() { + case bindID: + bindPolicy = &converted + case dialID: + dialPolicy = &converted + default: + otherPolicies = append(otherPolicies, converted) + } + } + return bindPolicy, dialPolicy, otherPolicies +} + +func toDebugPolicy(policy *zitimanagementv1.DebugServicePolicy) DebugServicePolicy { + return DebugServicePolicy{ + ID: policy.GetId(), + Name: policy.GetName(), + Type: policy.GetType(), + IdentityRoles: append([]string(nil), policy.GetIdentityRoles()...), + ServiceRoles: append([]string(nil), policy.GetServiceRoles()...), + } +} + +func toDebugTerminators(terminators []*zitimanagementv1.DebugTerminator) []DebugTerminator { + items := make([]DebugTerminator, len(terminators)) + for i, terminator := range terminators { + items[i] = DebugTerminator{ + ID: terminator.GetId(), + Identity: terminator.GetIdentity(), + RouterID: terminator.GetRouterId(), + RouterName: terminator.GetRouterName(), + Precedence: terminator.GetPrecedence(), + Cost: terminator.GetCost(), + DynamicCost: terminator.GetDynamicCost(), + Binding: terminator.GetBinding(), + Address: terminator.GetAddress(), + } + } + return items +} + +func exposureServiceName(exposureID uuid.UUID) string { + return "exposed-" + exposureID.String() +} + +func writeDebugStatusError(w http.ResponseWriter, err error) { + code := status.Code(err) + switch code { + case codes.InvalidArgument: + writeDebugError(w, http.StatusBadRequest, status.Convert(err).Message()) + case codes.NotFound: + writeDebugError(w, http.StatusNotFound, status.Convert(err).Message()) + case codes.PermissionDenied, codes.Unauthenticated: + writeDebugError(w, http.StatusForbidden, status.Convert(err).Message()) + default: + writeDebugError(w, http.StatusInternalServerError, status.Convert(err).Message()) + } +} + +func writeDebugError(w http.ResponseWriter, statusCode int, message string) { + writeDebugJSON(w, statusCode, map[string]string{"error": message}) +} + +func writeDebugJSON(w http.ResponseWriter, statusCode int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if err := json.NewEncoder(w).Encode(value); err != nil { + panic(err) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index bf97091..84bee02 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -113,7 +113,7 @@ func (s *Server) AddExposure(ctx context.Context, req *exposev1.AddExposureReque return nil, toStatusError(err) } - serviceName := fmt.Sprintf("exposed-%s", exposureID) + serviceName := exposureServiceName(exposureID) interceptAddress := fmt.Sprintf("%s.ziti", serviceName) url := fmt.Sprintf("http://%s:%d", interceptAddress, port) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index cfebc17..a65ae19 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -2,8 +2,11 @@ package server import ( "context" + "encoding/json" "errors" "fmt" + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -94,6 +97,7 @@ type mockZitiMgmt struct { createServicePolicy func(ctx context.Context, req *zitimanagementv1.CreateServicePolicyRequest) (*zitimanagementv1.CreateServicePolicyResponse, error) deleteServicePolicy func(ctx context.Context, req *zitimanagementv1.DeleteServicePolicyRequest) (*zitimanagementv1.DeleteServicePolicyResponse, error) deleteService func(ctx context.Context, req *zitimanagementv1.DeleteServiceRequest) (*zitimanagementv1.DeleteServiceResponse, error) + debugServiceState func(ctx context.Context, req *zitimanagementv1.DebugServiceStateRequest) (*zitimanagementv1.DebugServiceStateResponse, error) } func (m *mockZitiMgmt) CreateAgentIdentity(context.Context, *zitimanagementv1.CreateAgentIdentityRequest, ...grpc.CallOption) (*zitimanagementv1.CreateAgentIdentityResponse, error) { @@ -164,6 +168,13 @@ func (m *mockZitiMgmt) DeleteService(ctx context.Context, req *zitimanagementv1. return m.deleteService(ctx, req) } +func (m *mockZitiMgmt) DebugServiceState(ctx context.Context, req *zitimanagementv1.DebugServiceStateRequest, _ ...grpc.CallOption) (*zitimanagementv1.DebugServiceStateResponse, error) { + if m.debugServiceState == nil { + return nil, errors.New("not implemented") + } + return m.debugServiceState(ctx, req) +} + func (m *mockZitiMgmt) CreateDeviceIdentity(context.Context, *zitimanagementv1.CreateDeviceIdentityRequest, ...grpc.CallOption) (*zitimanagementv1.CreateDeviceIdentityResponse, error) { return nil, status.Error(codes.Unimplemented, "not implemented") } @@ -1189,3 +1200,107 @@ func TestListExposuresStoreError(t *testing.T) { t.Fatalf("expected internal error, got %v", err) } } + +func TestDebugExposureEndpointReturnsZitiState(t *testing.T) { + exposureID := uuid.New() + storeMock := &mockStore{ + getExposure: func(_ context.Context, id uuid.UUID) (store.Exposure, error) { + if id != exposureID { + return store.Exposure{}, fmt.Errorf("unexpected exposure id %s", id) + } + return store.Exposure{ + ID: exposureID, + OpenZitiServiceID: "svc-id", + OpenZitiBindPolicyID: "bind-id", + OpenZitiDialPolicyID: "dial-id", + }, nil + }, + } + zitiMock := &mockZitiMgmt{ + debugServiceState: func(_ context.Context, req *zitimanagementv1.DebugServiceStateRequest) (*zitimanagementv1.DebugServiceStateResponse, error) { + if _, ok := req.GetServiceIdentifier().(*zitimanagementv1.DebugServiceStateRequest_ZitiServiceId); !ok { + t.Fatalf("expected service id identifier, got %T", req.GetServiceIdentifier()) + } + if req.GetZitiServiceId() != "svc-id" { + t.Fatalf("expected service id svc-id, got %s", req.GetZitiServiceId()) + } + serviceName := "exposed-" + exposureID.String() + if req.GetZitiServiceName() != "" { + t.Fatalf("expected empty service name with id identifier, got %s", req.GetZitiServiceName()) + } + return &zitimanagementv1.DebugServiceStateResponse{ + ZitiServiceId: "svc-id", + ZitiServiceName: serviceName, + RoleAttributes: []string{"exposed-services"}, + Configs: []*zitimanagementv1.DebugConfig{{ + Id: "cfg-id", + Name: "svc-host-v1", + ConfigTypeName: "host.v1", + Json: `{"address":"127.0.0.1","port":3000}`, + }}, + ServicePolicies: []*zitimanagementv1.DebugServicePolicy{{ + Id: "bind-id", + Name: "bind", + Type: "Bind", + IdentityRoles: []string{"#workload-id"}, + ServiceRoles: []string{"@svc-id"}, + }, { + Id: "dial-id", + Name: "dial", + Type: "Dial", + IdentityRoles: []string{"#all"}, + ServiceRoles: []string{"@svc-id"}, + }}, + Terminators: []*zitimanagementv1.DebugTerminator{{ + Id: "terminator-id", + Identity: "identity-id", + RouterName: "router", + Precedence: "default", + Cost: 10, + }}, + }, nil + }, + } + + debugServer := NewDebugHTTPServer(storeMock, zitiMock, "secret") + req := httptest.NewRequest(http.MethodGet, "/debug/ziti/exposures/"+exposureID.String(), nil) + req.Header.Set(debugTokenHeader, "secret") + recorder := httptest.NewRecorder() + + debugServer.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", recorder.Code, recorder.Body.String()) + } + var payload DebugExposureState + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.ExposureID != exposureID.String() { + t.Fatalf("unexpected exposure id %s", payload.ExposureID) + } + if payload.BindPolicy == nil || payload.BindPolicy.ID != "bind-id" { + t.Fatalf("expected bind policy, got %#v", payload.BindPolicy) + } + if payload.DialPolicy == nil || payload.DialPolicy.ID != "dial-id" { + t.Fatalf("expected dial policy, got %#v", payload.DialPolicy) + } + if len(payload.Configs) != 1 || string(payload.Configs[0].Data) != `{"address":"127.0.0.1","port":3000}` { + t.Fatalf("unexpected configs %#v", payload.Configs) + } + if len(payload.Terminators) != 1 || payload.Terminators[0].ID != "terminator-id" { + t.Fatalf("unexpected terminators %#v", payload.Terminators) + } +} + +func TestDebugExposureEndpointRequiresToken(t *testing.T) { + debugServer := NewDebugHTTPServer(&mockStore{}, &mockZitiMgmt{}, "secret") + req := httptest.NewRequest(http.MethodGet, "/debug/ziti/exposures/"+uuid.New().String(), nil) + recorder := httptest.NewRecorder() + + debugServer.Handler().ServeHTTP(recorder, req) + + if recorder.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", recorder.Code) + } +}