mirror of
https://github.com/Control-D-Inc/ctrld.git
synced 2026-09-04 13:36:35 +02:00
processCDFlags retries the resolver-config fetch indefinitely by design: a device that has no working network at boot must eventually come up. The loop had no cancellation, so a stop request arriving while the API is unreachable was ignored - the process kept retrying long after the service reported itself stopped, doing work on behalf of a service the OS considers stopped. Thread a context through processCDFlags and derive it from p.stopCh, in both the startup preflight and the config-reload path. The loop now returns as soon as the context is cancelled, checked both before a retry and after backoff returns (backoff can wake up on cancellation). A stop during preflight exits the way a normal stop does, without Fatal, so the service manager does not treat it as a failed start and apply its restart policy to a service the operator just asked to stop. Bind the two API requests themselves as well, so a stop does not have to wait out an in-flight request. Without this the loop honours a stop only between attempts, which leaves up to defaultTimeout (20s) of a request the service is no longer interested in - the same "still working after Service stopped" the loop change exists to end, one layer down. Doing so means a context parameter on FetchResolverConfig, FetchResolverUID, UpdateCustomLastFailed and SendLogs, since all four reach a request builder. The callers that have no context pass context.Background(), which is what master effectively does at those sites: its loggerCtx carries a logger, not cancellation. doWithFallback needs no parameter, because it clones the request with req.Context() and so inherits the binding. This also repairs internal/controld/controld_test.go, which is behind //go:build controld and had already been written against the context-taking signature, so it could not compile. Cover the cancellation paths; removing either check makes the tests hang until timeout. Sampling the stop state is the whole point of runAPIPreflight rather than doing this inline. A stop and a failure need opposite handling - one exits quietly, the other self-uninstalls a deleted device, surfaces the error to a mobile app, and reports a failed start - so the two must not be confused. Reading it from the context after cancelling would report "stopped" for every failure, since CancelFunc sets ctx.Err() regardless of whether anyone asked to stop; the stop channel is read directly instead, which also does not depend on the context watcher goroutine having been scheduled.
539 lines
14 KiB
Go
539 lines
14 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)
|
|
}
|
|
|
|
type controlServer struct {
|
|
server *http.Server
|
|
mux *http.ServeMux
|
|
addr string
|
|
}
|
|
|
|
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) {
|
|
mainLog.Load().Debug().Msg("handling list clients request")
|
|
|
|
clients := p.ciTable.ListClients()
|
|
mainLog.Load().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)
|
|
})
|
|
mainLog.Load().Debug().Msg("sorted clients by IP address")
|
|
|
|
if p.metricsQueryStats.Load() {
|
|
mainLog.Load().Debug().Msg("metrics query stats enabled, collecting query counts")
|
|
|
|
for idx, client := range clients {
|
|
mainLog.Load().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 {
|
|
mainLog.Load().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 {
|
|
mainLog.Load().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())
|
|
mainLog.Load().Debug().
|
|
Str("client_ip", client.IP.String()).
|
|
Int64("query_count", client.QueryCount).
|
|
Msg("successfully collected query count")
|
|
} else if err != nil {
|
|
mainLog.Load().Debug().
|
|
Err(err).
|
|
Str("client_ip", client.IP.String()).
|
|
Msg("failed to write metric")
|
|
}
|
|
}
|
|
} else {
|
|
mainLog.Load().Debug().Msg("metrics query stats disabled, skipping query counts")
|
|
}
|
|
|
|
if err := json.NewEncoder(w).Encode(&clients); err != nil {
|
|
mainLog.Load().Error().
|
|
Err(err).
|
|
Int("client_count", len(clients)).
|
|
Msg("failed to encode clients response")
|
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
mainLog.Load().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 {
|
|
mainLog.Load().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
|
|
}
|
|
|
|
// 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: rootCmd.Version,
|
|
Metadata: ctrld.SystemMetadataRuntime(context.Background()),
|
|
}
|
|
if rc, err := controld.FetchResolverConfig(context.Background(), rcReq, cdDev); rc != nil {
|
|
if rc.DeactivationPin != nil {
|
|
cdDeactivationPin.Store(*rc.DeactivationPin)
|
|
} else {
|
|
cdDeactivationPin.Store(defaultDeactivationPin)
|
|
}
|
|
} else {
|
|
mainLog.Load().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)
|
|
mainLog.Load().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.logReader()
|
|
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.logReader()
|
|
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,
|
|
}
|
|
mainLog.Load().Debug().Msg("sending log file to ControlD server")
|
|
resp := logSentResponse{Size: r.size}
|
|
if err := controld.SendLogs(context.Background(), req, cdDev); err != nil {
|
|
mainLog.Load().Error().Msgf("could not send log file to ControlD server: %v", err)
|
|
resp.Error = err.Error()
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
} else {
|
|
mainLog.Load().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
|
|
}
|
|
|
|
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)
|
|
})
|
|
}
|