From 8fde62fd3af922e0f3ccf7c8837931e13f13ac73 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Wed, 10 Dec 2025 22:10:45 -0700 Subject: [PATCH 1/5] feat(audit): add socket auditor for forwarding logs to coder agent Add SocketAuditor that sends audit logs to the Coder workspace agent via a Unix socket. This enables boundary audit events to be forwarded to coderd for centralized logging. Features: - Batching: 10 logs or 5 seconds, whichever comes first - Wire format: length-prefixed protobuf (compatible with coder agent proto) - Automatic reconnection on connection errors - MultiAuditor to combine local logging with socket forwarding Environment variables: - CODER_BOUNDARY_LOG_SOCKET: path to agent's Unix socket - CODER_WORKSPACE_ID: workspace UUID for log attribution When both env vars are set, boundary sends logs to both the local logger and the agent socket. --- Makefile | 13 ++- app/parent.go | 60 +++++++++- audit/multi_auditor.go | 18 +++ audit/socket_auditor.go | 149 +++++++++++++++++++++++++ go.mod | 1 + proto/boundary_logs.pb.go | 224 ++++++++++++++++++++++++++++++++++++++ proto/boundary_logs.proto | 25 +++++ 7 files changed, 486 insertions(+), 4 deletions(-) create mode 100644 audit/multi_auditor.go create mode 100644 audit/socket_auditor.go create mode 100644 proto/boundary_logs.pb.go create mode 100644 proto/boundary_logs.proto diff --git a/Makefile b/Makefile index 3037181..a339d0a 100644 --- a/Makefile +++ b/Makefile @@ -159,4 +159,15 @@ help: @echo " clean Clean build artifacts" @echo " fmt Format code" @echo " lint Lint code" - @echo " help Show this help message" \ No newline at end of file + @echo " help Show this help message" +# Generate protobuf code +.PHONY: proto +proto: + @echo "Generating protobuf code..." + protoc --go_out=. --go_opt=paths=source_relative proto/boundary_logs.proto + @echo "✓ Protobuf code generated!" + +# Run all code generation +.PHONY: gen +gen: proto + @echo "✓ All code generation complete!" diff --git a/app/parent.go b/app/parent.go index ce1e34e..7c8498a 100644 --- a/app/parent.go +++ b/app/parent.go @@ -10,14 +10,23 @@ import ( "strings" "syscall" - "github.com/coder/boundary/boundary" "github.com/coder/boundary/audit" + "github.com/coder/boundary/boundary" "github.com/coder/boundary/jail" "github.com/coder/boundary/rulesengine" "github.com/coder/boundary/tls" "github.com/coder/boundary/util" ) +// Environment variables for socket auditor integration with Coder agent. +const ( + // BoundaryLogSocketEnvVar is the path to the Unix socket where boundary + // should send audit logs. + BoundaryLogSocketEnvVar = "CODER_BOUNDARY_LOG_SOCKET" + // BoundaryWorkspaceIDEnvVar is the workspace ID for boundary audit logs. + BoundaryWorkspaceIDEnvVar = "CODER_WORKSPACE_ID" +) + func RunParent(ctx context.Context, logger *slog.Logger, args []string, config Config) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -50,8 +59,8 @@ func RunParent(ctx context.Context, logger *slog.Logger, args []string, config C // Create rule engine ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) - // Create auditor - auditor := audit.NewLogAuditor(logger) + // Create auditor - check for socket path to forward logs to agent + auditor := createAuditor(logger) // Create TLS certificate manager certManager, err := tls.NewCertificateManager(tls.Config{ @@ -166,3 +175,48 @@ func RunParent(ctx context.Context, logger *slog.Logger, args []string, config C return nil } + +// createAuditor creates the appropriate auditor based on environment variables. +// If CODER_BOUNDARY_LOG_SOCKET and CODER_WORKSPACE_ID are set, it creates a +// MultiAuditor that sends to both the local logger and the agent socket. +func createAuditor(logger *slog.Logger) audit.Auditor { + logAuditor := audit.NewLogAuditor(logger) + + socketPath := os.Getenv(BoundaryLogSocketEnvVar) + workspaceIDStr := os.Getenv(BoundaryWorkspaceIDEnvVar) + + if socketPath == "" || workspaceIDStr == "" { + return logAuditor + } + + workspaceID, err := parseUUID(workspaceIDStr) + if err != nil { + logger.Warn("Invalid workspace ID, using local auditor only", "error", err) + return logAuditor + } + + socketAuditor := audit.NewSocketAuditor(socketPath, workspaceID) + logger.Info("Boundary log forwarding enabled", + "socket_path", socketPath, + "workspace_id", workspaceIDStr) + + return audit.NewMultiAuditor(logAuditor, socketAuditor) +} + +// parseUUID parses a UUID string into a 16-byte slice. +func parseUUID(s string) ([]byte, error) { + s = strings.ReplaceAll(s, "-", "") + if len(s) != 32 { + return nil, fmt.Errorf("invalid UUID length: %d", len(s)) + } + b := make([]byte, 16) + for i := 0; i < 16; i++ { + var v byte + _, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &v) + if err != nil { + return nil, fmt.Errorf("invalid UUID character at position %d: %v", i*2, err) + } + b[i] = v + } + return b, nil +} diff --git a/audit/multi_auditor.go b/audit/multi_auditor.go new file mode 100644 index 0000000..1157c2d --- /dev/null +++ b/audit/multi_auditor.go @@ -0,0 +1,18 @@ +package audit + +// MultiAuditor wraps multiple auditors and sends audit events to all of them. +type MultiAuditor struct { + auditors []Auditor +} + +// NewMultiAuditor creates a new MultiAuditor that sends to all provided auditors. +func NewMultiAuditor(auditors ...Auditor) *MultiAuditor { + return &MultiAuditor{auditors: auditors} +} + +// AuditRequest sends the request to all wrapped auditors. +func (m *MultiAuditor) AuditRequest(req Request) { + for _, a := range m.auditors { + a.AuditRequest(req) + } +} diff --git a/audit/socket_auditor.go b/audit/socket_auditor.go new file mode 100644 index 0000000..c61313d --- /dev/null +++ b/audit/socket_auditor.go @@ -0,0 +1,149 @@ +package audit + +import ( + "encoding/binary" + "net" + "sync" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + boundaryproto "github.com/coder/boundary/proto" +) + +const ( + socketFlushInterval = 5 * time.Second + socketBatchSize = 10 +) + +// SocketAuditor implements the Auditor interface by sending logs to the +// workspace agent's boundary log proxy socket. It batches logs using either +// a 5-second timeout or when 10 logs have accumulated, whichever comes first. +type SocketAuditor struct { + socketPath string + workspaceID []byte + + mu sync.Mutex + conn net.Conn + logs []*boundaryproto.BoundaryLog + closed bool + flushTimer *time.Timer +} + +// NewSocketAuditor creates a new SocketAuditor that sends logs to the agent's +// boundary log proxy socket. The workspaceID should be the 16-byte UUID. +func NewSocketAuditor(socketPath string, workspaceID []byte) *SocketAuditor { + return &SocketAuditor{ + socketPath: socketPath, + workspaceID: workspaceID, + } +} + +// AuditRequest implements the Auditor interface. It queues the request and +// batches sends to the agent socket. +func (s *SocketAuditor) AuditRequest(req Request) { + s.mu.Lock() + defer s.mu.Unlock() + + if s.closed { + return + } + + log := &boundaryproto.BoundaryLog{ + WorkspaceId: s.workspaceID, + Time: timestamppb.Now(), + Allowed: req.Allowed, + HttpMethod: req.Method, + HttpUrl: req.URL, + } + // Only include matched rule for denied requests. + if !req.Allowed { + log.MatchedRule = req.Rule + } + s.logs = append(s.logs, log) + + // Start flush timer if this is the first log. + if len(s.logs) == 1 { + s.flushTimer = time.AfterFunc(socketFlushInterval, func() { + s.mu.Lock() + defer s.mu.Unlock() + s.flushLocked() + }) + } + + // Flush immediately if we've reached batch size. + if len(s.logs) >= socketBatchSize { + if s.flushTimer != nil { + s.flushTimer.Stop() + s.flushTimer = nil + } + s.flushLocked() + } +} + +// flushLocked sends the current batch of logs. Caller must hold the lock. +func (s *SocketAuditor) flushLocked() { + if len(s.logs) == 0 { + return + } + + // Try to connect if not connected. + if s.conn == nil { + conn, err := net.Dial("unix", s.socketPath) + if err != nil { + // Drop logs if we can't connect. + s.logs = nil + return + } + s.conn = conn + } + + req := &boundaryproto.BoundaryLogsRequest{ + Logs: s.logs, + } + + data, err := proto.Marshal(req) + if err != nil { + s.logs = nil + return + } + + // Write length-prefixed message. + if err := binary.Write(s.conn, binary.BigEndian, uint32(len(data))); err != nil { + // Connection error - close and try again next time. + _ = s.conn.Close() + s.conn = nil + s.logs = nil + return + } + if _, err := s.conn.Write(data); err != nil { + _ = s.conn.Close() + s.conn = nil + s.logs = nil + return + } + + s.logs = nil +} + +// Close flushes any remaining logs and closes the connection. +func (s *SocketAuditor) Close() error { + s.mu.Lock() + defer s.mu.Unlock() + + s.closed = true + if s.flushTimer != nil { + s.flushTimer.Stop() + s.flushTimer = nil + } + + s.flushLocked() + + if s.conn != nil { + err := s.conn.Close() + s.conn = nil + return err + } + return nil +} diff --git a/go.mod b/go.mod index bcc62e7..0a0c104 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/coder/serpent v0.10.0 github.com/stretchr/testify v1.8.4 golang.org/x/sys v0.38.0 + google.golang.org/protobuf v1.32.0 ) require ( diff --git a/proto/boundary_logs.pb.go b/proto/boundary_logs.pb.go new file mode 100644 index 0000000..7fa9a19 --- /dev/null +++ b/proto/boundary_logs.pb.go @@ -0,0 +1,224 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.33.1 +// source: proto/boundary_logs.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// BoundaryLog represents a single boundary audit log entry. +// Field numbers MUST match coder's agent/proto/agent.proto to ensure +// wire format compatibility. +type BoundaryLog struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId []byte `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Time *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"` + Allowed bool `protobuf:"varint,3,opt,name=allowed,proto3" json:"allowed,omitempty"` + HttpMethod string `protobuf:"bytes,4,opt,name=http_method,json=httpMethod,proto3" json:"http_method,omitempty"` + HttpUrl string `protobuf:"bytes,5,opt,name=http_url,json=httpUrl,proto3" json:"http_url,omitempty"` + MatchedRule string `protobuf:"bytes,6,opt,name=matched_rule,json=matchedRule,proto3" json:"matched_rule,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoundaryLog) Reset() { + *x = BoundaryLog{} + mi := &file_proto_boundary_logs_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoundaryLog) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoundaryLog) ProtoMessage() {} + +func (x *BoundaryLog) ProtoReflect() protoreflect.Message { + mi := &file_proto_boundary_logs_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoundaryLog.ProtoReflect.Descriptor instead. +func (*BoundaryLog) Descriptor() ([]byte, []int) { + return file_proto_boundary_logs_proto_rawDescGZIP(), []int{0} +} + +func (x *BoundaryLog) GetWorkspaceId() []byte { + if x != nil { + return x.WorkspaceId + } + return nil +} + +func (x *BoundaryLog) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +func (x *BoundaryLog) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *BoundaryLog) GetHttpMethod() string { + if x != nil { + return x.HttpMethod + } + return "" +} + +func (x *BoundaryLog) GetHttpUrl() string { + if x != nil { + return x.HttpUrl + } + return "" +} + +func (x *BoundaryLog) GetMatchedRule() string { + if x != nil { + return x.MatchedRule + } + return "" +} + +// BoundaryLogsRequest is sent to the agent socket. +// Field numbers MUST match coder's ReportBoundaryLogsRequest. +type BoundaryLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Logs []*BoundaryLog `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoundaryLogsRequest) Reset() { + *x = BoundaryLogsRequest{} + mi := &file_proto_boundary_logs_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoundaryLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoundaryLogsRequest) ProtoMessage() {} + +func (x *BoundaryLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_boundary_logs_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoundaryLogsRequest.ProtoReflect.Descriptor instead. +func (*BoundaryLogsRequest) Descriptor() ([]byte, []int) { + return file_proto_boundary_logs_proto_rawDescGZIP(), []int{1} +} + +func (x *BoundaryLogsRequest) GetLogs() []*BoundaryLog { + if x != nil { + return x.Logs + } + return nil +} + +var File_proto_boundary_logs_proto protoreflect.FileDescriptor + +const file_proto_boundary_logs_proto_rawDesc = "" + + "\n" + + "\x19proto/boundary_logs.proto\x12\x0eboundary.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd9\x01\n" + + "\vBoundaryLog\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\fR\vworkspaceId\x12.\n" + + "\x04time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x12\x18\n" + + "\aallowed\x18\x03 \x01(\bR\aallowed\x12\x1f\n" + + "\vhttp_method\x18\x04 \x01(\tR\n" + + "httpMethod\x12\x19\n" + + "\bhttp_url\x18\x05 \x01(\tR\ahttpUrl\x12!\n" + + "\fmatched_rule\x18\x06 \x01(\tR\vmatchedRule\"F\n" + + "\x13BoundaryLogsRequest\x12/\n" + + "\x04logs\x18\x01 \x03(\v2\x1b.boundary.proto.BoundaryLogR\x04logsB!Z\x1fgithub.com/coder/boundary/protob\x06proto3" + +var ( + file_proto_boundary_logs_proto_rawDescOnce sync.Once + file_proto_boundary_logs_proto_rawDescData []byte +) + +func file_proto_boundary_logs_proto_rawDescGZIP() []byte { + file_proto_boundary_logs_proto_rawDescOnce.Do(func() { + file_proto_boundary_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_boundary_logs_proto_rawDesc), len(file_proto_boundary_logs_proto_rawDesc))) + }) + return file_proto_boundary_logs_proto_rawDescData +} + +var file_proto_boundary_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_proto_boundary_logs_proto_goTypes = []any{ + (*BoundaryLog)(nil), // 0: boundary.proto.BoundaryLog + (*BoundaryLogsRequest)(nil), // 1: boundary.proto.BoundaryLogsRequest + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_proto_boundary_logs_proto_depIdxs = []int32{ + 2, // 0: boundary.proto.BoundaryLog.time:type_name -> google.protobuf.Timestamp + 0, // 1: boundary.proto.BoundaryLogsRequest.logs:type_name -> boundary.proto.BoundaryLog + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_proto_boundary_logs_proto_init() } +func file_proto_boundary_logs_proto_init() { + if File_proto_boundary_logs_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_boundary_logs_proto_rawDesc), len(file_proto_boundary_logs_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_boundary_logs_proto_goTypes, + DependencyIndexes: file_proto_boundary_logs_proto_depIdxs, + MessageInfos: file_proto_boundary_logs_proto_msgTypes, + }.Build() + File_proto_boundary_logs_proto = out.File + file_proto_boundary_logs_proto_goTypes = nil + file_proto_boundary_logs_proto_depIdxs = nil +} diff --git a/proto/boundary_logs.proto b/proto/boundary_logs.proto new file mode 100644 index 0000000..1f2baf0 --- /dev/null +++ b/proto/boundary_logs.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +package boundary.proto; + +option go_package = "github.com/coder/boundary/proto"; + +import "google/protobuf/timestamp.proto"; + +// BoundaryLog represents a single boundary audit log entry. +// Field numbers MUST match coder's agent/proto/agent.proto to ensure +// wire format compatibility. +message BoundaryLog { + bytes workspace_id = 1; + google.protobuf.Timestamp time = 2; + bool allowed = 3; + string http_method = 4; + string http_url = 5; + string matched_rule = 6; +} + +// BoundaryLogsRequest is sent to the agent socket. +// Field numbers MUST match coder's ReportBoundaryLogsRequest. +message BoundaryLogsRequest { + repeated BoundaryLog logs = 1; +} From dbef9a386f70cc963eb19b80f145d8ab76dffbd8 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Wed, 10 Dec 2025 23:30:47 -0700 Subject: [PATCH 2/5] feat: add --audit-socket CLI flag, generalize log format, and simple mode - Replace CODER_BOUNDARY_LOG_SOCKET env var with --audit-socket CLI flag (env: BOUNDARY_AUDIT_SOCKET) for configuring the socket path - Remove workspace_id from proto (coderd has this info from agent auth) - Generalize BoundaryLog proto with oneof resource to support future resource types (e.g., file operations) - Rename proto file to logs.proto - Add comment noting wire compatibility with coder's agent proto - Add simple mode (--simple) using HTTP_PROXY instead of network namespaces for environments where jail/namespace features aren't available --- .idea/workspace.xml | 104 ++++++++++++++ Makefile | 2 +- app/app.go | 7 + app/parent.go | 54 ++----- app/simple.go | 160 +++++++++++++++++++++ audit/socket_auditor.go | 27 ++-- cli/cli.go | 17 +++ jail/simple_jail.go | 77 ++++++++++ proto/boundary_logs.pb.go | 224 ----------------------------- proto/boundary_logs.proto | 25 ---- proto/logs.pb.go | 290 ++++++++++++++++++++++++++++++++++++++ proto/logs.proto | 31 ++++ 12 files changed, 710 insertions(+), 308 deletions(-) create mode 100644 .idea/workspace.xml create mode 100644 app/simple.go create mode 100644 jail/simple_jail.go delete mode 100644 proto/boundary_logs.pb.go delete mode 100644 proto/boundary_logs.proto create mode 100644 proto/logs.pb.go create mode 100644 proto/logs.proto diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..f159227 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,104 @@ + + + + + + + + + + + + + + { + "associatedIndex": 7 +} + + + + + + + + + + + + + + 1763419778387 + + + + + + + + + + + true + + \ No newline at end of file diff --git a/Makefile b/Makefile index a339d0a..4bd0b6e 100644 --- a/Makefile +++ b/Makefile @@ -164,7 +164,7 @@ help: .PHONY: proto proto: @echo "Generating protobuf code..." - protoc --go_out=. --go_opt=paths=source_relative proto/boundary_logs.proto + protoc --go_out=. --go_opt=paths=source_relative proto/logs.proto @echo "✓ Protobuf code generated!" # Run all code generation diff --git a/app/app.go b/app/app.go index 09ef1a9..1aacf88 100644 --- a/app/app.go +++ b/app/app.go @@ -16,6 +16,7 @@ import ( // Config holds all configuration for the CLI type Config struct { Config serpent.YAMLConfigPath `yaml:"-"` + SimpleMode serpent.Bool `yaml:"simple"` AllowListStrings serpent.StringArray `yaml:"allowlist"` // From config file AllowStrings serpent.StringArray `yaml:"-"` // From CLI flags only LogLevel serpent.String `yaml:"log_level"` @@ -24,6 +25,7 @@ type Config struct { PprofEnabled serpent.Bool `yaml:"pprof_enabled"` PprofPort serpent.Int64 `yaml:"pprof_port"` ConfigureDNSForLocalStubResolver serpent.Bool `yaml:"configure_dns_for_local_stub_resolver"` + AuditSocket serpent.String `yaml:"audit_socket"` } func isChild() bool { @@ -43,6 +45,11 @@ func Run(ctx context.Context, config Config, args []string) error { } logger.Debug("config", "json_config", configInJSON) + // In simple mode, we don't use child processes with network namespaces + if config.SimpleMode.Value() { + return RunSimple(ctx, logger, args, config) + } + if isChild() { return RunChild(logger, args) } diff --git a/app/parent.go b/app/parent.go index 7c8498a..f6d200d 100644 --- a/app/parent.go +++ b/app/parent.go @@ -18,14 +18,6 @@ import ( "github.com/coder/boundary/util" ) -// Environment variables for socket auditor integration with Coder agent. -const ( - // BoundaryLogSocketEnvVar is the path to the Unix socket where boundary - // should send audit logs. - BoundaryLogSocketEnvVar = "CODER_BOUNDARY_LOG_SOCKET" - // BoundaryWorkspaceIDEnvVar is the workspace ID for boundary audit logs. - BoundaryWorkspaceIDEnvVar = "CODER_WORKSPACE_ID" -) func RunParent(ctx context.Context, logger *slog.Logger, args []string, config Config) error { ctx, cancel := context.WithCancel(ctx) @@ -60,7 +52,7 @@ func RunParent(ctx context.Context, logger *slog.Logger, args []string, config C ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) // Create auditor - check for socket path to forward logs to agent - auditor := createAuditor(logger) + auditor := createAuditor(logger, config) // Create TLS certificate manager certManager, err := tls.NewCertificateManager(tls.Config{ @@ -176,47 +168,19 @@ func RunParent(ctx context.Context, logger *slog.Logger, args []string, config C return nil } -// createAuditor creates the appropriate auditor based on environment variables. -// If CODER_BOUNDARY_LOG_SOCKET and CODER_WORKSPACE_ID are set, it creates a -// MultiAuditor that sends to both the local logger and the agent socket. -func createAuditor(logger *slog.Logger) audit.Auditor { +// createAuditor creates the appropriate auditor based on config. +// If --audit-socket is set, it creates a MultiAuditor that sends to both +// the local logger and the agent socket. +func createAuditor(logger *slog.Logger, config Config) audit.Auditor { logAuditor := audit.NewLogAuditor(logger) - socketPath := os.Getenv(BoundaryLogSocketEnvVar) - workspaceIDStr := os.Getenv(BoundaryWorkspaceIDEnvVar) - - if socketPath == "" || workspaceIDStr == "" { - return logAuditor - } - - workspaceID, err := parseUUID(workspaceIDStr) - if err != nil { - logger.Warn("Invalid workspace ID, using local auditor only", "error", err) + socketPath := config.AuditSocket.Value() + if socketPath == "" { return logAuditor } - socketAuditor := audit.NewSocketAuditor(socketPath, workspaceID) - logger.Info("Boundary log forwarding enabled", - "socket_path", socketPath, - "workspace_id", workspaceIDStr) + socketAuditor := audit.NewSocketAuditor(socketPath) + logger.Info("Boundary log forwarding enabled", "socket_path", socketPath) return audit.NewMultiAuditor(logAuditor, socketAuditor) } - -// parseUUID parses a UUID string into a 16-byte slice. -func parseUUID(s string) ([]byte, error) { - s = strings.ReplaceAll(s, "-", "") - if len(s) != 32 { - return nil, fmt.Errorf("invalid UUID length: %d", len(s)) - } - b := make([]byte, 16) - for i := 0; i < 16; i++ { - var v byte - _, err := fmt.Sscanf(s[i*2:i*2+2], "%02x", &v) - if err != nil { - return nil, fmt.Errorf("invalid UUID character at position %d: %v", i*2, err) - } - b[i] = v - } - return b, nil -} diff --git a/app/simple.go b/app/simple.go new file mode 100644 index 0000000..d70d0a7 --- /dev/null +++ b/app/simple.go @@ -0,0 +1,160 @@ +package app + +import ( + "context" + "fmt" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/coder/boundary/boundary" + "github.com/coder/boundary/audit" + "github.com/coder/boundary/jail" + "github.com/coder/boundary/rulesengine" + "github.com/coder/boundary/tls" + "github.com/coder/boundary/util" +) + +// RunSimple runs boundary in simple mode using HTTP_PROXY environment variables +// instead of network namespaces. This mode doesn't require elevated privileges +// but only intercepts traffic from proxy-aware applications. +func RunSimple(ctx context.Context, logger *slog.Logger, args []string, config Config) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + _, uid, gid, _, configDir := util.GetUserInfo() + + // Get command arguments + if len(args) == 0 { + return fmt.Errorf("no command specified") + } + + // Merge allowlist from config file with allow from CLI flags + allowListStrings := config.AllowListStrings.Value() + allowStrings := config.AllowStrings.Value() + + // Combine allowlist (config file) with allow (CLI flags) + allAllowStrings := append(allowListStrings, allowStrings...) + + if len(allAllowStrings) == 0 { + logger.Warn("No allow rules specified; all network traffic will be denied by default") + } + + // Parse allow rules + allowRules, err := rulesengine.ParseAllowSpecs(allAllowStrings) + if err != nil { + logger.Error("Failed to parse allow rules", "error", err) + return fmt.Errorf("failed to parse allow rules: %v", err) + } + + // Create rule engine + ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) + + // Create auditor + auditor := createAuditor(logger, config) + + // Create TLS certificate manager + certManager, err := tls.NewCertificateManager(tls.Config{ + Logger: logger, + ConfigDir: configDir, + Uid: uid, + Gid: gid, + }) + if err != nil { + logger.Error("Failed to create certificate manager", "error", err) + return fmt.Errorf("failed to create certificate manager: %v", err) + } + + // Setup TLS to get cert path for jailer + tlsConfig, caCertPath, configDir, err := certManager.SetupTLSAndWriteCACert() + if err != nil { + return fmt.Errorf("failed to setup TLS and CA certificate: %v", err) + } + + // Create simple jailer (uses HTTP_PROXY instead of network namespaces) + jailer, err := jail.NewSimpleJail(jail.Config{ + Logger: logger, + HttpProxyPort: int(config.ProxyPort.Value()), + ConfigDir: configDir, + CACertPath: caCertPath, + }) + if err != nil { + return fmt.Errorf("failed to create simple jailer: %v", err) + } + + // Create boundary instance + boundaryInstance, err := boundary.New(ctx, boundary.Config{ + RuleEngine: ruleEngine, + Auditor: auditor, + TLSConfig: tlsConfig, + Logger: logger, + Jailer: jailer, + ProxyPort: int(config.ProxyPort.Value()), + PprofEnabled: config.PprofEnabled.Value(), + PprofPort: int(config.PprofPort.Value()), + }) + if err != nil { + return fmt.Errorf("failed to create boundary instance: %v", err) + } + + // Setup signal handling BEFORE any setup + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Open boundary (starts proxy server) + err = boundaryInstance.Start() + if err != nil { + return fmt.Errorf("failed to open boundary: %v", err) + } + defer func() { + logger.Info("Closing boundary...") + err := boundaryInstance.Close() + if err != nil { + logger.Error("Failed to close boundary", "error", err) + } + }() + + logger.Info("Running in simple mode (HTTP_PROXY based)", + "proxy_port", config.ProxyPort.Value()) + + // Execute command with proxy environment + go func() { + defer cancel() + cmd := boundaryInstance.Command(args) + + logger.Debug("Executing command with proxy environment", "command", strings.Join(args, " ")) + err := cmd.Start() + if err != nil { + logger.Error("Command failed to start", "error", err) + return + } + + // No post-start configuration needed for simple mode + err = boundaryInstance.ConfigureAfterCommandExecution(cmd.Process.Pid) + if err != nil { + logger.Error("configuration after command execution failed", "error", err) + return + } + + logger.Debug("waiting on a child process to finish") + err = cmd.Wait() + if err != nil { + logger.Error("Command execution failed", "error", err) + return + } + }() + + // Wait for signal or context cancellation + select { + case sig := <-sigChan: + logger.Info("Received signal, shutting down...", "signal", sig) + cancel() + case <-ctx.Done(): + // Context cancelled by command completion + logger.Info("Command completed, shutting down...") + } + + return nil +} diff --git a/audit/socket_auditor.go b/audit/socket_auditor.go index c61313d..701ee3c 100644 --- a/audit/socket_auditor.go +++ b/audit/socket_auditor.go @@ -21,8 +21,7 @@ const ( // workspace agent's boundary log proxy socket. It batches logs using either // a 5-second timeout or when 10 logs have accumulated, whichever comes first. type SocketAuditor struct { - socketPath string - workspaceID []byte + socketPath string mu sync.Mutex conn net.Conn @@ -32,11 +31,10 @@ type SocketAuditor struct { } // NewSocketAuditor creates a new SocketAuditor that sends logs to the agent's -// boundary log proxy socket. The workspaceID should be the 16-byte UUID. -func NewSocketAuditor(socketPath string, workspaceID []byte) *SocketAuditor { +// boundary log proxy socket. +func NewSocketAuditor(socketPath string) *SocketAuditor { return &SocketAuditor{ - socketPath: socketPath, - workspaceID: workspaceID, + socketPath: socketPath, } } @@ -50,16 +48,19 @@ func (s *SocketAuditor) AuditRequest(req Request) { return } - log := &boundaryproto.BoundaryLog{ - WorkspaceId: s.workspaceID, - Time: timestamppb.Now(), - Allowed: req.Allowed, - HttpMethod: req.Method, - HttpUrl: req.URL, + httpReq := &boundaryproto.BoundaryLog_HttpRequest{ + Method: req.Method, + Url: req.URL, } // Only include matched rule for denied requests. if !req.Allowed { - log.MatchedRule = req.Rule + httpReq.MatchedRule = req.Rule + } + + log := &boundaryproto.BoundaryLog{ + Allowed: req.Allowed, + Time: timestamppb.Now(), + Resource: &boundaryproto.BoundaryLog_HttpRequest_{HttpRequest: httpReq}, } s.logs = append(s.logs, log) diff --git a/cli/cli.go b/cli/cli.go index e103cb4..8fc5699 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -26,6 +26,9 @@ func NewCommand() *serpent.Command { # Use allowlist from config file with additional CLI allow rules boundary --allow "domain=example.com" -- curl https://example.com + # Use simple mode (HTTP_PROXY based, no network namespaces) + boundary --simple --allow "domain=github.com" -- curl https://github.com + # Block everything by default (implicit)` return cmd @@ -57,6 +60,13 @@ func BaseCommand() *serpent.Command { Value: &config.Config, YAML: "", }, + { + Flag: "simple", + Env: "BOUNDARY_SIMPLE", + Description: "Use simple mode (HTTP_PROXY based) instead of network namespaces. Does not require elevated privileges but only intercepts traffic from proxy-aware applications.", + Value: &config.SimpleMode, + YAML: "simple", + }, { Flag: "allow", Env: "BOUNDARY_ALLOW", @@ -115,6 +125,13 @@ func BaseCommand() *serpent.Command { Value: &config.ConfigureDNSForLocalStubResolver, YAML: "configure_dns_for_local_stub_resolver", }, + { + Flag: "audit-socket", + Env: "BOUNDARY_AUDIT_SOCKET", + Description: "Path to Unix socket for forwarding audit logs to the Coder agent.", + Value: &config.AuditSocket, + YAML: "audit_socket", + }, }, Handler: func(inv *serpent.Invocation) error { args := inv.Args diff --git a/jail/simple_jail.go b/jail/simple_jail.go new file mode 100644 index 0000000..a415971 --- /dev/null +++ b/jail/simple_jail.go @@ -0,0 +1,77 @@ +package jail + +import ( + "fmt" + "log/slog" + "os" + "os/exec" +) + +// SimpleJail implements Jailer using HTTP_PROXY environment variables instead +// of network namespaces. This mode doesn't require elevated privileges or +// Linux-specific features, making it portable across platforms. +// +// Note: This mode only intercepts traffic from applications that respect +// HTTP_PROXY/HTTPS_PROXY environment variables. Applications that make direct +// connections will bypass the proxy. +type SimpleJail struct { + logger *slog.Logger + httpProxyPort int + configDir string + caCertPath string + commandEnv []string +} + +// NewSimpleJail creates a new SimpleJail that uses environment variables +// for proxy configuration. +func NewSimpleJail(config Config) (*SimpleJail, error) { + return &SimpleJail{ + logger: config.Logger, + httpProxyPort: config.HttpProxyPort, + configDir: config.ConfigDir, + caCertPath: config.CACertPath, + }, nil +} + +// ConfigureBeforeCommandExecution prepares environment variables for the proxy. +func (s *SimpleJail) ConfigureBeforeCommandExecution() error { + s.commandEnv = getEnvs(s.configDir, s.caCertPath) + + // Add proxy environment variables + proxyURL := fmt.Sprintf("http://127.0.0.1:%d", s.httpProxyPort) + s.commandEnv = append(s.commandEnv, + fmt.Sprintf("HTTP_PROXY=%s", proxyURL), + fmt.Sprintf("HTTPS_PROXY=%s", proxyURL), + fmt.Sprintf("http_proxy=%s", proxyURL), + fmt.Sprintf("https_proxy=%s", proxyURL), + ) + + s.logger.Debug("Configured simple jail with proxy environment variables", + "proxy_url", proxyURL) + + return nil +} + +// Command returns an exec.Cmd configured with proxy environment variables. +func (s *SimpleJail) Command(command []string) *exec.Cmd { + s.logger.Debug("Creating command with proxy environment", "command", command) + + cmd := exec.Command(command[0], command[1:]...) + cmd.Env = s.commandEnv + cmd.Stderr = os.Stderr + cmd.Stdout = os.Stdout + cmd.Stdin = os.Stdin + + return cmd +} + +// ConfigureAfterCommandExecution is a no-op for SimpleJail since there's no +// network namespace to configure. +func (s *SimpleJail) ConfigureAfterCommandExecution(processPID int) error { + return nil +} + +// Close is a no-op for SimpleJail since there are no resources to clean up. +func (s *SimpleJail) Close() error { + return nil +} diff --git a/proto/boundary_logs.pb.go b/proto/boundary_logs.pb.go deleted file mode 100644 index 7fa9a19..0000000 --- a/proto/boundary_logs.pb.go +++ /dev/null @@ -1,224 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.10 -// protoc v6.33.1 -// source: proto/boundary_logs.proto - -package proto - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// BoundaryLog represents a single boundary audit log entry. -// Field numbers MUST match coder's agent/proto/agent.proto to ensure -// wire format compatibility. -type BoundaryLog struct { - state protoimpl.MessageState `protogen:"open.v1"` - WorkspaceId []byte `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` - Time *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"` - Allowed bool `protobuf:"varint,3,opt,name=allowed,proto3" json:"allowed,omitempty"` - HttpMethod string `protobuf:"bytes,4,opt,name=http_method,json=httpMethod,proto3" json:"http_method,omitempty"` - HttpUrl string `protobuf:"bytes,5,opt,name=http_url,json=httpUrl,proto3" json:"http_url,omitempty"` - MatchedRule string `protobuf:"bytes,6,opt,name=matched_rule,json=matchedRule,proto3" json:"matched_rule,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BoundaryLog) Reset() { - *x = BoundaryLog{} - mi := &file_proto_boundary_logs_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BoundaryLog) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BoundaryLog) ProtoMessage() {} - -func (x *BoundaryLog) ProtoReflect() protoreflect.Message { - mi := &file_proto_boundary_logs_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BoundaryLog.ProtoReflect.Descriptor instead. -func (*BoundaryLog) Descriptor() ([]byte, []int) { - return file_proto_boundary_logs_proto_rawDescGZIP(), []int{0} -} - -func (x *BoundaryLog) GetWorkspaceId() []byte { - if x != nil { - return x.WorkspaceId - } - return nil -} - -func (x *BoundaryLog) GetTime() *timestamppb.Timestamp { - if x != nil { - return x.Time - } - return nil -} - -func (x *BoundaryLog) GetAllowed() bool { - if x != nil { - return x.Allowed - } - return false -} - -func (x *BoundaryLog) GetHttpMethod() string { - if x != nil { - return x.HttpMethod - } - return "" -} - -func (x *BoundaryLog) GetHttpUrl() string { - if x != nil { - return x.HttpUrl - } - return "" -} - -func (x *BoundaryLog) GetMatchedRule() string { - if x != nil { - return x.MatchedRule - } - return "" -} - -// BoundaryLogsRequest is sent to the agent socket. -// Field numbers MUST match coder's ReportBoundaryLogsRequest. -type BoundaryLogsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Logs []*BoundaryLog `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *BoundaryLogsRequest) Reset() { - *x = BoundaryLogsRequest{} - mi := &file_proto_boundary_logs_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *BoundaryLogsRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*BoundaryLogsRequest) ProtoMessage() {} - -func (x *BoundaryLogsRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_boundary_logs_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use BoundaryLogsRequest.ProtoReflect.Descriptor instead. -func (*BoundaryLogsRequest) Descriptor() ([]byte, []int) { - return file_proto_boundary_logs_proto_rawDescGZIP(), []int{1} -} - -func (x *BoundaryLogsRequest) GetLogs() []*BoundaryLog { - if x != nil { - return x.Logs - } - return nil -} - -var File_proto_boundary_logs_proto protoreflect.FileDescriptor - -const file_proto_boundary_logs_proto_rawDesc = "" + - "\n" + - "\x19proto/boundary_logs.proto\x12\x0eboundary.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xd9\x01\n" + - "\vBoundaryLog\x12!\n" + - "\fworkspace_id\x18\x01 \x01(\fR\vworkspaceId\x12.\n" + - "\x04time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x12\x18\n" + - "\aallowed\x18\x03 \x01(\bR\aallowed\x12\x1f\n" + - "\vhttp_method\x18\x04 \x01(\tR\n" + - "httpMethod\x12\x19\n" + - "\bhttp_url\x18\x05 \x01(\tR\ahttpUrl\x12!\n" + - "\fmatched_rule\x18\x06 \x01(\tR\vmatchedRule\"F\n" + - "\x13BoundaryLogsRequest\x12/\n" + - "\x04logs\x18\x01 \x03(\v2\x1b.boundary.proto.BoundaryLogR\x04logsB!Z\x1fgithub.com/coder/boundary/protob\x06proto3" - -var ( - file_proto_boundary_logs_proto_rawDescOnce sync.Once - file_proto_boundary_logs_proto_rawDescData []byte -) - -func file_proto_boundary_logs_proto_rawDescGZIP() []byte { - file_proto_boundary_logs_proto_rawDescOnce.Do(func() { - file_proto_boundary_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_boundary_logs_proto_rawDesc), len(file_proto_boundary_logs_proto_rawDesc))) - }) - return file_proto_boundary_logs_proto_rawDescData -} - -var file_proto_boundary_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 2) -var file_proto_boundary_logs_proto_goTypes = []any{ - (*BoundaryLog)(nil), // 0: boundary.proto.BoundaryLog - (*BoundaryLogsRequest)(nil), // 1: boundary.proto.BoundaryLogsRequest - (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp -} -var file_proto_boundary_logs_proto_depIdxs = []int32{ - 2, // 0: boundary.proto.BoundaryLog.time:type_name -> google.protobuf.Timestamp - 0, // 1: boundary.proto.BoundaryLogsRequest.logs:type_name -> boundary.proto.BoundaryLog - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name -} - -func init() { file_proto_boundary_logs_proto_init() } -func file_proto_boundary_logs_proto_init() { - if File_proto_boundary_logs_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_boundary_logs_proto_rawDesc), len(file_proto_boundary_logs_proto_rawDesc)), - NumEnums: 0, - NumMessages: 2, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_proto_boundary_logs_proto_goTypes, - DependencyIndexes: file_proto_boundary_logs_proto_depIdxs, - MessageInfos: file_proto_boundary_logs_proto_msgTypes, - }.Build() - File_proto_boundary_logs_proto = out.File - file_proto_boundary_logs_proto_goTypes = nil - file_proto_boundary_logs_proto_depIdxs = nil -} diff --git a/proto/boundary_logs.proto b/proto/boundary_logs.proto deleted file mode 100644 index 1f2baf0..0000000 --- a/proto/boundary_logs.proto +++ /dev/null @@ -1,25 +0,0 @@ -syntax = "proto3"; - -package boundary.proto; - -option go_package = "github.com/coder/boundary/proto"; - -import "google/protobuf/timestamp.proto"; - -// BoundaryLog represents a single boundary audit log entry. -// Field numbers MUST match coder's agent/proto/agent.proto to ensure -// wire format compatibility. -message BoundaryLog { - bytes workspace_id = 1; - google.protobuf.Timestamp time = 2; - bool allowed = 3; - string http_method = 4; - string http_url = 5; - string matched_rule = 6; -} - -// BoundaryLogsRequest is sent to the agent socket. -// Field numbers MUST match coder's ReportBoundaryLogsRequest. -message BoundaryLogsRequest { - repeated BoundaryLog logs = 1; -} diff --git a/proto/logs.pb.go b/proto/logs.pb.go new file mode 100644 index 0000000..07ff919 --- /dev/null +++ b/proto/logs.pb.go @@ -0,0 +1,290 @@ +// NOTE: BoundaryLog and BoundaryLogsRequest must stay wire-compatible with +// github.com/coder/coder/v2/agent/proto (BoundaryLog and ReportBoundaryLogsRequest) +// for communication over the Unix socket to the agent. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc v6.33.1 +// source: proto/logs.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type BoundaryLog struct { + state protoimpl.MessageState `protogen:"open.v1"` + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + Time *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=time,proto3" json:"time,omitempty"` + // Types that are valid to be assigned to Resource: + // + // *BoundaryLog_HttpRequest_ + Resource isBoundaryLog_Resource `protobuf_oneof:"resource"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoundaryLog) Reset() { + *x = BoundaryLog{} + mi := &file_proto_logs_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoundaryLog) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoundaryLog) ProtoMessage() {} + +func (x *BoundaryLog) ProtoReflect() protoreflect.Message { + mi := &file_proto_logs_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoundaryLog.ProtoReflect.Descriptor instead. +func (*BoundaryLog) Descriptor() ([]byte, []int) { + return file_proto_logs_proto_rawDescGZIP(), []int{0} +} + +func (x *BoundaryLog) GetAllowed() bool { + if x != nil { + return x.Allowed + } + return false +} + +func (x *BoundaryLog) GetTime() *timestamppb.Timestamp { + if x != nil { + return x.Time + } + return nil +} + +func (x *BoundaryLog) GetResource() isBoundaryLog_Resource { + if x != nil { + return x.Resource + } + return nil +} + +func (x *BoundaryLog) GetHttpRequest() *BoundaryLog_HttpRequest { + if x != nil { + if x, ok := x.Resource.(*BoundaryLog_HttpRequest_); ok { + return x.HttpRequest + } + } + return nil +} + +type isBoundaryLog_Resource interface { + isBoundaryLog_Resource() +} + +type BoundaryLog_HttpRequest_ struct { + HttpRequest *BoundaryLog_HttpRequest `protobuf:"bytes,3,opt,name=http_request,json=httpRequest,proto3,oneof"` +} + +func (*BoundaryLog_HttpRequest_) isBoundaryLog_Resource() {} + +// BoundaryLogsRequest is sent to the agent socket. +// Field numbers MUST match coder's ReportBoundaryLogsRequest. +type BoundaryLogsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Logs []*BoundaryLog `protobuf:"bytes,1,rep,name=logs,proto3" json:"logs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoundaryLogsRequest) Reset() { + *x = BoundaryLogsRequest{} + mi := &file_proto_logs_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoundaryLogsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoundaryLogsRequest) ProtoMessage() {} + +func (x *BoundaryLogsRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_logs_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoundaryLogsRequest.ProtoReflect.Descriptor instead. +func (*BoundaryLogsRequest) Descriptor() ([]byte, []int) { + return file_proto_logs_proto_rawDescGZIP(), []int{1} +} + +func (x *BoundaryLogsRequest) GetLogs() []*BoundaryLog { + if x != nil { + return x.Logs + } + return nil +} + +type BoundaryLog_HttpRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Method string `protobuf:"bytes,1,opt,name=method,proto3" json:"method,omitempty"` + Url string `protobuf:"bytes,2,opt,name=url,proto3" json:"url,omitempty"` + MatchedRule string `protobuf:"bytes,3,opt,name=matched_rule,json=matchedRule,proto3" json:"matched_rule,omitempty"` // Only populated when allowed = false + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *BoundaryLog_HttpRequest) Reset() { + *x = BoundaryLog_HttpRequest{} + mi := &file_proto_logs_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *BoundaryLog_HttpRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*BoundaryLog_HttpRequest) ProtoMessage() {} + +func (x *BoundaryLog_HttpRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_logs_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use BoundaryLog_HttpRequest.ProtoReflect.Descriptor instead. +func (*BoundaryLog_HttpRequest) Descriptor() ([]byte, []int) { + return file_proto_logs_proto_rawDescGZIP(), []int{0, 0} +} + +func (x *BoundaryLog_HttpRequest) GetMethod() string { + if x != nil { + return x.Method + } + return "" +} + +func (x *BoundaryLog_HttpRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *BoundaryLog_HttpRequest) GetMatchedRule() string { + if x != nil { + return x.MatchedRule + } + return "" +} + +var File_proto_logs_proto protoreflect.FileDescriptor + +const file_proto_logs_proto_rawDesc = "" + + "\n" + + "\x10proto/logs.proto\x12\x11coder.boundary.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x90\x02\n" + + "\vBoundaryLog\x12\x18\n" + + "\aallowed\x18\x01 \x01(\bR\aallowed\x12.\n" + + "\x04time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\x04time\x12O\n" + + "\fhttp_request\x18\x03 \x01(\v2*.coder.boundary.v1.BoundaryLog.HttpRequestH\x00R\vhttpRequest\x1aZ\n" + + "\vHttpRequest\x12\x16\n" + + "\x06method\x18\x01 \x01(\tR\x06method\x12\x10\n" + + "\x03url\x18\x02 \x01(\tR\x03url\x12!\n" + + "\fmatched_rule\x18\x03 \x01(\tR\vmatchedRuleB\n" + + "\n" + + "\bresource\"I\n" + + "\x13BoundaryLogsRequest\x122\n" + + "\x04logs\x18\x01 \x03(\v2\x1e.coder.boundary.v1.BoundaryLogR\x04logsB!Z\x1fgithub.com/coder/boundary/protob\x06proto3" + +var ( + file_proto_logs_proto_rawDescOnce sync.Once + file_proto_logs_proto_rawDescData []byte +) + +func file_proto_logs_proto_rawDescGZIP() []byte { + file_proto_logs_proto_rawDescOnce.Do(func() { + file_proto_logs_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_logs_proto_rawDesc), len(file_proto_logs_proto_rawDesc))) + }) + return file_proto_logs_proto_rawDescData +} + +var file_proto_logs_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_proto_logs_proto_goTypes = []any{ + (*BoundaryLog)(nil), // 0: coder.boundary.v1.BoundaryLog + (*BoundaryLogsRequest)(nil), // 1: coder.boundary.v1.BoundaryLogsRequest + (*BoundaryLog_HttpRequest)(nil), // 2: coder.boundary.v1.BoundaryLog.HttpRequest + (*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp +} +var file_proto_logs_proto_depIdxs = []int32{ + 3, // 0: coder.boundary.v1.BoundaryLog.time:type_name -> google.protobuf.Timestamp + 2, // 1: coder.boundary.v1.BoundaryLog.http_request:type_name -> coder.boundary.v1.BoundaryLog.HttpRequest + 0, // 2: coder.boundary.v1.BoundaryLogsRequest.logs:type_name -> coder.boundary.v1.BoundaryLog + 3, // [3:3] is the sub-list for method output_type + 3, // [3:3] is the sub-list for method input_type + 3, // [3:3] is the sub-list for extension type_name + 3, // [3:3] is the sub-list for extension extendee + 0, // [0:3] is the sub-list for field type_name +} + +func init() { file_proto_logs_proto_init() } +func file_proto_logs_proto_init() { + if File_proto_logs_proto != nil { + return + } + file_proto_logs_proto_msgTypes[0].OneofWrappers = []any{ + (*BoundaryLog_HttpRequest_)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_logs_proto_rawDesc), len(file_proto_logs_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_logs_proto_goTypes, + DependencyIndexes: file_proto_logs_proto_depIdxs, + MessageInfos: file_proto_logs_proto_msgTypes, + }.Build() + File_proto_logs_proto = out.File + file_proto_logs_proto_goTypes = nil + file_proto_logs_proto_depIdxs = nil +} diff --git a/proto/logs.proto b/proto/logs.proto new file mode 100644 index 0000000..64cfb96 --- /dev/null +++ b/proto/logs.proto @@ -0,0 +1,31 @@ +// NOTE: BoundaryLog and BoundaryLogsRequest must stay wire-compatible with +// github.com/coder/coder/v2/agent/proto (BoundaryLog and ReportBoundaryLogsRequest) +// for communication over the Unix socket to the agent. + +syntax = "proto3"; + +package coder.boundary.v1; + +option go_package = "github.com/coder/boundary/proto"; + +import "google/protobuf/timestamp.proto"; + +message BoundaryLog { + message HttpRequest { + string method = 1; + string url = 2; + string matched_rule = 3; // Only populated when allowed = false + } + + bool allowed = 1; + google.protobuf.Timestamp time = 2; + oneof resource { + HttpRequest http_request = 3; + } +} + +// BoundaryLogsRequest is sent to the agent socket. +// Field numbers MUST match coder's ReportBoundaryLogsRequest. +message BoundaryLogsRequest { + repeated BoundaryLog logs = 1; +} From 5f704d0191b1b447422c53a56755ca7e4b2cad48 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Fri, 12 Dec 2025 15:18:35 -0700 Subject: [PATCH 3/5] fix: remove unused audit import from simple.go --- app/simple.go | 1 - 1 file changed, 1 deletion(-) diff --git a/app/simple.go b/app/simple.go index d70d0a7..7e306fc 100644 --- a/app/simple.go +++ b/app/simple.go @@ -10,7 +10,6 @@ import ( "syscall" "github.com/coder/boundary/boundary" - "github.com/coder/boundary/audit" "github.com/coder/boundary/jail" "github.com/coder/boundary/rulesengine" "github.com/coder/boundary/tls" From 747c54b1b222709bbe995687bf07fbedd4f99499 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Fri, 12 Dec 2025 16:07:12 -0700 Subject: [PATCH 4/5] feat: add CONNECT method handling for simple mode HTTPS tunneling --- proxy/proxy.go | 73 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/proxy/proxy.go b/proxy/proxy.go index 86f9e2e..507de5f 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -218,10 +218,81 @@ func (p *Server) handleHTTPConnection(conn net.Conn) { return } - p.logger.Debug("🌐 HTTP Request: %s %s", req.Method, req.URL.String()) + p.logger.Debug("🌐 HTTP Request", "method", req.Method, "url", req.URL.String()) + + // Handle CONNECT method for HTTPS tunneling (used in simple/proxy mode) + if req.Method == http.MethodConnect { + p.handleCONNECT(conn, req) + return + } + p.processHTTPRequest(conn, req, false) } +// handleCONNECT handles HTTP CONNECT requests for HTTPS tunneling. +// This is used in simple mode where clients send CONNECT through HTTP_PROXY. +func (p *Server) handleCONNECT(clientConn net.Conn, req *http.Request) { + targetHost := req.Host + if targetHost == "" { + targetHost = req.URL.Host + } + + p.logger.Debug("🔗 CONNECT request", "target", targetHost) + + // Check if request should be allowed + result := p.ruleEngine.Evaluate(req.Method, targetHost) + + // Audit the request + p.auditor.AuditRequest(audit.Request{ + Method: req.Method, + URL: targetHost, + Host: targetHost, + Allowed: result.Allowed, + Rule: result.Rule, + }) + + if !result.Allowed { + p.logger.Info("DENY", "method", req.Method, "host", targetHost) + // Send 403 Forbidden for CONNECT + _, _ = clientConn.Write([]byte("HTTP/1.1 403 Forbidden\r\n\r\n")) + return + } + + p.logger.Info("ALLOW", "method", req.Method, "host", targetHost, "rule", result.Rule) + + // Dial the target server + targetConn, err := net.DialTimeout("tcp", targetHost, 10*time.Second) + if err != nil { + p.logger.Error("Failed to connect to target", "target", targetHost, "error", err) + _, _ = clientConn.Write([]byte("HTTP/1.1 502 Bad Gateway\r\n\r\n")) + return + } + defer targetConn.Close() + + // Send 200 Connection Established to client + _, err = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) + if err != nil { + p.logger.Error("Failed to send connection established", "error", err) + return + } + + // Proxy data bidirectionally + done := make(chan struct{}, 2) + + go func() { + _, _ = io.Copy(targetConn, clientConn) + done <- struct{}{} + }() + + go func() { + _, _ = io.Copy(clientConn, targetConn) + done <- struct{}{} + }() + + // Wait for one direction to finish + <-done +} + func (p *Server) handleTLSConnection(conn net.Conn) { // Create TLS connection tlsConn := tls.Server(conn, p.tlsConfig) From 08d35117529c772f4b8658dfc1459bbac4f0b522 Mon Sep 17 00:00:00 2001 From: Zach Kipp Date: Fri, 12 Dec 2025 17:01:55 -0700 Subject: [PATCH 5/5] refactor: hardcode audit socket path to /tmp/boundary-audit.sock Remove --audit-socket CLI flag and always use the well-known path. --- .idea/workspace.xml | 44 +++++++++++++++++++++-------------------- app/app.go | 1 - app/parent.go | 20 ++++++------------- app/simple.go | 2 +- audit/socket_auditor.go | 10 ++++++---- cli/cli.go | 7 ------- 6 files changed, 36 insertions(+), 48 deletions(-) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index f159227..5451758 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -4,7 +4,9 @@