Skip to content

Commit b91454a

Browse files
committed
Add --native-log flag for Apple Unified Logging support
Add macOS-specific slog.Handler that writes to os_log via cgo, enabling logs to appear in Console.app with subsystem dev.epithet. On non-Darwin platforms, the flag falls back to console logging with a warning.
1 parent a747f61 commit b91454a

8 files changed

Lines changed: 329 additions & 10 deletions

File tree

.tasks/closed/5p7v12qq.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
yatl_version: 1
3+
title: Test native logging with epithet agent --native-log
4+
id: 5p7v12qq
5+
created: 2026-01-05T21:03:48.929392Z
6+
updated: 2026-01-05T21:07:48.421671Z
7+
author: Brian McCallister
8+
priority: medium
9+
tags:
10+
- test
11+
---
12+
13+
---
14+
# Log: 2026-01-05T21:03:48Z Brian McCallister
15+
16+
Created task.
17+
18+
---
19+
# Log: 2026-01-05T21:07:48Z Brian McCallister
20+
21+
Closed: Verified logs appear in Console.app with subsystem dev.epithet

.tasks/closed/ecyzfr5y.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
yatl_version: 1
3+
title: Add --native-log flag to root command in main.go
4+
id: ecyzfr5y
5+
created: 2026-01-05T21:03:48.579409Z
6+
updated: 2026-01-05T21:07:48.317647Z
7+
author: Brian McCallister
8+
priority: high
9+
tags:
10+
- feature
11+
---
12+
13+
---
14+
# Log: 2026-01-05T21:03:48Z Brian McCallister
15+
16+
Created task.
17+
18+
---
19+
# Log: 2026-01-05T21:04:51Z Brian McCallister
20+
21+
Started working.
22+
23+
---
24+
# Log: 2026-01-05T21:07:48Z Brian McCallister
25+
26+
Closed: Added --native-log flag to main.go, verified working

.tasks/closed/wqdrkrc0.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
yatl_version: 1
3+
title: Create pkg/slogoslog/handler_darwin.go with cgo os_log wrapper
4+
id: wqdrkrc0
5+
created: 2026-01-05T21:03:48.688735Z
6+
updated: 2026-01-05T21:04:41.221763Z
7+
author: Brian McCallister
8+
priority: high
9+
tags:
10+
- feature
11+
---
12+
13+
---
14+
# Log: 2026-01-05T21:03:48Z Brian McCallister
15+
16+
Created task.
17+
18+
---
19+
# Log: 2026-01-05T21:03:52Z Brian McCallister
20+
21+
Started working.
22+
23+
---
24+
# Log: 2026-01-05T21:04:41Z Brian McCallister
25+
26+
Closed: Created darwin handler with cgo os_log wrapper

.tasks/closed/xprycypa.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
yatl_version: 1
3+
title: Create pkg/slogoslog/handler_other.go stub for non-Darwin
4+
id: xprycypa
5+
created: 2026-01-05T21:03:48.806787Z
6+
updated: 2026-01-05T21:04:51.456242Z
7+
author: Brian McCallister
8+
priority: high
9+
tags:
10+
- feature
11+
---
12+
13+
---
14+
# Log: 2026-01-05T21:03:48Z Brian McCallister
15+
16+
Created task.
17+
18+
---
19+
# Log: 2026-01-05T21:04:41Z Brian McCallister
20+
21+
Started working.
22+
23+
---
24+
# Log: 2026-01-05T21:04:51Z Brian McCallister
25+
26+
Closed: Created stub that returns nil for non-darwin platforms

cmd/epithet/main.go

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"github.com/alecthomas/kong"
1111
"github.com/brianm/kongcue"
12+
"github.com/epithet-ssh/epithet/pkg/slogoslog"
1213
"github.com/epithet-ssh/epithet/pkg/tlsconfig"
1314
"github.com/lmittmann/tint"
1415
)
@@ -20,10 +21,11 @@ var (
2021
)
2122

2223
var cli struct {
23-
Version kong.VersionFlag `short:"V" help:"Print version information"`
24-
Verbose int `short:"v" type:"counter" help:"Increase verbosity (-v for debug, -vv for trace)"`
25-
LogFile string `name:"log-file" help:"Path to log file (supports ~ expansion)" env:"EPITHET_LOG_FILE"`
26-
Config kongcue.Config `help:"Path to config file" sep:";" default:"/etc/epithet/*.{cue,yaml,yml,json};~/.epithet/*.{cue,yaml,yml,json}"`
24+
Version kong.VersionFlag `short:"V" help:"Print version information"`
25+
Verbose int `short:"v" type:"counter" help:"Increase verbosity (-v for debug, -vv for trace)"`
26+
LogFile string `name:"log-file" help:"Path to log file (supports ~ expansion)" env:"EPITHET_LOG_FILE"`
27+
NativeLog bool `name:"native-log" help:"Use native OS logging (macOS: Console.app)" env:"EPITHET_NATIVE_LOG"`
28+
Config kongcue.Config `help:"Path to config file" sep:";" default:"/etc/epithet/*.{cue,yaml,yml,json};~/.epithet/*.{cue,yaml,yml,json}"`
2729

2830
// TLS configuration flags (global)
2931
Insecure bool `help:"Disable TLS certificate verification (NOT RECOMMENDED)" env:"EPITHET_INSECURE"`
@@ -50,7 +52,7 @@ func main() {
5052
ktx := kong.Parse(&cli,
5153
kong.Vars{"version": version + " (" + commit + ", " + date + ")"},
5254
kong.ShortUsageOnError(),
53-
kongcue.AllowUnknownFields("policy.users", "policy.defaults", "policy.hosts"),
55+
kongcue.AllowUnknownFields("policy.users", "policy.defaults", "policy.hosts", "policy.oidc"),
5456
)
5557
logger := setupLogger()
5658

@@ -82,7 +84,7 @@ func expandPath(path string) (string, error) {
8284
}
8385

8486
func setupLogger() *slog.Logger {
85-
// Determine log level based on verbosity
87+
// Determine log level based on verbosity.
8688
level := slog.LevelWarn
8789
switch cli.Verbose {
8890
case 0:
@@ -93,15 +95,24 @@ func setupLogger() *slog.Logger {
9395
level = slog.LevelDebug
9496
}
9597

96-
// Determine output writer
98+
// If native logging requested, try to use it.
99+
if cli.NativeLog {
100+
if handler := slogoslog.NewHandler(level); handler != nil {
101+
return slog.New(handler)
102+
}
103+
// Fall through to default handler if native not available.
104+
slog.Warn("native logging not available on this platform, using console")
105+
}
106+
107+
// Determine output writer.
97108
var w io.Writer = os.Stderr
98109
if cli.LogFile != "" {
99110
path, err := expandPath(cli.LogFile)
100111
if err != nil {
101-
// Fall back to stderr if path expansion fails
112+
// Fall back to stderr if path expansion fails.
102113
slog.Error("failed to expand log file path", "error", err)
103114
} else {
104-
// Ensure parent directory exists
115+
// Ensure parent directory exists.
105116
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
106117
slog.Error("failed to create log directory", "error", err)
107118
} else {

cmd/epithet/policy.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ type PolicyOIDCConfig struct {
3333
type PolicyServerCLI struct {
3434
Listen string `help:"Address to listen on" short:"l" default:"0.0.0.0:9999"`
3535

36-
// Nested struct for OIDC - gives us policy.oidc.issuer in config
36+
// Embedded struct for OIDC - CLI flags are --oidc-issuer, --oidc-client-id, etc.
37+
// Config file uses nested policy.oidc.issuer (handled by loadPolicyFromCUE, allowed via AllowUnknownFields)
3738
OIDC PolicyOIDCConfig `embed:"" prefix:"oidc-"`
3839

3940
// CA public key - can be URL, file path, or literal key

pkg/slogoslog/handler_darwin.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
//go:build darwin
2+
3+
// Package slogoslog provides a slog.Handler that writes to Apple's Unified Logging system (os_log).
4+
package slogoslog
5+
6+
/*
7+
#cgo LDFLAGS: -framework Foundation
8+
#include <os/log.h>
9+
#include <stdlib.h>
10+
11+
static os_log_t epithet_log = NULL;
12+
13+
void epithet_log_init(const char* subsystem, const char* category) {
14+
epithet_log = os_log_create(subsystem, category);
15+
}
16+
17+
void epithet_log_debug(const char* msg) {
18+
os_log_debug(epithet_log, "%{public}s", msg);
19+
}
20+
21+
void epithet_log_info(const char* msg) {
22+
os_log_info(epithet_log, "%{public}s", msg);
23+
}
24+
25+
void epithet_log_default(const char* msg) {
26+
os_log(epithet_log, "%{public}s", msg);
27+
}
28+
29+
void epithet_log_error(const char* msg) {
30+
os_log_error(epithet_log, "%{public}s", msg);
31+
}
32+
*/
33+
import "C"
34+
35+
import (
36+
"context"
37+
"fmt"
38+
"log/slog"
39+
"strings"
40+
"sync"
41+
"unsafe"
42+
)
43+
44+
const (
45+
// Subsystem is the os_log subsystem identifier for epithet.
46+
Subsystem = "dev.epithet"
47+
// Category is the default os_log category.
48+
Category = "default"
49+
)
50+
51+
var initOnce sync.Once
52+
53+
// Handler is a slog.Handler that writes to Apple's Unified Logging system.
54+
type Handler struct {
55+
level slog.Leveler
56+
attrs []slog.Attr
57+
groups []string
58+
}
59+
60+
// NewHandler creates a new os_log handler with the given minimum log level.
61+
// Returns a handler that writes to Apple's Unified Logging system with
62+
// subsystem "dev.epithet".
63+
func NewHandler(level slog.Leveler) slog.Handler {
64+
initOnce.Do(func() {
65+
cs := C.CString(Subsystem)
66+
cc := C.CString(Category)
67+
defer C.free(unsafe.Pointer(cs))
68+
defer C.free(unsafe.Pointer(cc))
69+
C.epithet_log_init(cs, cc)
70+
})
71+
return &Handler{level: level}
72+
}
73+
74+
// Enabled reports whether the handler handles records at the given level.
75+
func (h *Handler) Enabled(_ context.Context, level slog.Level) bool {
76+
return level >= h.level.Level()
77+
}
78+
79+
// Handle writes the record to os_log.
80+
func (h *Handler) Handle(_ context.Context, r slog.Record) error {
81+
msg := h.formatRecord(r)
82+
cmsg := C.CString(msg)
83+
defer C.free(unsafe.Pointer(cmsg))
84+
85+
switch {
86+
case r.Level >= slog.LevelError:
87+
C.epithet_log_error(cmsg)
88+
case r.Level >= slog.LevelWarn:
89+
C.epithet_log_default(cmsg)
90+
case r.Level >= slog.LevelInfo:
91+
C.epithet_log_info(cmsg)
92+
default:
93+
C.epithet_log_debug(cmsg)
94+
}
95+
return nil
96+
}
97+
98+
// WithAttrs returns a new handler with the given attributes added.
99+
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
100+
newAttrs := make([]slog.Attr, len(h.attrs), len(h.attrs)+len(attrs))
101+
copy(newAttrs, h.attrs)
102+
newAttrs = append(newAttrs, attrs...)
103+
return &Handler{
104+
level: h.level,
105+
attrs: newAttrs,
106+
groups: h.groups,
107+
}
108+
}
109+
110+
// WithGroup returns a new handler with the given group name added.
111+
func (h *Handler) WithGroup(name string) slog.Handler {
112+
if name == "" {
113+
return h
114+
}
115+
newGroups := make([]string, len(h.groups), len(h.groups)+1)
116+
copy(newGroups, h.groups)
117+
newGroups = append(newGroups, name)
118+
return &Handler{
119+
level: h.level,
120+
attrs: h.attrs,
121+
groups: newGroups,
122+
}
123+
}
124+
125+
// formatRecord formats a log record as "message key=value key=value ...".
126+
func (h *Handler) formatRecord(r slog.Record) string {
127+
var b strings.Builder
128+
b.WriteString(r.Message)
129+
130+
// Write pre-accumulated attrs.
131+
for _, a := range h.attrs {
132+
h.writeAttr(&b, a, h.groups)
133+
}
134+
135+
// Write record attrs.
136+
r.Attrs(func(a slog.Attr) bool {
137+
h.writeAttr(&b, a, h.groups)
138+
return true
139+
})
140+
141+
return b.String()
142+
}
143+
144+
// writeAttr writes a single attribute to the builder.
145+
func (h *Handler) writeAttr(b *strings.Builder, a slog.Attr, groups []string) {
146+
// Resolve the attribute value.
147+
a.Value = a.Value.Resolve()
148+
149+
// Skip empty attributes.
150+
if a.Equal(slog.Attr{}) {
151+
return
152+
}
153+
154+
b.WriteByte(' ')
155+
156+
// Write group prefix.
157+
for _, g := range groups {
158+
b.WriteString(g)
159+
b.WriteByte('.')
160+
}
161+
162+
// Handle groups specially.
163+
if a.Value.Kind() == slog.KindGroup {
164+
attrs := a.Value.Group()
165+
newGroups := groups
166+
if a.Key != "" {
167+
newGroups = append(groups, a.Key)
168+
}
169+
for _, ga := range attrs {
170+
h.writeAttr(b, ga, newGroups)
171+
}
172+
return
173+
}
174+
175+
b.WriteString(a.Key)
176+
b.WriteByte('=')
177+
b.WriteString(formatValue(a.Value))
178+
}
179+
180+
// formatValue formats a slog.Value for display.
181+
func formatValue(v slog.Value) string {
182+
switch v.Kind() {
183+
case slog.KindString:
184+
s := v.String()
185+
if strings.ContainsAny(s, " \t\n\"") {
186+
return fmt.Sprintf("%q", s)
187+
}
188+
return s
189+
case slog.KindTime:
190+
return v.Time().Format("15:04:05.000")
191+
case slog.KindDuration:
192+
return v.Duration().String()
193+
default:
194+
return fmt.Sprintf("%v", v.Any())
195+
}
196+
}

pkg/slogoslog/handler_other.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
//go:build !darwin
2+
3+
// Package slogoslog provides a slog.Handler that writes to native OS logging systems.
4+
package slogoslog
5+
6+
import "log/slog"
7+
8+
// NewHandler returns nil on non-Darwin platforms.
9+
// The caller should fall back to the default handler when nil is returned.
10+
func NewHandler(level slog.Leveler) slog.Handler {
11+
return nil
12+
}

0 commit comments

Comments
 (0)