Skip to content

Commit

Permalink
⭐️ serve mode for cnspec (#290)
Browse files Browse the repository at this point in the history
This change depends on mondoohq/cnquery#786 and
mondoohq/cnquery#787

**Problem**

As a user, I want to run cnspec continuously and report the results into
Mondoo Platform.

**Solution**

With this new change, `cnspec` has a new `serve` subcommand that runs
scans in the background. The package also comes with Linux service
configuration for SysV and Systemd.

```bash
cnspec serve
→ loaded configuration from /Users/chris/.config/mondoo/mondoo.yml using source default
→ start cnspec client=//agents.api.mondoo.app/spaces/saha-saha-123/agents/1zDY7auR20SgrFfiGUT5qZWx6mE service_account=//agents.api.mondoo.app/spaces/saha-saha-123/serviceaccounts/1zDY7cJ7bA84JxxNBWDxBdui2xE space=//captain.api.mondoo.app/spaces/saha-saha-123 version=v7.12.1
→ using service account credentials
→ scan local operating system
→ start cnspec background service
→ scan interval is 60 minute(s)
→ discover related assets for 1 asset(s)
→ resolved assets resolved-assets=1
→ connecting to asset spacerocket.fritz.box (baremetal)
→ scan for asset spacerocket.fritz.box (baremetal) completed
^C→ stop service gracefully
→ stop worker
→ bye bye space cowboy
```

If you want to enable `cnspec` as a service on Linux run the following
commands:

```
systemctl enable cnspec.service
systemctl start cnspec.service
systemctl daemon-reload
```
  • Loading branch information
chris-rock authored Jan 17, 2023
1 parent e110510 commit 6ea2f69
Show file tree
Hide file tree
Showing 20 changed files with 673 additions and 10 deletions.
42 changes: 42 additions & 0 deletions .goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,48 @@ nfpms:
formats:
- deb
- rpm
contents:
- src: "scripts/pkg/config/mondoo.yml"
dst: "/etc/opt/mondoo/mondoo.yml.example"
type: config
file_info:
mode: 0644
- src: "scripts/pkg/linux/cnspec.service"
dst: "/etc/systemd/system/cnspec.service"
type: config
file_info:
mode: 0644
- src: "scripts/pkg/linux/cnspec.conf"
dst: "/etc/init/cnspec.conf"
type: config
file_info:
mode: 0644
scripts:
preinstall: "scripts/pkg/linux/preinstall.sh"
postinstall: "scripts/pkg/linux/postinstall.sh"
preremove: "scripts/pkg/linux/preremove.sh"
overrides:
deb:
contents:
- src: "scripts/pkg/config/mondoo.yml"
dst: "/etc/opt/mondoo/mondoo.yml.example"
type: config
file_info:
mode: 0644
- src: "scripts/pkg/debian/cnspec.service"
dst: "/etc/systemd/system/cnspec.service"
type: config
file_info:
mode: 0644
- src: "scripts/pkg/debian/cnspec.conf"
dst: "/etc/init/cnspec.conf"
type: config
file_info:
mode: 0644
scripts:
preinstall: "scripts/pkg/debian/preinstall.sh"
postinstall: "scripts/pkg/debian/postinstall.sh"
preremove: "scripts/pkg/debian/preremove.sh"
rpm:
signature:
key_file: '{{ .Env.GPG_KEY_PATH }}'
Expand Down
25 changes: 25 additions & 0 deletions apps/cnspec/cmd/backgroundjob/background.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package backgroundjob

import (
"time"

"github.com/spf13/viper"
)

const (
// Service Name
SvcName = "cnspec" // NOTE: this name needs to align with the service name in packages
)

type JobRunner func() error

func New() (*BackgroundScanner, error) {
return &BackgroundScanner{}, nil
}

type BackgroundScanner struct{}

func (bs *BackgroundScanner) Run(runScanFn JobRunner) error {
Serve(time.Duration(viper.GetInt64("timer"))*time.Minute, runScanFn)
return nil
}
60 changes: 60 additions & 0 deletions apps/cnspec/cmd/backgroundjob/healthping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package backgroundjob

import (
"context"
"sync"
"time"

"github.com/rs/zerolog/log"
"go.mondoo.com/cnquery/upstream/health"
)

type healthPinger struct {
ctx context.Context
interval time.Duration
quit chan struct{}
wg sync.WaitGroup
endpoint string
}

func NewHealthPinger(ctx context.Context, endpoint string, interval time.Duration) *healthPinger {
return &healthPinger{
ctx: ctx,
interval: interval,
quit: make(chan struct{}),
endpoint: endpoint,
}
}

func (h *healthPinger) Start() {
h.wg.Add(1)
runHealthCheck := func() {
_, err := health.CheckApiHealth(h.endpoint)
if err != nil {
log.Info().Err(err).Msg("could not perform health check")
}
}

// run health check once on startup
runHealthCheck()

// TODO we may want to add jitter and backoff
healthTicker := time.NewTicker(h.interval)
go func() {
defer h.wg.Done()
for {
select {
case <-healthTicker.C:
runHealthCheck()
case <-h.quit:
healthTicker.Stop()
return
}
}
}()
}

func (h *healthPinger) Stop() {
close(h.quit)
h.wg.Wait()
}
65 changes: 65 additions & 0 deletions apps/cnspec/cmd/backgroundjob/serve_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//go:build linux || darwin || netbsd || openbsd || freebsd
// +build linux darwin netbsd openbsd freebsd

package backgroundjob

import (
"os"
"os/signal"
"sync"
"syscall"
"time"

"github.com/rs/zerolog/log"
)

func Serve(timer time.Duration, handler JobRunner) {
log.Info().Msg("start cnspec background service")
log.Info().Msgf("scan interval is %d minute(s)", int(timer.Minutes()))

quitChannel := make(chan os.Signal)
signal.Notify(quitChannel, syscall.SIGINT, syscall.SIGTERM)

shutdownChannel := make(chan struct{})
waitGroup := &sync.WaitGroup{}

initTick := time.Tick(1 * time.Second)
defaultTick := time.Tick(timer)
tick := initTick
waitGroup.Add(1)

go func(shutdownChannel chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
// Give shutdown priority
select {
case <-shutdownChannel:
log.Info().Msg("stop worker")
return
default:
}

select {
case <-tick:
if tick == initTick {
tick = defaultTick
}
err := handler()
if err != nil {
log.Error().Err(err).Send()
}
case <-shutdownChannel:
log.Info().Msg("stop worker")
return
}
}
}(shutdownChannel, waitGroup)

<-quitChannel // received SIGINT or SIGTERM
close(shutdownChannel)

log.Info().Msg("stop service gracefully")

waitGroup.Wait() // wait for all goroutines
log.Info().Msg("bye bye space cowboy")
}
114 changes: 114 additions & 0 deletions apps/cnspec/cmd/backgroundjob/serve_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
//go:build windows
// +build windows

package backgroundjob

import (
"time"

"github.com/rs/zerolog/log"
"go.mondoo.com/cnquery/logger/eventlog"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/debug"
)

func Serve(timer time.Duration, handler JobRunner) {
isIntSess, err := svc.IsAnInteractiveSession()
if err != nil {
log.Fatal().Err(err).Msg("failed to determine if we are running in an interactive session")
}
// if it is an service ...
if !isIntSess {
// set windows eventlogger
w, err := eventlog.NewEventlogWriter(SvcName)
if err != nil {
log.Fatal().Err(err).Msg("failed to connect to windows event log")
}
log.Logger = log.Output(w)

// run service
runService(false, timer, handler)
return
}
runService(true, timer, handler)
}

type windowsService struct {
Timer time.Duration
Handler JobRunner
}

// NOTE: we do not support svc.AcceptPauseAndContinue yet, we may reconsider this later
func (m *windowsService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (ssec bool, errno uint32) {
const cmdsAccepted = svc.AcceptStop | svc.AcceptShutdown
changes <- svc.Status{State: svc.StartPending}
initTick := time.Tick(1 * time.Second)
defaulttick := time.Tick(m.Timer)
tick := initTick
log.Info().Msg("schedule background scan")
changes <- svc.Status{State: svc.Running, Accepts: cmdsAccepted}

runChan := make(chan struct{})
go func() {
// This goroutine doesn't stop cleanly.
// This isn't great, but we cannot block the service event loop.
// It would be good to make sure this shuts down cleanly, but
// that's not possible right now and requires wiring through
// context throughout the execution.
for range runChan {
log.Info().Msg("starting background scan")
err := m.Handler()
if err != nil {
log.Error().Err(err).Send()
} else {
log.Info().Msg("scan completed")
}
}
}()
loop:
for {
select {
case <-tick:
if tick == initTick {
tick = defaulttick
}
select {
case runChan <- struct{}{}:
default:
log.Error().Msg("scan not started. may be stuck")
}
case c := <-r:
switch c.Cmd {
case svc.Interrogate:
changes <- c.CurrentStatus
case svc.Stop, svc.Shutdown:
log.Info().Msg("stopping cnspec service")
break loop
default:
log.Error().Msgf("unexpected control request #%d", c)
}
}
}
close(runChan)
changes <- svc.Status{State: svc.StopPending}
return
}

func runService(isDebug bool, timer time.Duration, handler JobRunner) {
var err error

log.Info().Msgf("starting %s service", SvcName)
run := svc.Run
if isDebug {
run = debug.Run
}
err = run(SvcName, &windowsService{
Handler: handler,
Timer: timer,
})
if err != nil {
log.Info().Msgf("%s service failed: %v", SvcName, err)
return
}
log.Info().Msgf("%s service stopped", SvcName)
}
26 changes: 17 additions & 9 deletions apps/cnspec/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -386,9 +386,15 @@ func getPoliciesForCompletion() []string {
}

type scanConfig struct {
Features cnquery.Features
Inventory *v1.Inventory
Output string
Features cnquery.Features
Inventory *v1.Inventory

// report type, indicates if the service how much data needs to be collected
ReportType scan.ReportType

// output format for the rendering
Output string

PolicyPaths []string
PolicyNames []string
Bundle *policy.Bundle
Expand Down Expand Up @@ -524,10 +530,12 @@ func (c *scanConfig) loadPolicies() error {
return nil
}

func RunScan(config *scanConfig) (*policy.ReportCollection, error) {
opts := []scan.ScannerOption{}
func RunScan(config *scanConfig, opts ...scan.ScannerOption) (*policy.ReportCollection, error) {
scannerOpts := []scan.ScannerOption{}
scannerOpts = append(scannerOpts, opts...)

if config.UpstreamConfig != nil {
opts = append(opts, scan.WithUpstream(config.UpstreamConfig.ApiEndpoint, config.UpstreamConfig.SpaceMrn), scan.WithPlugins(config.UpstreamConfig.Plugins))
scannerOpts = append(scannerOpts, scan.WithUpstream(config.UpstreamConfig.ApiEndpoint, config.UpstreamConfig.SpaceMrn), scan.WithPlugins(config.UpstreamConfig.Plugins))
}

// show warning to the user of the policy filter container a bundle file name
Expand All @@ -538,7 +546,7 @@ func RunScan(config *scanConfig) (*policy.ReportCollection, error) {
}
}

scanner := scan.NewLocalScanner(opts...)
scanner := scan.NewLocalScanner(scannerOpts...)
ctx := cnquery.SetFeatures(context.Background(), config.Features)

if config.IsIncognito {
Expand All @@ -549,7 +557,7 @@ func RunScan(config *scanConfig) (*policy.ReportCollection, error) {
Inventory: config.Inventory,
Bundle: config.Bundle,
PolicyFilters: config.PolicyNames,
ReportType: scan.ReportType_FULL,
ReportType: config.ReportType,
})
if err != nil {
return nil, err
Expand All @@ -564,7 +572,7 @@ func RunScan(config *scanConfig) (*policy.ReportCollection, error) {
Inventory: config.Inventory,
Bundle: config.Bundle,
PolicyFilters: config.PolicyNames,
ReportType: scan.ReportType_FULL,
ReportType: config.ReportType,
})
if err != nil {
return nil, err
Expand Down
Loading

0 comments on commit 6ea2f69

Please sign in to comment.