mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
Firewall Mode only permits what ctrld resolved, so an approved service addressed by literal IP - with no DNS lookup to observe - is unreachable, and the only workaround was turning the mode off. The API now sends the effective per-org list in destination_ips of every resolver-config response. Apply it as a set rather than as additions: each refresh replaces the previous snapshot, so an entry added upstream takes effect and one removed upstream stops bypassing enforcement. This happens inside the refresh handler before its early returns, so scheduled and forced refreshes both carry it, and without a ctrld reload. Entries carry no TTL and survive the allowlist flushes that follow a profile or network change. Track what the API asked for separately from what pf/WFP accepted, because mirroring can fail and the next refresh - carrying an identical list - would compute no delta to retry. The applied snapshot advances only on success, and the difference is retried by the next refresh and by a reconcile every 5 minutes, reported meanwhile as allowed_destinations_pending. Enforcement coming up replaces the whole set rather than adding to it: the macOS table is a persist table that can still hold what a previous run put there. Enforcement is versioned by a generation advanced under the same lock the mirror is called with, so a maintenance worker outliving its run cannot reinstall permits into enforcement that is gone. macOS keeps the set in a second pf table, <ctrld_allowed_dst>; Windows in per-entry WFP permit filters in their own map - apart from the DNS-resolved entries so a flush of those leaves them installed. Linux is unchanged, the mode already fails open there, and devices with Firewall Mode off are unaffected. Lookups binary search sorted per-family address ranges, so the per-connection hot path stays flat at ~40ns rather than growing with the list. Addresses are logged at debug level only: the list is organization network topology, and Info-level logs are persisted and travel in support bundles. Indirect the refresh's fetch and split its handler out of the fetch loop so both refresh paths are driven end to end in tests without an API server.
555 lines
15 KiB
Go
555 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/kardianos/service"
|
|
dto "github.com/prometheus/client_model/go"
|
|
|
|
"github.com/Control-D-Inc/ctrld"
|
|
"github.com/Control-D-Inc/ctrld/internal/controld"
|
|
)
|
|
|
|
const (
|
|
contentTypeJson = "application/json"
|
|
listClientsPath = "/clients"
|
|
startedPath = "/started"
|
|
reloadPath = "/reload"
|
|
deactivationPath = "/deactivation"
|
|
cdPath = "/cd"
|
|
ifacePath = "/iface"
|
|
viewLogsPath = "/log/view"
|
|
sendLogsPath = "/log/send"
|
|
tailLogsPath = "/log/tail"
|
|
)
|
|
|
|
type ifaceResponse struct {
|
|
Name string `json:"name"`
|
|
All bool `json:"all"`
|
|
OK bool `json:"ok"`
|
|
InterceptMode string `json:"intercept_mode,omitempty"` // "dns", "hard", or "" (not intercepting)
|
|
}
|
|
|
|
// controlServer represents an HTTP server for handling control requests
|
|
type controlServer struct {
|
|
server *http.Server
|
|
mux *http.ServeMux
|
|
addr string
|
|
}
|
|
|
|
// newControlServer creates a new control server instance
|
|
func newControlServer(addr string) (*controlServer, error) {
|
|
mux := http.NewServeMux()
|
|
s := &controlServer{
|
|
server: &http.Server{Handler: mux},
|
|
mux: mux,
|
|
}
|
|
s.addr = addr
|
|
return s, nil
|
|
}
|
|
|
|
func (s *controlServer) start() error {
|
|
_ = os.Remove(s.addr)
|
|
unixListener, err := net.Listen("unix", s.addr)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Restrict socket permissions to owner-only (0600) so that only the
|
|
// process owner (typically root) can connect. Defense-in-depth since
|
|
// the control server endpoints carry no authentication of their own.
|
|
if err := os.Chmod(s.addr, 0600); err != nil {
|
|
return err
|
|
}
|
|
if l, ok := unixListener.(*net.UnixListener); ok {
|
|
l.SetUnlinkOnClose(true)
|
|
}
|
|
go s.server.Serve(unixListener)
|
|
return nil
|
|
}
|
|
|
|
func (s *controlServer) stop() error {
|
|
_ = os.Remove(s.addr)
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
|
|
defer cancel()
|
|
return s.server.Shutdown(ctx)
|
|
}
|
|
|
|
func (s *controlServer) register(pattern string, handler http.Handler) {
|
|
s.mux.Handle(pattern, jsonResponse(handler))
|
|
}
|
|
|
|
func (p *prog) registerControlServerHandler() {
|
|
p.cs.register(listClientsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
p.Debug().Msg("Handling list clients request")
|
|
|
|
clients := p.ciTable.ListClients()
|
|
p.Debug().Int("client_count", len(clients)).Msg("Retrieved clients list")
|
|
|
|
sort.Slice(clients, func(i, j int) bool {
|
|
return clients[i].IP.Less(clients[j].IP)
|
|
})
|
|
p.Debug().Msg("Sorted clients by IP address")
|
|
|
|
if p.metricsQueryStats.Load() {
|
|
p.Debug().Msg("Metrics query stats enabled, collecting query counts")
|
|
|
|
for idx, client := range clients {
|
|
p.Debug().
|
|
Int("index", idx).
|
|
Str("ip", client.IP.String()).
|
|
Str("mac", client.Mac).
|
|
Str("hostname", client.Hostname).
|
|
Msg("Processing client metrics")
|
|
|
|
client.IncludeQueryCount = true
|
|
dm := &dto.Metric{}
|
|
|
|
if statsClientQueriesCount.MetricVec == nil {
|
|
p.Debug().
|
|
Str("client_ip", client.IP.String()).
|
|
Msg("Skipping metrics collection: MetricVec is nil")
|
|
continue
|
|
}
|
|
|
|
m, err := statsClientQueriesCount.MetricVec.GetMetricWithLabelValues(
|
|
client.IP.String(),
|
|
client.Mac,
|
|
client.Hostname,
|
|
)
|
|
if err != nil {
|
|
p.Debug().
|
|
Err(err).
|
|
Str("client_ip", client.IP.String()).
|
|
Str("mac", client.Mac).
|
|
Str("hostname", client.Hostname).
|
|
Msg("Failed to get metrics for client")
|
|
continue
|
|
}
|
|
|
|
if err := m.Write(dm); err == nil && dm.Counter != nil {
|
|
client.QueryCount = int64(dm.Counter.GetValue())
|
|
p.Debug().
|
|
Str("client_ip", client.IP.String()).
|
|
Int64("query_count", client.QueryCount).
|
|
Msg("Successfully collected query count")
|
|
} else if err != nil {
|
|
p.Debug().
|
|
Err(err).
|
|
Str("client_ip", client.IP.String()).
|
|
Msg("Failed to write metric")
|
|
}
|
|
}
|
|
} else {
|
|
p.Debug().Msg("Metrics query stats disabled, skipping query counts")
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
|
p.Error().
|
|
Err(err).
|
|
Int("client_count", len(clients)).
|
|
Msg("Failed to encode clients response")
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
p.Debug().
|
|
Int("client_count", len(clients)).
|
|
Msg("Successfully sent clients list response")
|
|
}))
|
|
p.cs.register(startedPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
select {
|
|
case <-p.onStartedDone:
|
|
w.WriteHeader(http.StatusOK)
|
|
case <-time.After(10 * time.Second):
|
|
w.WriteHeader(http.StatusRequestTimeout)
|
|
}
|
|
}))
|
|
p.cs.register(reloadPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
listeners := make(map[string]*ctrld.ListenerConfig)
|
|
p.mu.Lock()
|
|
for k, v := range p.cfg.Listener {
|
|
listeners[k] = &ctrld.ListenerConfig{
|
|
IP: v.IP,
|
|
Port: v.Port,
|
|
}
|
|
}
|
|
oldSvc := p.cfg.Service
|
|
p.mu.Unlock()
|
|
if err := p.sendReloadSignal(); err != nil {
|
|
p.Error().Err(err).Msg("Could not send reload signal")
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
select {
|
|
case <-p.reloadDoneCh:
|
|
case <-time.After(5 * time.Second):
|
|
http.Error(w, "Timeout waiting for ctrld reload", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
|
|
// Checking for cases that we could not do a reload.
|
|
|
|
// 1. Listener config ip or port changes.
|
|
for k, v := range p.cfg.Listener {
|
|
l := listeners[k]
|
|
if l == nil || l.IP != v.IP || l.Port != v.Port {
|
|
w.WriteHeader(http.StatusCreated)
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Service config changes.
|
|
if !reflect.DeepEqual(oldSvc, p.cfg.Service) {
|
|
w.WriteHeader(http.StatusCreated)
|
|
return
|
|
}
|
|
|
|
// Otherwise, reload is done.
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
p.cs.register(deactivationPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
// Non-cd mode always allowing deactivation.
|
|
if cdUID == "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
|
|
|
// Reject further attempts while locked out due to repeated wrong PINs.
|
|
if now := time.Now().Unix(); now < deactivationLockedUntil.Load() {
|
|
w.WriteHeader(http.StatusTooManyRequests)
|
|
return
|
|
}
|
|
|
|
// Re-fetch pin code from API.
|
|
rcReq := &controld.ResolverConfigRequest{
|
|
RawUID: cdUID,
|
|
Version: appVersion,
|
|
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
|
}
|
|
if rc, err := controld.FetchResolverConfig(loggerCtx, rcReq, cdDev); rc != nil {
|
|
if rc.DeactivationPin != nil {
|
|
cdDeactivationPin.Store(*rc.DeactivationPin)
|
|
} else {
|
|
cdDeactivationPin.Store(defaultDeactivationPin)
|
|
}
|
|
// Every resolver-config response carries the organization's allowed
|
|
// destinations, including this one, so apply them rather than discarding
|
|
// a fresher list until the next scheduled refresh converges.
|
|
//
|
|
// Only the destinations: p.rc is deliberately left alone. The scheduled
|
|
// refresh decides whether to reload ctrld by comparing the response
|
|
// against p.rc, so storing this one here would let an exclude-list change
|
|
// be compared away and never reloaded. The destination set needs no
|
|
// reload - it is enforced directly and applying it is idempotent.
|
|
p.applyAllowedDestinations(p.firewallAllowList(), rc.DestinationIPs)
|
|
} else {
|
|
p.Warn().Err(err).Msg("Could not re-fetch deactivation pin code")
|
|
}
|
|
|
|
// If pin code not set, allowing deactivation.
|
|
if !deactivationPinSet() {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
|
|
var req deactivationRequest
|
|
if err := json.NewDecoder(request.Body).Decode(&req); err != nil {
|
|
w.WriteHeader(http.StatusPreconditionFailed)
|
|
p.Error().Err(err).Msg("Invalid deactivation request")
|
|
return
|
|
}
|
|
|
|
code := http.StatusForbidden
|
|
switch req.Pin {
|
|
case cdDeactivationPin.Load():
|
|
code = http.StatusOK
|
|
deactivationFailedAttempts.Store(0)
|
|
select {
|
|
case p.pinCodeValidCh <- struct{}{}:
|
|
default:
|
|
}
|
|
case defaultDeactivationPin:
|
|
// If the pin code was set, but users do not provide --pin, return proper code to client.
|
|
code = http.StatusBadRequest
|
|
default:
|
|
if deactivationFailedAttempts.Add(1) >= deactivationMaxFailedAttempts {
|
|
deactivationLockedUntil.Store(time.Now().Unix() + deactivationLockoutSeconds)
|
|
deactivationFailedAttempts.Store(0)
|
|
}
|
|
}
|
|
w.WriteHeader(code)
|
|
}))
|
|
p.cs.register(cdPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
if cdUID != "" {
|
|
w.WriteHeader(http.StatusOK)
|
|
w.Write([]byte(cdUID))
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusBadRequest)
|
|
}))
|
|
p.cs.register(ifacePath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
res := &ifaceResponse{Name: iface}
|
|
// p.setDNS is only called when running as a service
|
|
if !service.Interactive() {
|
|
<-p.csSetDnsDone
|
|
if p.csSetDnsOk {
|
|
res.Name = p.runningIface
|
|
res.All = p.requiredMultiNICsConfig
|
|
res.OK = true
|
|
// Report intercept mode to the start command for proper log output.
|
|
if interceptMode == "dns" || interceptMode == "hard" {
|
|
res.InterceptMode = interceptMode
|
|
}
|
|
}
|
|
}
|
|
if err := json.NewEncoder(w).Encode(res); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
http.Error(w, fmt.Sprintf("could not marshal iface data: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}))
|
|
p.cs.register(viewLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
lr, err := p.logReaderRaw()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
defer lr.r.Close()
|
|
if lr.size == 0 {
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
data, err := io.ReadAll(lr.r)
|
|
if err != nil {
|
|
http.Error(w, fmt.Sprintf("could not read log: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
if err := json.NewEncoder(w).Encode(&logViewResponse{Data: string(data)}); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
http.Error(w, fmt.Sprintf("could not marshal log data: %v", err), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
}))
|
|
p.cs.register(sendLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
if time.Since(p.internalLogSent) < logWriterSentInterval {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
r, err := p.logReaderNoColor()
|
|
if err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
if r.size == 0 {
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
req := &controld.LogsRequest{
|
|
UID: cdUID,
|
|
Data: r.r,
|
|
}
|
|
p.Debug().Msg("Sending log file to ControlD server")
|
|
resp := logSentResponse{Size: r.size}
|
|
loggerCtx := ctrld.LoggerCtx(context.Background(), p.logger.Load())
|
|
if err := controld.SendLogs(loggerCtx, req, cdDev); err != nil {
|
|
p.Error().Msgf("Could not send log file to ControlD server: %v", err)
|
|
resp.Error = err.Error()
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
} else {
|
|
p.Debug().Msg("Sending log file successfully")
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
if err := json.NewEncoder(w).Encode(&resp); err != nil {
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
}
|
|
p.internalLogSent = time.Now()
|
|
}))
|
|
p.cs.register(tailLogsPath, http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// Determine logging mode and validate before starting the stream.
|
|
var lw *logWriter
|
|
useInternalLog := p.needInternalLogging()
|
|
if useInternalLog {
|
|
p.mu.Lock()
|
|
lw = p.internalLogWriter
|
|
p.mu.Unlock()
|
|
if lw == nil {
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
} else if p.cfg.Service.LogPath == "" {
|
|
// No logging configured at all.
|
|
w.WriteHeader(http.StatusMovedPermanently)
|
|
return
|
|
}
|
|
|
|
// Parse optional "lines" query param for initial context.
|
|
numLines := 10
|
|
if v := request.URL.Query().Get("lines"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
|
|
numLines = n
|
|
}
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
w.Header().Set("Transfer-Encoding", "chunked")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
if useInternalLog {
|
|
// Internal logging mode: subscribe to the logWriter.
|
|
|
|
// Send last N lines as initial context.
|
|
if numLines > 0 {
|
|
if tail := lw.tailLastLines(numLines); len(tail) > 0 {
|
|
w.Write(tail)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
|
|
ch, unsub := lw.Subscribe()
|
|
defer unsub()
|
|
for {
|
|
select {
|
|
case data, ok := <-ch:
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
case <-request.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
} else {
|
|
// File-based logging mode: tail the log file.
|
|
logFile := normalizeLogFilePath(p.cfg.Service.LogPath)
|
|
f, err := os.Open(logFile)
|
|
if err != nil {
|
|
// Already committed 200, just return.
|
|
return
|
|
}
|
|
defer f.Close()
|
|
|
|
// Seek to show last N lines.
|
|
if numLines > 0 {
|
|
if tail := tailFileLastLines(f, numLines); len(tail) > 0 {
|
|
w.Write(tail)
|
|
flusher.Flush()
|
|
}
|
|
} else {
|
|
// Seek to end.
|
|
f.Seek(0, io.SeekEnd)
|
|
}
|
|
|
|
// Poll for new data.
|
|
buf := make([]byte, 4096)
|
|
ticker := time.NewTicker(200 * time.Millisecond)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
n, err := f.Read(buf)
|
|
if n > 0 {
|
|
if _, werr := w.Write(buf[:n]); werr != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
}
|
|
if err != nil && err != io.EOF {
|
|
return
|
|
}
|
|
case <-request.Context().Done():
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}))
|
|
}
|
|
|
|
// tailFileLastLines reads the last n lines from a file and returns them.
|
|
// The file position is left at the end of the file after this call.
|
|
func tailFileLastLines(f *os.File, n int) []byte {
|
|
stat, err := f.Stat()
|
|
if err != nil || stat.Size() == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Read from the end in chunks to find the last n lines.
|
|
const chunkSize = 4096
|
|
fileSize := stat.Size()
|
|
var lines []byte
|
|
offset := fileSize
|
|
count := 0
|
|
|
|
for offset > 0 && count <= n {
|
|
readSize := int64(chunkSize)
|
|
if readSize > offset {
|
|
readSize = offset
|
|
}
|
|
offset -= readSize
|
|
buf := make([]byte, readSize)
|
|
nRead, err := f.ReadAt(buf, offset)
|
|
if err != nil && err != io.EOF {
|
|
break
|
|
}
|
|
buf = buf[:nRead]
|
|
lines = append(buf, lines...)
|
|
|
|
// Count newlines in this chunk.
|
|
for _, b := range buf {
|
|
if b == '\n' {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
// Trim to last n lines.
|
|
idx := 0
|
|
nlCount := 0
|
|
for i := len(lines) - 1; i >= 0; i-- {
|
|
if lines[i] == '\n' {
|
|
nlCount++
|
|
if nlCount == n+1 {
|
|
idx = i + 1
|
|
break
|
|
}
|
|
}
|
|
}
|
|
lines = lines[idx:]
|
|
|
|
// Seek to end of file for subsequent reads.
|
|
f.Seek(0, io.SeekEnd)
|
|
return lines
|
|
}
|
|
|
|
// jsonResponse wraps an HTTP handler to set JSON content type
|
|
func jsonResponse(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|