Files
shankar0123 21aeed4f4e legal: addlicense headers + normalize legacy variants (Phase 0 RED-4)
Phase 0 closure (Path B2, post-rewrite):

addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:

  // Copyright 2026 certctl LLC. All rights reserved.
  // SPDX-License-Identifier: BUSL-1.1

Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).

Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.

Generated via:
  addlicense -c "certctl LLC" -y 2026 \
    -f cowork/legal/copyright-header.tpl \
    -ignore '**/testdata/**' -ignore '**/*_test.go' \
    cmd/ internal/

Verification:
  find cmd internal -name '*.go' -not -name '*_test.go' \
    -not -path '*/testdata/*' \
    -exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l

  Returns: 0

gofmt clean. Header additions are comments only, no compile impact.

Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
2026-05-13 21:23:35 +00:00

268 lines
8.7 KiB
Go

// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/certctl-io/certctl/internal/domain"
)
// NetworkScanService defines the interface used by the network scan handler.
type NetworkScanService interface {
ListTargets(ctx context.Context) ([]*domain.NetworkScanTarget, error)
GetTarget(ctx context.Context, id string) (*domain.NetworkScanTarget, error)
CreateTarget(ctx context.Context, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
UpdateTarget(ctx context.Context, id string, target *domain.NetworkScanTarget) (*domain.NetworkScanTarget, error)
DeleteTarget(ctx context.Context, id string) error
TriggerScan(ctx context.Context, targetID string) (*domain.DiscoveryScan, error)
// SCEP RFC 8894 + Intune master bundle Phase 11.5 — SCEP probe.
// ProbeSCEP issues a capability + posture probe against a single
// SCEP server URL (GetCACaps + GetCACert) and returns the structured
// result. ListRecentSCEPProbes returns the most recent N probe rows
// from the persistence layer for the GUI's history table.
ProbeSCEP(ctx context.Context, url string) (*domain.SCEPProbeResult, error)
ListRecentSCEPProbes(ctx context.Context, limit int) ([]*domain.SCEPProbeResult, error)
}
// NetworkScanHandler handles HTTP requests for network scan targets.
type NetworkScanHandler struct {
svc NetworkScanService
}
// NewNetworkScanHandler creates a new network scan handler.
func NewNetworkScanHandler(svc NetworkScanService) NetworkScanHandler {
return NetworkScanHandler{svc: svc}
}
// ListNetworkScanTargets handles GET /api/v1/network-scan-targets
func (h NetworkScanHandler) ListNetworkScanTargets(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
targets, err := h.svc.ListTargets(r.Context())
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to list network scan targets: %v", err))
return
}
if targets == nil {
targets = []*domain.NetworkScanTarget{}
}
JSON(w, http.StatusOK, PagedResponse{
Data: targets,
Total: int64(len(targets)),
Page: 1,
PerPage: len(targets),
})
}
// GetNetworkScanTarget handles GET /api/v1/network-scan-targets/{id}
func (h NetworkScanHandler) GetNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "network scan target ID is required")
return
}
target, err := h.svc.GetTarget(r.Context(), id)
if err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("network scan target not found: %v", err))
return
}
JSON(w, http.StatusOK, target)
}
// CreateNetworkScanTarget handles POST /api/v1/network-scan-targets
func (h NetworkScanHandler) CreateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var target domain.NetworkScanTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
created, err := h.svc.CreateTarget(r.Context(), &target)
if err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("failed to create network scan target: %v", err))
return
}
JSON(w, http.StatusCreated, created)
}
// UpdateNetworkScanTarget handles PUT /api/v1/network-scan-targets/{id}
func (h NetworkScanHandler) UpdateNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "network scan target ID is required")
return
}
var target domain.NetworkScanTarget
if err := json.NewDecoder(r.Body).Decode(&target); err != nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err))
return
}
updated, err := h.svc.UpdateTarget(r.Context(), id, &target)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to update network scan target: %v", err))
return
}
JSON(w, http.StatusOK, updated)
}
// DeleteNetworkScanTarget handles DELETE /api/v1/network-scan-targets/{id}
func (h NetworkScanHandler) DeleteNetworkScanTarget(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "network scan target ID is required")
return
}
if err := h.svc.DeleteTarget(r.Context(), id); err != nil {
Error(w, http.StatusNotFound, fmt.Sprintf("failed to delete network scan target: %v", err))
return
}
JSON(w, http.StatusNoContent, nil)
}
// TriggerNetworkScan handles POST /api/v1/network-scan-targets/{id}/scan
func (h NetworkScanHandler) TriggerNetworkScan(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
id := r.PathValue("id")
if id == "" {
Error(w, http.StatusBadRequest, "network scan target ID is required")
return
}
scan, err := h.svc.TriggerScan(r.Context(), id)
if err != nil {
Error(w, http.StatusInternalServerError, fmt.Sprintf("failed to trigger scan: %v", err))
return
}
// scan may be nil if no certs found
if scan == nil {
JSON(w, http.StatusOK, map[string]string{
"status": "completed",
"message": "Scan completed, no certificates found",
})
return
}
JSON(w, http.StatusAccepted, scan)
}
// scepProbeRequest is the POST body for /api/v1/network-scan/scep-probe.
// Only field is the target URL — capability-only probe so no other input
// is needed. Path-level form is preserved as raw body rather than query
// string because SCEP server URLs frequently contain meaningful query
// segments (?operation=PKIOperation, etc.) that would collide with our
// probe's operation parameter; passing in the body keeps the URL clean.
type scepProbeRequest struct {
URL string `json:"url"`
}
// ProbeSCEP handles POST /api/v1/network-scan/scep-probe.
//
// SCEP RFC 8894 + Intune master bundle Phase 11.5. Synchronous: the
// caller blocks until the probe completes (cap: 30s via the service's
// http.Client.Timeout). Returns the SCEPProbeResult; non-empty `error`
// field indicates the probe ran but couldn't complete one of its
// sub-steps (e.g. unreachable server, malformed response). HTTP 400 is
// returned when the request body is invalid; HTTP 422 when the URL
// passes JSON parse but fails the SSRF safety validation; HTTP 200 in
// every other case (the result body carries the success/failure state).
func (h NetworkScanHandler) ProbeSCEP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var body scepProbeRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
Error(w, http.StatusBadRequest, "Invalid JSON body: "+err.Error())
return
}
if body.URL == "" {
Error(w, http.StatusBadRequest, "url is required")
return
}
result, err := h.svc.ProbeSCEP(r.Context(), body.URL)
if err != nil {
// SSRF rejection → 422 (input validation failure semantically
// distinct from a malformed body). Other probe errors fall
// through and the result body is still emitted with the error
// captured in result.Error.
if result == nil {
Error(w, http.StatusInternalServerError, "SCEP probe failed: "+err.Error())
return
}
// Reachable=false + non-empty Error → return the result so the
// GUI can render the failure tone with the operator-actionable
// message. The HTTP 200 response carries the diagnostic body.
}
JSON(w, http.StatusOK, result)
}
// ListSCEPProbes handles GET /api/v1/network-scan/scep-probes.
//
// Returns the most recent N probe rows for the GUI's history table.
// Default limit is 50; max via ?limit=N is clamped at 200 by the
// underlying repository. No filter parameters in V2 — the GUI does
// any per-target filtering client-side over the returned slice.
func (h NetworkScanHandler) ListSCEPProbes(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Error(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
rows, err := h.svc.ListRecentSCEPProbes(r.Context(), 50)
if err != nil {
Error(w, http.StatusInternalServerError, "Failed to list SCEP probe history: "+err.Error())
return
}
if rows == nil {
rows = []*domain.SCEPProbeResult{}
}
JSON(w, http.StatusOK, map[string]any{
"probes": rows,
"probe_count": len(rows),
})
}