its been a while
This commit is contained in:
1 parent
ea7470231f
commit
34f13c65e1
47 files changed
+5680
-339
No files matched your search
@@ -0,0 +1,35 @@
|
||||
# Logging Package Options
|
||||
|
||||
This agent already uses a library approach for journald (`github.com/coreos/go-systemd/v22/sdjournal`) and does not shell out to `journalctl` in normal collection paths.
|
||||
|
||||
## Current (keep)
|
||||
|
||||
- `github.com/coreos/go-systemd/v22/sdjournal`
|
||||
- Direct journal access from Go.
|
||||
- Cursor-based reads map well to incremental shipping.
|
||||
- `github.com/coreos/go-systemd/v22/dbus` (candidate add-on)
|
||||
- Read systemd unit state/metadata (`ListUnitsByNames`, unit properties) for richer service context.
|
||||
|
||||
## Good external collectors (if we want hardened third-party gathering)
|
||||
|
||||
- Fluent Bit
|
||||
- `systemd` input plugin for journald, plus mature filtering/parsing/routing outputs.
|
||||
- Strong choice when we want a small footprint daemon dedicated to log forwarding.
|
||||
- Vector
|
||||
- `journald` source + transforms (VRL) for compact line formatting before shipping.
|
||||
- Good if we want expressive local transforms and multiple downstream sinks.
|
||||
- OpenTelemetry Collector distributions with journald receiver support (for example Splunk OTel distro)
|
||||
- Useful if we want logs + metrics + traces through one pipeline.
|
||||
|
||||
## Parser packages (when we need deeper line parsing)
|
||||
|
||||
- `github.com/influxdata/go-syslog/v3`
|
||||
- RFC3164/RFC5424 syslog parsing for file or stream logs.
|
||||
- `github.com/elastic/go-grok`
|
||||
- Pattern-based parsing when message formats vary by service.
|
||||
|
||||
## Practical direction
|
||||
|
||||
1. Keep `go-systemd` for now and add optional `dbus` lookups for service status enrichment.
|
||||
2. If we need host-agnostic, independently hardened collection, run Fluent Bit or Vector and ingest normalized JSON lines in the agent/server.
|
||||
3. Reserve heavier parsing packages (`go-syslog`, `go-grok`) for specific source formats that require structured extraction beyond current compact line ingestion.
|
||||
@@ -16,12 +16,28 @@ go build -o keywarden-agent ./cmd/keywarden-agent
|
||||
./keywarden-agent -config /etc/keywarden/agent.json -server-url https://keywarden.example.com -enroll-token <token>
|
||||
```
|
||||
|
||||
To rotate/re-enroll an existing agent identity, add `-force-enroll` with a fresh token.
|
||||
|
||||
You can also pass `KEYWARDEN_SERVER_URL` and `KEYWARDEN_ENROLL_TOKEN` as environment variables.
|
||||
|
||||
## Config
|
||||
|
||||
On first boot, the agent will create a config file if it does not exist. Only `server_url` is required for bootstrapping.
|
||||
Do not prefill `server_id` or `agent_api_token` with placeholders; those are written by enrollment.
|
||||
|
||||
If the Keywarden server uses a private TLS CA, set `server_ca_path` (or `KEYWARDEN_SERVER_CA_PATH`) to the CA PEM file so the agent can verify the server certificate.
|
||||
`agent_api_token` is issued by `/agent/enroll` and persisted into `agent.json` automatically.
|
||||
Use the raw token value (no `Bearer ` prefix). If `KEYWARDEN_AGENT_API_TOKEN` env var is set, it overrides `agent_api_token` in `agent.json`.
|
||||
When no `log_sources` are configured, the agent defaults to journald collection via `go-systemd/sdjournal` and backfills from the start of the current boot on first sync.
|
||||
If journald is unavailable, the agent falls back to common system log files (`/var/log/auth.log`, `/var/log/secure`, `/var/log/syslog`, `/var/log/messages`).
|
||||
|
||||
`log_sources` can be defined locally for service/file monitors. These are used as:
|
||||
- fallback if central `/agent/servers/{id}/log-config` cannot be fetched
|
||||
- additive sources when central config defines one or more explicit sources
|
||||
|
||||
If central `log-config` returns an empty list, the agent uses default journald
|
||||
collection from the current boot and ignores local `log_sources` for that run.
|
||||
|
||||
See `config.example.json`.
|
||||
|
||||
For package alternatives and external collector options, see `LOGGING_PACKAGES.md`.
|
||||
@@ -4,15 +4,20 @@ import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sort"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -23,10 +28,13 @@ import (
|
||||
"keywarden/agent/internal/version"
|
||||
)
|
||||
|
||||
var lastNoEventsNotice time.Time
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", config.DefaultConfigPath, "Path to agent config JSON")
|
||||
serverURL := flag.String("server-url", "", "Keywarden server URL (first boot)")
|
||||
enrollToken := flag.String("enroll-token", "", "Enrollment token (first boot)")
|
||||
forceEnroll := flag.Bool("force-enroll", false, "Force re-enrollment even if already bootstrapped")
|
||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
@@ -43,7 +51,7 @@ func main() {
|
||||
log.Fatalf("state dir error: %v", err)
|
||||
}
|
||||
|
||||
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken)); err != nil {
|
||||
if err := bootstrapIfNeeded(cfg, *configPath, pickEnrollToken(*enrollToken), *forceEnroll); err != nil {
|
||||
log.Fatalf("bootstrap error: %v", err)
|
||||
}
|
||||
|
||||
@@ -80,20 +88,30 @@ func runOnce(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
log.Printf("host update deferred; will retry: %v", err)
|
||||
} else {
|
||||
log.Printf("host update error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
}
|
||||
if err := apiClient.SyncAccounts(ctx, cfg); err != nil {
|
||||
log.Printf("sync accounts error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
if err := shipLogs(ctx, apiClient, cfg); err != nil {
|
||||
if client.IsRetriable(err) {
|
||||
log.Printf("log shipping deferred; will retry: %v", err)
|
||||
} else {
|
||||
log.Printf("log shipping error: %v", err)
|
||||
logAgentUnauthorizedHint(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logAgentUnauthorizedHint(err error) {
|
||||
var statusErr *client.HTTPStatusError
|
||||
if errors.As(err, &statusErr) && statusErr.StatusCode == 401 {
|
||||
log.Printf("agent auth failed (401): ensure agent_api_token matches the enrollment-issued token for this server (raw token only; no 'Bearer ' prefix)")
|
||||
}
|
||||
}
|
||||
|
||||
func ensureDirs(cfg *config.Config) error {
|
||||
if err := os.MkdirAll(cfg.StateDir, 0o700); err != nil {
|
||||
return err
|
||||
@@ -114,16 +132,56 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
return err
|
||||
}
|
||||
|
||||
cursor, err := logs.ReadCursor(cfg.LogCursorPath())
|
||||
state, err := logs.ReadState(cfg.LogStatePath())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localSources := toLogSourcesFromConfig(cfg.EnabledLogSources())
|
||||
sourceConfig, err := apiClient.FetchLogConfig(ctx, cfg.ServerID)
|
||||
var sources []logs.SourceConfig
|
||||
if err != nil {
|
||||
if len(localSources) == 0 {
|
||||
return err
|
||||
}
|
||||
log.Printf("log config fetch failed; using local log_sources fallback: %v", err)
|
||||
sources = localSources
|
||||
} else {
|
||||
sources = toLogSources(sourceConfig)
|
||||
if len(sources) > 0 && len(localSources) > 0 {
|
||||
sources = append(sources, localSources...)
|
||||
}
|
||||
}
|
||||
state, err = reconcileLogState(cfg, state, sources)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collector := logs.NewCollector()
|
||||
events, nextCursor, err := collector.Collect(ctx, cursor, cfg.LogBatchSize)
|
||||
events, nextState, err := collector.Collect(ctx, cfg.LogBatchSize, sources, state)
|
||||
if err != nil {
|
||||
return err
|
||||
if len(sources) > 0 {
|
||||
return err
|
||||
}
|
||||
fallbackSources := defaultFileFallbackSources()
|
||||
log.Printf("default journald collection failed; falling back to file monitors: %v", err)
|
||||
events, nextState, err = collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(events) == 0 && len(sources) == 0 {
|
||||
fallbackSources := defaultFileFallbackSources()
|
||||
log.Printf("default journald yielded no events; trying file monitors")
|
||||
fileEvents, fileState, fileErr := collector.Collect(ctx, cfg.LogBatchSize, fallbackSources, nextState)
|
||||
if fileErr != nil {
|
||||
return fileErr
|
||||
}
|
||||
events = fileEvents
|
||||
nextState = fileState
|
||||
}
|
||||
if len(events) == 0 {
|
||||
if maybeLogNoEventsNotice(len(sources)) && len(sources) == 0 {
|
||||
log.Printf("log shipping file fallback probe: %s", describeFileSourceAvailability(defaultFileFallbackSources()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
payload, err := json.Marshal(events)
|
||||
@@ -136,7 +194,7 @@ func shipLogs(ctx context.Context, apiClient *client.Client, cfg *config.Config)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := logs.WriteCursor(cfg.LogCursorPath(), nextCursor); err != nil {
|
||||
if err := logs.WriteState(cfg.LogStatePath(), nextState); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@@ -172,13 +230,22 @@ func pickEnrollToken(flagValue string) string {
|
||||
return os.Getenv("KEYWARDEN_ENROLL_TOKEN")
|
||||
}
|
||||
|
||||
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string) error {
|
||||
if cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
||||
func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string, forceEnroll bool) error {
|
||||
previousServerID := strings.TrimSpace(cfg.ServerID)
|
||||
if !forceEnroll && cfg.ServerID != "" && fileExists(cfg.ClientCertPath()) && fileExists(cfg.CACertPath()) {
|
||||
if strings.TrimSpace(enrollToken) != "" {
|
||||
log.Printf("enrollment token provided but bootstrap is already complete; skipping enrollment (use -force-enroll to rotate identity)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if enrollToken == "" {
|
||||
return fmt.Errorf("missing enrollment token; set KEYWARDEN_ENROLL_TOKEN or -enroll-token")
|
||||
}
|
||||
if forceEnroll {
|
||||
log.Printf("force re-enrollment requested; replacing existing server identity")
|
||||
cfg.ServerID = ""
|
||||
cfg.AgentAPIToken = ""
|
||||
}
|
||||
keyPath := cfg.ClientKeyPath()
|
||||
if !fileExists(keyPath) {
|
||||
if err := generateKey(keyPath); err != nil {
|
||||
@@ -208,12 +275,156 @@ func bootstrapIfNeeded(cfg *config.Config, configPath string, enrollToken string
|
||||
return err
|
||||
}
|
||||
cfg.ServerID = resp.ServerID
|
||||
if token := strings.TrimSpace(resp.AgentToken); token != "" {
|
||||
cfg.AgentAPIToken = token
|
||||
}
|
||||
if forceEnroll || (previousServerID != "" && previousServerID != cfg.ServerID) {
|
||||
if err := resetLogState(cfg); err != nil {
|
||||
return fmt.Errorf("reset log state: %w", err)
|
||||
}
|
||||
}
|
||||
if err := config.Save(configPath, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetLogState(cfg *config.Config) error {
|
||||
if err := os.Remove(cfg.LogCursorPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(cfg.LogOffsetsPath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(cfg.LogStatePath()); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(logSourceStatePath(cfg)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(cfg.LogSpoolDir()); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(cfg.LogSpoolDir(), 0o700)
|
||||
}
|
||||
|
||||
func defaultFileFallbackSources() []logs.SourceConfig {
|
||||
return []logs.SourceConfig{
|
||||
{Kind: "file", Name: "/var/log/auth.log", FilePath: "/var/log/auth.log", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/secure", FilePath: "/var/log/secure", Category: "auth", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/syslog", FilePath: "/var/log/syslog", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||
{Kind: "file", Name: "/var/log/messages", FilePath: "/var/log/messages", Category: "system", EventType: "file.line", Parser: "syslog"},
|
||||
}
|
||||
}
|
||||
|
||||
func maybeLogNoEventsNotice(sourceCount int) bool {
|
||||
now := time.Now()
|
||||
if !lastNoEventsNotice.IsZero() && now.Sub(lastNoEventsNotice) < time.Minute {
|
||||
return false
|
||||
}
|
||||
mode := "default journald (current boot)"
|
||||
if sourceCount > 0 {
|
||||
mode = "configured sources"
|
||||
}
|
||||
log.Printf("log shipping: no events collected (%s)", mode)
|
||||
lastNoEventsNotice = now
|
||||
return true
|
||||
}
|
||||
|
||||
func describeFileSourceAvailability(sources []logs.SourceConfig) string {
|
||||
items := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
path := strings.TrimSpace(source.FilePath)
|
||||
if path == "" {
|
||||
path = strings.TrimSpace(source.Name)
|
||||
}
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
item := path + ": "
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
items = append(items, item+"missing")
|
||||
continue
|
||||
}
|
||||
items = append(items, item+err.Error())
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
items = append(items, item+"is directory")
|
||||
continue
|
||||
}
|
||||
file, openErr := os.Open(path)
|
||||
if openErr != nil {
|
||||
items = append(items, item+openErr.Error())
|
||||
continue
|
||||
}
|
||||
_ = file.Close()
|
||||
items = append(items, fmt.Sprintf("%d bytes readable", info.Size()))
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return "no file sources"
|
||||
}
|
||||
return strings.Join(items, "; ")
|
||||
}
|
||||
|
||||
func reconcileLogState(
|
||||
cfg *config.Config,
|
||||
state map[string]logs.SourceState,
|
||||
sources []logs.SourceConfig,
|
||||
) (map[string]logs.SourceState, error) {
|
||||
statePath := logSourceStatePath(cfg)
|
||||
previous, err := os.ReadFile(statePath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return state, err
|
||||
}
|
||||
previousSig := strings.TrimSpace(string(previous))
|
||||
currentSig := logSourceSignature(sources)
|
||||
needsReset := false
|
||||
if previousSig == "" {
|
||||
if len(state) > 0 {
|
||||
log.Printf("log state signature missing; resetting per-source state for fresh collection")
|
||||
needsReset = true
|
||||
}
|
||||
} else if previousSig != currentSig {
|
||||
log.Printf("log sources changed; resetting per-source state for fresh collection")
|
||||
needsReset = true
|
||||
}
|
||||
if err := os.WriteFile(statePath, []byte(currentSig+"\n"), 0o600); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if needsReset {
|
||||
return map[string]logs.SourceState{}, nil
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func logSourceStatePath(cfg *config.Config) string {
|
||||
return cfg.StateDir + "/log.sources.sha256"
|
||||
}
|
||||
|
||||
func logSourceSignature(sources []logs.SourceConfig) string {
|
||||
parts := make([]string, 0, len(sources))
|
||||
for _, source := range sources {
|
||||
parts = append(parts, strings.Join([]string{
|
||||
strings.TrimSpace(source.SourceID),
|
||||
strings.ToLower(strings.TrimSpace(source.Kind)),
|
||||
strings.TrimSpace(source.Name),
|
||||
strings.TrimSpace(source.ServiceUnit),
|
||||
strings.TrimSpace(source.FilePath),
|
||||
strings.ToLower(strings.TrimSpace(source.Parser)),
|
||||
strings.TrimSpace(source.Category),
|
||||
strings.TrimSpace(source.EventType),
|
||||
encodeMatchMap(source.IncludeMatches),
|
||||
encodeMatchMap(source.ExcludeMatches),
|
||||
}, "\x1f"))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "\x1e")))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func retry(ctx context.Context, delays []time.Duration, fn func() error) error {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= len(delays); attempt++ {
|
||||
@@ -276,3 +487,59 @@ func fileExists(path string) bool {
|
||||
}
|
||||
return !info.IsDir()
|
||||
}
|
||||
|
||||
func toLogSources(in []client.LogSourceConfig) []logs.SourceConfig {
|
||||
out := make([]logs.SourceConfig, 0, len(in))
|
||||
for _, source := range in {
|
||||
out = append(out, logs.SourceConfig{
|
||||
SourceID: source.SourceID,
|
||||
Kind: source.Kind,
|
||||
Name: source.Name,
|
||||
ServiceUnit: source.ServiceUnit,
|
||||
FilePath: source.FilePath,
|
||||
Parser: source.Parser,
|
||||
IncludeMatches: source.IncludeMatches,
|
||||
ExcludeMatches: source.ExcludeMatches,
|
||||
Category: source.Category,
|
||||
EventType: source.EventType,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toLogSourcesFromConfig(in []config.LogSource) []logs.SourceConfig {
|
||||
out := make([]logs.SourceConfig, 0, len(in))
|
||||
for _, source := range in {
|
||||
out = append(out, logs.SourceConfig{
|
||||
SourceID: source.SourceID,
|
||||
Kind: source.Kind,
|
||||
Name: source.Name,
|
||||
ServiceUnit: source.ServiceUnit,
|
||||
FilePath: source.FilePath,
|
||||
Parser: source.Parser,
|
||||
IncludeMatches: source.IncludeMatches,
|
||||
ExcludeMatches: source.ExcludeMatches,
|
||||
Category: source.Category,
|
||||
EventType: source.EventType,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodeMatchMap(in map[string][]string) string {
|
||||
if len(in) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(in))
|
||||
for key := range in {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
values := append([]string{}, in[key]...)
|
||||
sort.Strings(values)
|
||||
parts = append(parts, key+"="+strings.Join(values, ","))
|
||||
}
|
||||
return strings.Join(parts, ";")
|
||||
}
|
||||
@@ -1,9 +1,59 @@
|
||||
{
|
||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
||||
"server_id": "4",
|
||||
"server_ca_path": "",
|
||||
"server_url": "https://keywarden.example.com/api/v1",
|
||||
"server_id": "6",
|
||||
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
|
||||
"sync_interval_seconds": 5,
|
||||
"log_batch_size": 500,
|
||||
"log_batch_size": 2000,
|
||||
"log_sources": [
|
||||
{
|
||||
"source_id": "journal-all",
|
||||
"kind": "journal",
|
||||
"name": "All Journal",
|
||||
"category": "system",
|
||||
"event_type": "system",
|
||||
"parser": "none",
|
||||
"include_matches": {},
|
||||
"exclude_matches": {}
|
||||
},
|
||||
{
|
||||
"source_id": "journal-kernel",
|
||||
"kind": "journal",
|
||||
"name": "Kernel Journal",
|
||||
"category": "system",
|
||||
"event_type": "kernel",
|
||||
"parser": "none",
|
||||
"include_matches": {
|
||||
"_TRANSPORT": ["kernel"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"source_id": "service-ssh",
|
||||
"kind": "service",
|
||||
"name": "OpenSSH",
|
||||
"service_unit": "ssh.service",
|
||||
"category": "access",
|
||||
"event_type": "ssh",
|
||||
"parser": "none"
|
||||
},
|
||||
{
|
||||
"source_id": "file-auth",
|
||||
"kind": "file",
|
||||
"name": "Auth Log",
|
||||
"file_path": "/var/log/auth.log",
|
||||
"category": "auth",
|
||||
"event_type": "file.line",
|
||||
"parser": "syslog"
|
||||
},
|
||||
{
|
||||
"source_id": "file-nginx-access",
|
||||
"kind": "file",
|
||||
"name": "Nginx Access",
|
||||
"file_path": "/var/log/nginx/access.log",
|
||||
"category": "access",
|
||||
"event_type": "http.access",
|
||||
"parser": "nginx_access"
|
||||
}
|
||||
],
|
||||
"state_dir": "/var/lib/keywarden-agent",
|
||||
"account_policy": {
|
||||
"username_template": "{{username}}_{{user_id}}",
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"server_url": "https://keywarden.dev.ntbx.io/api/v1",
|
||||
"server_id": "6",
|
||||
"agent_api_token": "HV8MnbysmINShu9wkXzzNrQE5NyRlPJHfP2aU6SX4HERrlac8EQpoMEu02FX1TYv",
|
||||
"sync_interval_seconds": 5,
|
||||
"log_batch_size": 2000,
|
||||
"state_dir": "/var/lib/keywarden-agent",
|
||||
"account_policy": {
|
||||
"username_template": "{{username}}_{{user_id}}",
|
||||
"default_shell": "/bin/bash",
|
||||
"admin_group": "sudo",
|
||||
"create_home": true,
|
||||
"lock_on_revoke": true
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,18 @@ type Client struct {
|
||||
scheme string
|
||||
host string
|
||||
addr string
|
||||
token string
|
||||
}
|
||||
|
||||
func normalizeAgentToken(raw string) string {
|
||||
token := strings.TrimSpace(raw)
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
if len(token) > 7 && strings.EqualFold(token[:7], "Bearer ") {
|
||||
token = strings.TrimSpace(token[7:])
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
func New(cfg *config.Config) (*Client, error) {
|
||||
@@ -35,6 +47,10 @@ func New(cfg *config.Config) (*Client, error) {
|
||||
if baseURL == "" {
|
||||
return nil, errors.New("server url is required")
|
||||
}
|
||||
agentToken := normalizeAgentToken(cfg.AgentAPIToken)
|
||||
if agentToken == "" {
|
||||
return nil, errors.New("agent api token is required (set agent_api_token or KEYWARDEN_AGENT_API_TOKEN)")
|
||||
}
|
||||
cert, err := tls.LoadX509KeyPair(cfg.ClientCertPath(), cfg.ClientKeyPath())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load client cert: %w", err)
|
||||
@@ -97,9 +113,17 @@ func New(cfg *config.Config) (*Client, error) {
|
||||
scheme: scheme,
|
||||
host: host,
|
||||
addr: addr,
|
||||
token: agentToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) setAuth(req *http.Request) {
|
||||
if c.token == "" {
|
||||
return
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
}
|
||||
|
||||
type EnrollRequest struct {
|
||||
Token string `json:"token"`
|
||||
CSRPEM string `json:"csr_pem"`
|
||||
@@ -113,6 +137,7 @@ type EnrollResponse struct {
|
||||
ServerID string `json:"server_id"`
|
||||
ClientCert string `json:"client_cert_pem"`
|
||||
CACert string `json:"ca_cert_pem"`
|
||||
AgentToken string `json:"agent_api_token,omitempty"`
|
||||
SyncProfile string `json:"sync_profile,omitempty"`
|
||||
DisplayName string `json:"display_name,omitempty"`
|
||||
}
|
||||
@@ -149,6 +174,19 @@ type SyncReportRequest struct {
|
||||
Accounts []AccountSyncEntry `json:"accounts,omitempty"`
|
||||
}
|
||||
|
||||
type LogSourceConfig struct {
|
||||
SourceID string `json:"source_id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
ServiceUnit string `json:"service_unit,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||
Category string `json:"category"`
|
||||
EventType string `json:"event_type"`
|
||||
}
|
||||
|
||||
func Enroll(ctx context.Context, serverURL string, req EnrollRequest) (*EnrollResponse, error) {
|
||||
baseURL := strings.TrimRight(serverURL, "/")
|
||||
if baseURL == "" {
|
||||
@@ -241,6 +279,7 @@ func (c *Client) FetchAccountAccess(ctx context.Context, serverID string) ([]Acc
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build account access request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch account access: %w", err)
|
||||
@@ -266,6 +305,7 @@ func (c *Client) FetchUserCA(ctx context.Context, serverID string) (*UserCARespo
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build user ca request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch user ca: %w", err)
|
||||
@@ -299,6 +339,7 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
||||
return fmt.Errorf("build sync report: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send sync report: %w", err)
|
||||
@@ -310,12 +351,39 @@ func (c *Client) SendSyncReport(ctx context.Context, serverID string, report Syn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) FetchLogConfig(ctx context.Context, serverID string) ([]LogSourceConfig, error) {
|
||||
req, err := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodGet,
|
||||
c.baseURL+"/agent/servers/"+serverID+"/log-config",
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build log config request: %w", err)
|
||||
}
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch log config: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Status: resp.Status}
|
||||
}
|
||||
var out []LogSourceConfig
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, fmt.Errorf("decode log config: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *Client) SendLogBatch(ctx context.Context, serverID string, payload []byte) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/agent/servers/"+serverID+"/logs", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("build log request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send log batch: %w", err)
|
||||
@@ -344,6 +412,7 @@ func (c *Client) UpdateHost(ctx context.Context, serverID string, reqBody Heartb
|
||||
return fmt.Errorf("build host update: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c.setAuth(req)
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send host update: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -26,12 +27,28 @@ type AccountPolicy struct {
|
||||
LockOnRevoke bool `json:"lock_on_revoke"`
|
||||
}
|
||||
|
||||
type LogSource struct {
|
||||
SourceID string `json:"source_id,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name,omitempty"`
|
||||
ServiceUnit string `json:"service_unit,omitempty"`
|
||||
FilePath string `json:"file_path,omitempty"`
|
||||
Parser string `json:"parser,omitempty"`
|
||||
IncludeMatches map[string][]string `json:"include_matches,omitempty"`
|
||||
ExcludeMatches map[string][]string `json:"exclude_matches,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
ServerURL string `json:"server_url"`
|
||||
ServerID string `json:"server_id,omitempty"`
|
||||
AgentAPIToken string `json:"agent_api_token,omitempty"`
|
||||
ServerCAPath string `json:"server_ca_path,omitempty"`
|
||||
SyncIntervalSeconds int `json:"sync_interval_seconds,omitempty"`
|
||||
LogBatchSize int `json:"log_batch_size,omitempty"`
|
||||
LogSources []LogSource `json:"log_sources,omitempty"`
|
||||
StateDir string `json:"state_dir,omitempty"`
|
||||
AccountPolicy AccountPolicy `json:"account_policy,omitempty"`
|
||||
}
|
||||
@@ -48,7 +65,11 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
||||
if serverURL == "" {
|
||||
return nil, errors.New("server url required for first boot")
|
||||
}
|
||||
cfg := &Config{ServerURL: serverURL, ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH")}
|
||||
cfg := &Config{
|
||||
ServerURL: serverURL,
|
||||
AgentAPIToken: os.Getenv("KEYWARDEN_AGENT_API_TOKEN"),
|
||||
ServerCAPath: os.Getenv("KEYWARDEN_SERVER_CA_PATH"),
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
if err := validate(cfg, false); err != nil {
|
||||
return nil, err
|
||||
@@ -62,9 +83,15 @@ func LoadOrInit(path string, serverURL string) (*Config, error) {
|
||||
if err := json.Unmarshal(data, cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
envAgentToken := strings.TrimSpace(os.Getenv("KEYWARDEN_AGENT_API_TOKEN"))
|
||||
if cfg.ServerCAPath == "" {
|
||||
cfg.ServerCAPath = os.Getenv("KEYWARDEN_SERVER_CA_PATH")
|
||||
}
|
||||
// Environment should win when set so operators can rotate tokens centrally
|
||||
// without editing persisted agent.json on every node.
|
||||
if envAgentToken != "" {
|
||||
cfg.AgentAPIToken = envAgentToken
|
||||
}
|
||||
applyDefaults(cfg)
|
||||
if err := validate(cfg, false); err != nil {
|
||||
return nil, err
|
||||
@@ -108,6 +135,8 @@ func applyDefaults(cfg *Config) {
|
||||
}
|
||||
|
||||
func validate(cfg *Config, requireServerID bool) error {
|
||||
cfg.ServerID = strings.TrimSpace(cfg.ServerID)
|
||||
cfg.AgentAPIToken = strings.TrimSpace(cfg.AgentAPIToken)
|
||||
var missing []string
|
||||
if cfg.ServerURL == "" {
|
||||
missing = append(missing, "server_url")
|
||||
@@ -118,12 +147,57 @@ func validate(cfg *Config, requireServerID bool) error {
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("missing required config fields: %v", missing)
|
||||
}
|
||||
if looksLikePlaceholder(cfg.ServerID) {
|
||||
return errors.New("invalid server_id placeholder in config; remove server_id and re-enroll the agent")
|
||||
}
|
||||
if cfg.ServerID != "" {
|
||||
if _, err := strconv.Atoi(cfg.ServerID); err != nil {
|
||||
return errors.New("invalid server_id in config; expected numeric server id from enrollment")
|
||||
}
|
||||
}
|
||||
if looksLikePlaceholder(cfg.AgentAPIToken) {
|
||||
return errors.New("invalid agent_api_token placeholder in config; remove agent_api_token and re-enroll the agent")
|
||||
}
|
||||
if cfg.SyncIntervalSeconds < 5 {
|
||||
return errors.New("sync_interval_seconds must be >= 5")
|
||||
}
|
||||
for i, source := range cfg.LogSources {
|
||||
kind := strings.ToLower(strings.TrimSpace(source.Kind))
|
||||
switch kind {
|
||||
case "journal":
|
||||
// journal source is valid with optional include/exclude matches.
|
||||
case "service":
|
||||
if strings.TrimSpace(source.ServiceUnit) == "" && strings.TrimSpace(source.Name) == "" {
|
||||
return fmt.Errorf("log_sources[%d]: service source requires service_unit or name", i)
|
||||
}
|
||||
case "file":
|
||||
if strings.TrimSpace(source.FilePath) == "" && strings.TrimSpace(source.Name) == "" {
|
||||
return fmt.Errorf("log_sources[%d]: file source requires file_path or name", i)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("log_sources[%d]: kind must be 'journal', 'service' or 'file'", i)
|
||||
}
|
||||
parser := strings.ToLower(strings.TrimSpace(source.Parser))
|
||||
if parser == "" {
|
||||
continue
|
||||
}
|
||||
switch parser {
|
||||
case "none", "syslog", "nginx_access", "json":
|
||||
default:
|
||||
return fmt.Errorf("log_sources[%d]: parser must be one of none|syslog|nginx_access|json", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikePlaceholder(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(value, "<") || strings.Contains(value, ">")
|
||||
}
|
||||
|
||||
func (c *Config) ClientCertPath() string {
|
||||
return c.StateDir + "/agent.crt"
|
||||
}
|
||||
@@ -144,6 +218,28 @@ func (c *Config) LogSpoolDir() string {
|
||||
return c.StateDir + "/spool"
|
||||
}
|
||||
|
||||
func (c *Config) LogOffsetsPath() string {
|
||||
return c.StateDir + "/log.offsets.json"
|
||||
}
|
||||
|
||||
func (c *Config) LogStatePath() string {
|
||||
return c.StateDir + "/log.state.json"
|
||||
}
|
||||
|
||||
func (c *Config) EnabledLogSources() []LogSource {
|
||||
if len(c.LogSources) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]LogSource, 0, len(c.LogSources))
|
||||
for _, source := range c.LogSources {
|
||||
if source.Enabled != nil && !*source.Enabled {
|
||||
continue
|
||||
}
|
||||
out = append(out, source)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dir(path string) string {
|
||||
if idx := strings.LastIndex(path, string(os.PathSeparator)); idx != -1 {
|
||||
return path[:idx]
|
||||
|
||||
+1010
-44
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,60 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestApplyNginxAccessParserSetsParsedTimestamp(t *testing.T) {
|
||||
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||
event.SourceKind = "file"
|
||||
event.Message = `192.168.2.40 - - [28/Jan/2026:17:43:43 +0000] "GET / HTTP/1.1" 200 612 "-" "curl/8.0"`
|
||||
|
||||
if !applyNginxAccessParser(&event) {
|
||||
t.Fatalf("expected nginx parser to match log line")
|
||||
}
|
||||
|
||||
expected := "2026-01-28T17:43:43Z"
|
||||
if event.Timestamp != expected {
|
||||
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONTimestampSupportsEpochMillis(t *testing.T) {
|
||||
event := NewEvent(time.Date(2026, time.April, 15, 18, 43, 7, 0, time.UTC))
|
||||
event.SourceKind = "file"
|
||||
event.Message = `{"timestamp":1700000000000,"message":"login event"}`
|
||||
|
||||
if !applyJSONParser(&event) {
|
||||
t.Fatalf("expected json parser to parse payload")
|
||||
}
|
||||
|
||||
expected := time.Unix(1700000000, 0).UTC().Format(time.RFC3339Nano)
|
||||
if event.Timestamp != expected {
|
||||
t.Fatalf("expected parsed timestamp %s, got %s", expected, event.Timestamp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSyslogTimestampHandlesYearRollover(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 1, 0, 0, 30, 0, time.UTC)
|
||||
parsed, ok := parseSyslogTimestamp("Dec 31 23:59:59", now)
|
||||
if !ok {
|
||||
t.Fatalf("expected syslog timestamp to parse")
|
||||
}
|
||||
if parsed.Year() != 2025 {
|
||||
t.Fatalf("expected year rollover to previous year, got %d", parsed.Year())
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeSetParsedTimestampSkipsJournalAndServiceSources(t *testing.T) {
|
||||
original := "2026-04-15T18:43:07Z"
|
||||
event := Event{
|
||||
Timestamp: original,
|
||||
SourceKind: "service",
|
||||
}
|
||||
|
||||
maybeSetParsedTimestamp(&event, time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC))
|
||||
if event.Timestamp != original {
|
||||
t.Fatalf("expected service timestamp to remain unchanged")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadOffsets(path string) (map[string]int64, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]int64{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]int64{}
|
||||
if len(data) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func WriteOffsets(path string, offsets map[string]int64) error {
|
||||
if offsets == nil {
|
||||
offsets = map[string]int64{}
|
||||
}
|
||||
data, err := json.Marshal(offsets)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package logs
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ReadState(path string) (map[string]SourceState, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]SourceState{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
out := map[string]SourceState{}
|
||||
if len(data) == 0 {
|
||||
return out, nil
|
||||
}
|
||||
if err := json.Unmarshal(data, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func WriteState(path string, state map[string]SourceState) error {
|
||||
if state == nil {
|
||||
state = map[string]SourceState{}
|
||||
}
|
||||
data, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0o600)
|
||||
}
|
||||
@@ -3,21 +3,42 @@ package logs
|
||||
import "time"
|
||||
|
||||
type Event struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Category string `json:"category"`
|
||||
EventType string `json:"event_type"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Principal string `json:"principal,omitempty"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
Category string `json:"category"`
|
||||
EventType string `json:"event_type"`
|
||||
SourceKind string `json:"source_kind,omitempty"`
|
||||
SourceName string `json:"source_name,omitempty"`
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Principal string `json:"principal,omitempty"`
|
||||
SourceIP string `json:"source_ip,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Raw string `json:"raw,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
func NewEvent(ts time.Time) Event {
|
||||
return Event{Timestamp: ts.UTC().Format(time.RFC3339Nano)}
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
SourceID string
|
||||
Kind string
|
||||
Name string
|
||||
ServiceUnit string
|
||||
FilePath string
|
||||
Parser string
|
||||
IncludeMatches map[string][]string
|
||||
ExcludeMatches map[string][]string
|
||||
Category string
|
||||
EventType string
|
||||
}
|
||||
|
||||
type SourceState struct {
|
||||
Cursor string `json:"cursor,omitempty"`
|
||||
Offset int64 `json:"offset,omitempty"`
|
||||
Inode uint64 `json:"inode,omitempty"`
|
||||
}
|
||||
Binary file not shown.
Reference in new issue
Block a user