2019-07-18 21:29:16 +00:00
//+build !windows
package sshserver
import (
2019-10-09 21:56:47 +00:00
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
2019-10-02 20:56:28 +00:00
"encoding/binary"
2019-09-06 21:37:58 +00:00
"encoding/json"
2019-07-18 21:29:16 +00:00
"fmt"
"io"
2019-09-11 18:46:23 +00:00
"net"
2019-09-04 15:37:53 +00:00
"runtime"
2019-09-06 21:37:58 +00:00
"strings"
2019-08-28 15:48:30 +00:00
"time"
2019-07-18 21:29:16 +00:00
2019-10-09 21:56:47 +00:00
"github.com/cloudflare/cloudflared/sshgen"
2019-08-26 20:25:24 +00:00
"github.com/cloudflare/cloudflared/sshlog"
2019-07-18 21:29:16 +00:00
"github.com/gliderlabs/ssh"
2019-08-26 20:25:24 +00:00
"github.com/google/uuid"
2019-09-30 20:44:23 +00:00
"github.com/pkg/errors"
2019-07-18 21:29:16 +00:00
"github.com/sirupsen/logrus"
2019-09-30 20:44:23 +00:00
gossh "golang.org/x/crypto/ssh"
2019-07-18 21:29:16 +00:00
)
2019-09-06 21:37:58 +00:00
const (
2019-09-30 20:44:23 +00:00
auditEventStart = "session_start"
auditEventStop = "session_stop"
auditEventExec = "exec"
auditEventScp = "scp"
auditEventResize = "resize"
auditEventShell = "shell"
sshContextSessionID = "sessionID"
2019-09-18 19:11:12 +00:00
sshContextEventLogger = "eventLogger"
2019-10-09 21:56:47 +00:00
sshContextPreamble = "sshPreamble"
sshContextSSHClient = "sshClient"
2019-10-16 15:53:46 +00:00
SSHPreambleLength = 2
2019-10-16 17:08:33 +00:00
defaultSSHPort = "22"
2019-09-06 21:37:58 +00:00
)
type auditEvent struct {
2019-10-02 20:56:28 +00:00
Event string ` json:"event,omitempty" `
EventType string ` json:"event_type,omitempty" `
SessionID string ` json:"session_id,omitempty" `
User string ` json:"user,omitempty" `
Login string ` json:"login,omitempty" `
Datetime string ` json:"datetime,omitempty" `
2019-10-09 21:56:47 +00:00
Hostname string ` json:"hostname,omitempty" `
2019-10-02 20:56:28 +00:00
Destination string ` json:"destination,omitempty" `
2019-09-06 21:37:58 +00:00
}
2019-10-09 21:56:47 +00:00
// sshConn wraps the incoming net.Conn and a cleanup function
// This is done to allow the outgoing SSH client to be retrieved and closed when the conn itself is closed.
type sshConn struct {
net . Conn
cleanupFunc func ( )
}
// close calls the cleanupFunc before closing the conn
func ( c sshConn ) Close ( ) error {
c . cleanupFunc ( )
return c . Conn . Close ( )
}
2019-09-30 20:44:23 +00:00
type SSHProxy struct {
2019-07-18 21:29:16 +00:00
ssh . Server
2019-10-09 21:56:47 +00:00
hostname string
2019-09-04 15:37:53 +00:00
logger * logrus . Logger
shutdownC chan struct { }
caCert ssh . PublicKey
logManager sshlog . Manager
2019-07-18 21:29:16 +00:00
}
2019-10-09 21:56:47 +00:00
type SSHPreamble struct {
Destination string
JWT string
}
2019-09-30 20:44:23 +00:00
// New creates a new SSHProxy and configures its host keys and authentication by the data provided
2019-10-17 21:23:06 +00:00
func New ( logManager sshlog . Manager , logger * logrus . Logger , version , localAddress , hostname , hostKeyDir string , shutdownC chan struct { } , idleTimeout , maxTimeout time . Duration ) ( * SSHProxy , error ) {
2019-09-30 20:44:23 +00:00
sshProxy := SSHProxy {
2019-10-09 21:56:47 +00:00
hostname : hostname ,
2019-09-04 15:37:53 +00:00
logger : logger ,
shutdownC : shutdownC ,
logManager : logManager ,
2019-08-22 16:36:21 +00:00
}
2019-09-30 20:44:23 +00:00
sshProxy . Server = ssh . Server {
2019-10-09 21:56:47 +00:00
Addr : localAddress ,
MaxTimeout : maxTimeout ,
IdleTimeout : idleTimeout ,
Version : fmt . Sprintf ( "SSH-2.0-Cloudflare-Access_%s_%s" , version , runtime . GOOS ) ,
PublicKeyHandler : sshProxy . proxyAuthCallback ,
ConnCallback : sshProxy . connCallback ,
2019-09-30 20:44:23 +00:00
ChannelHandlers : map [ string ] ssh . ChannelHandler {
2019-10-02 20:56:28 +00:00
"default" : sshProxy . channelHandler ,
2019-09-30 20:44:23 +00:00
} ,
}
2019-10-17 21:23:06 +00:00
if err := sshProxy . configureHostKeys ( hostKeyDir ) ; err != nil {
2019-08-19 18:51:59 +00:00
return nil , err
}
2019-09-30 20:44:23 +00:00
return & sshProxy , nil
2019-07-18 21:29:16 +00:00
}
2019-09-30 20:44:23 +00:00
// Start the SSH proxy listener to start handling SSH connections from clients
func ( s * SSHProxy ) Start ( ) error {
2019-07-18 21:29:16 +00:00
s . logger . Infof ( "Starting SSH server at %s" , s . Addr )
go func ( ) {
<- s . shutdownC
if err := s . Close ( ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Cannot close SSH server" )
}
} ( )
return s . ListenAndServe ( )
}
2019-10-09 21:56:47 +00:00
// proxyAuthCallback attempts to connect to ultimate SSH destination. If successful, it allows the incoming connection
// to connect to the proxy and saves the outgoing SSH client to the context. Otherwise, no connection to the
// the proxy is allowed.
func ( s * SSHProxy ) proxyAuthCallback ( ctx ssh . Context , key ssh . PublicKey ) bool {
client , err := s . dialDestination ( ctx )
if err != nil {
return false
}
ctx . SetValue ( sshContextSSHClient , client )
return true
}
// connCallback reads the preamble sent from the proxy server and saves an audit event logger to the context.
// If any errors occur, the connection is terminated by returning nil from the callback.
2019-10-02 20:56:28 +00:00
func ( s * SSHProxy ) connCallback ( ctx ssh . Context , conn net . Conn ) net . Conn {
// AUTH-2050: This is a temporary workaround of a timing issue in the tunnel muxer to allow further testing.
// TODO: Remove this
time . Sleep ( 10 * time . Millisecond )
2019-10-09 21:56:47 +00:00
preamble , err := s . readPreamble ( conn )
if err != nil {
if netErr , ok := err . ( net . Error ) ; ok && netErr . Timeout ( ) {
s . logger . Warn ( "Could not establish session. Client likely does not have --destination set and is using old-style ssh config" )
} else if err != io . EOF {
s . logger . WithError ( err ) . Error ( "failed to read SSH preamble" )
2019-10-02 20:56:28 +00:00
}
return nil
}
2019-10-09 21:56:47 +00:00
ctx . SetValue ( sshContextPreamble , preamble )
2019-10-02 20:56:28 +00:00
2019-10-09 21:56:47 +00:00
logger , sessionID , err := s . auditLogger ( )
if err != nil {
2019-10-02 20:56:28 +00:00
s . logger . WithError ( err ) . Error ( "failed to configure logger" )
return nil
}
2019-10-09 21:56:47 +00:00
ctx . SetValue ( sshContextEventLogger , logger )
ctx . SetValue ( sshContextSessionID , sessionID )
// attempts to retrieve and close the outgoing ssh client when the incoming conn is closed.
// If no client exists, the conn is being closed before the PublicKeyCallback was called (where the client is created).
cleanupFunc := func ( ) {
client , ok := ctx . Value ( sshContextSSHClient ) . ( * gossh . Client )
if ok && client != nil {
client . Close ( )
}
}
return sshConn { conn , cleanupFunc }
2019-10-02 20:56:28 +00:00
}
2019-09-30 20:44:23 +00:00
// channelHandler proxies incoming and outgoing SSH traffic back and forth over an SSH Channel
func ( s * SSHProxy ) channelHandler ( srv * ssh . Server , conn * gossh . ServerConn , newChan gossh . NewChannel , ctx ssh . Context ) {
2019-10-02 20:56:28 +00:00
if newChan . ChannelType ( ) != "session" && newChan . ChannelType ( ) != "direct-tcpip" {
msg := fmt . Sprintf ( "channel type %s is not supported" , newChan . ChannelType ( ) )
s . logger . Info ( msg )
if err := newChan . Reject ( gossh . UnknownChannelType , msg ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Error rejecting SSH channel" )
}
2019-07-18 21:29:16 +00:00
return
}
2019-10-02 20:56:28 +00:00
localChan , localChanReqs , err := newChan . Accept ( )
if err != nil {
s . logger . WithError ( err ) . Error ( "Failed to accept session channel" )
return
2019-07-18 21:29:16 +00:00
}
2019-10-02 20:56:28 +00:00
defer localChan . Close ( )
2019-09-06 21:37:58 +00:00
2019-10-09 21:56:47 +00:00
// client will be closed when the sshConn is closed
client , ok := ctx . Value ( sshContextSSHClient ) . ( * gossh . Client )
if ! ok {
s . logger . Error ( "Could not retrieve client from context" )
2019-10-02 20:56:28 +00:00
return
}
2019-09-30 20:44:23 +00:00
2019-10-02 20:56:28 +00:00
remoteChan , remoteChanReqs , err := client . OpenChannel ( newChan . ChannelType ( ) , newChan . ExtraData ( ) )
if err != nil {
s . logger . WithError ( err ) . Error ( "Failed to open remote channel" )
return
}
2019-08-29 17:40:21 +00:00
2019-10-02 20:56:28 +00:00
defer remoteChan . Close ( )
2019-08-29 17:40:21 +00:00
2019-10-02 20:56:28 +00:00
// Proxy ssh traffic back and forth between client and destination
s . proxyChannel ( localChan , remoteChan , localChanReqs , remoteChanReqs , conn , ctx )
2019-09-30 20:44:23 +00:00
}
// proxyChannel couples two SSH channels and proxies SSH traffic and channel requests back and forth.
func ( s * SSHProxy ) proxyChannel ( localChan , remoteChan gossh . Channel , localChanReqs , remoteChanReqs <- chan * gossh . Request , conn * gossh . ServerConn , ctx ssh . Context ) {
done := make ( chan struct { } , 2 )
2019-08-26 20:25:24 +00:00
go func ( ) {
2019-09-30 20:44:23 +00:00
if _ , err := io . Copy ( localChan , remoteChan ) ; err != nil {
s . logger . WithError ( err ) . Error ( "remote to local copy error" )
2019-09-10 23:50:04 +00:00
}
2019-09-30 20:44:23 +00:00
done <- struct { } { }
2019-09-10 23:50:04 +00:00
} ( )
go func ( ) {
2019-09-30 20:44:23 +00:00
if _ , err := io . Copy ( remoteChan , localChan ) ; err != nil {
s . logger . WithError ( err ) . Error ( "local to remote copy error" )
2019-09-10 23:50:04 +00:00
}
2019-09-30 20:44:23 +00:00
done <- struct { } { }
2019-07-18 21:29:16 +00:00
} ( )
2019-10-23 15:27:37 +00:00
// stderr streams are used non-pty sessions since they have distinct IO streams.
remoteStderr := remoteChan . Stderr ( )
localStderr := localChan . Stderr ( )
go func ( ) {
if _ , err := io . Copy ( remoteStderr , localStderr ) ; err != nil {
s . logger . WithError ( err ) . Error ( "stderr local to remote copy error" )
}
} ( )
go func ( ) {
if _ , err := io . Copy ( localStderr , remoteStderr ) ; err != nil {
s . logger . WithError ( err ) . Error ( "stderr remote to local copy error" )
}
} ( )
2019-09-30 20:44:23 +00:00
s . logAuditEvent ( conn , "" , auditEventStart , ctx )
defer s . logAuditEvent ( conn , "" , auditEventStop , ctx )
// Proxy channel requests
for {
select {
case req := <- localChanReqs :
if req == nil {
return
}
if err := s . forwardChannelRequest ( remoteChan , req ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Failed to forward request" )
return
}
2019-07-18 21:29:16 +00:00
2019-09-30 20:44:23 +00:00
s . logChannelRequest ( req , conn , ctx )
2019-07-18 21:29:16 +00:00
2019-09-30 20:44:23 +00:00
case req := <- remoteChanReqs :
if req == nil {
return
}
if err := s . forwardChannelRequest ( localChan , req ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Failed to forward request" )
return
}
case <- done :
return
}
2019-09-06 21:37:58 +00:00
}
2019-09-30 20:44:23 +00:00
}
2019-09-06 21:37:58 +00:00
2019-10-09 21:56:47 +00:00
// readPreamble reads a preamble from the SSH connection before any SSH traffic is sent.
// This preamble is a JSON encoded struct containing the users JWT and ultimate destination.
// The first 4 bytes contain the length of the preamble which follows immediately.
func ( s * SSHProxy ) readPreamble ( conn net . Conn ) ( * SSHPreamble , error ) {
// Set conn read deadline while reading preamble to prevent hangs if preamble wasnt sent.
if err := conn . SetReadDeadline ( time . Now ( ) . Add ( 500 * time . Millisecond ) ) ; err != nil {
return nil , errors . Wrap ( err , "failed to set conn deadline" )
}
defer func ( ) {
if err := conn . SetReadDeadline ( time . Time { } ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Failed to unset conn read deadline" )
}
} ( )
size := make ( [ ] byte , SSHPreambleLength )
2019-10-02 20:56:28 +00:00
if _ , err := io . ReadFull ( conn , size ) ; err != nil {
2019-10-09 21:56:47 +00:00
return nil , err
2019-10-02 20:56:28 +00:00
}
2019-10-16 15:53:46 +00:00
payloadLength := binary . BigEndian . Uint16 ( size )
2019-10-09 21:56:47 +00:00
payload := make ( [ ] byte , payloadLength )
if _ , err := io . ReadFull ( conn , payload ) ; err != nil {
return nil , err
2019-10-02 20:56:28 +00:00
}
2019-10-09 21:56:47 +00:00
var preamble SSHPreamble
err := json . Unmarshal ( payload , & preamble )
2019-10-02 20:56:28 +00:00
if err != nil {
2019-10-09 21:56:47 +00:00
return nil , err
}
2019-10-16 17:08:33 +00:00
preamble . Destination , err = canonicalizeDest ( preamble . Destination )
2019-10-09 21:56:47 +00:00
if err != nil {
2019-10-16 15:53:46 +00:00
return nil , err
2019-10-02 20:56:28 +00:00
}
2019-10-16 17:08:33 +00:00
return & preamble , nil
}
2019-10-16 15:53:46 +00:00
2019-10-16 17:08:33 +00:00
// canonicalizeDest adds a default port if one doesnt exist
func canonicalizeDest ( dest string ) ( string , error ) {
_ , _ , err := net . SplitHostPort ( dest )
// if host and port are split without error, a port exists.
if err != nil {
addrErr , ok := err . ( * net . AddrError )
if ! ok {
return "" , err
}
// If the port is missing, append it.
if addrErr . Err == "missing port in address" {
return fmt . Sprintf ( "%s:%s" , dest , defaultSSHPort ) , nil
}
// If there are too many colons and address is IPv6, wrap in brackets and append port. Otherwise invalid address
ip := net . ParseIP ( dest )
if addrErr . Err == "too many colons in address" && ip != nil && ip . To4 ( ) == nil {
return fmt . Sprintf ( "[%s]:%s" , dest , defaultSSHPort ) , nil
}
return "" , addrErr
2019-10-02 20:56:28 +00:00
}
2019-10-16 15:53:46 +00:00
2019-10-16 17:08:33 +00:00
return dest , nil
2019-10-02 20:56:28 +00:00
}
2019-10-09 21:56:47 +00:00
// dialDestination creates a new SSH client and dials the destination server
func ( s * SSHProxy ) dialDestination ( ctx ssh . Context ) ( * gossh . Client , error ) {
preamble , ok := ctx . Value ( sshContextPreamble ) . ( * SSHPreamble )
if ! ok {
msg := "failed to retrieve SSH preamble from context"
s . logger . Error ( msg )
return nil , errors . New ( msg )
}
signer , err := s . genSSHSigner ( preamble . JWT )
if err != nil {
s . logger . WithError ( err ) . Error ( "Failed to generate signed short lived cert" )
return nil , err
}
2019-10-23 15:27:37 +00:00
s . logger . Debugf ( "Short lived certificate for %s connecting to %s:\n\n%s" , ctx . User ( ) , preamble . Destination , gossh . MarshalAuthorizedKey ( signer . PublicKey ( ) ) )
2019-10-09 21:56:47 +00:00
2019-10-02 20:56:28 +00:00
clientConfig := & gossh . ClientConfig {
User : ctx . User ( ) ,
// AUTH-2103 TODO: proper host key check
HostKeyCallback : gossh . InsecureIgnoreHostKey ( ) ,
2019-10-09 21:56:47 +00:00
Auth : [ ] gossh . AuthMethod { gossh . PublicKeys ( signer ) } ,
ClientVersion : ctx . ServerVersion ( ) ,
2019-10-02 20:56:28 +00:00
}
2019-10-09 21:56:47 +00:00
client , err := gossh . Dial ( "tcp" , preamble . Destination , clientConfig )
2019-10-02 20:56:28 +00:00
if err != nil {
2019-10-09 21:56:47 +00:00
s . logger . WithError ( err ) . Info ( "Failed to connect to destination SSH server" )
2019-10-02 20:56:28 +00:00
return nil , err
}
return client , nil
}
2019-10-09 21:56:47 +00:00
// Generates a key pair and sends public key to get signed by CA
func ( s * SSHProxy ) genSSHSigner ( jwt string ) ( gossh . Signer , error ) {
key , err := ecdsa . GenerateKey ( elliptic . P256 ( ) , rand . Reader )
if err != nil {
return nil , errors . Wrap ( err , "failed to generate ecdsa key pair" )
}
pub , err := gossh . NewPublicKey ( & key . PublicKey )
if err != nil {
return nil , errors . Wrap ( err , "failed to convert ecdsa public key to SSH public key" )
}
pubBytes := gossh . MarshalAuthorizedKey ( pub )
signedCertBytes , err := sshgen . SignCert ( jwt , string ( pubBytes ) )
if err != nil {
return nil , errors . Wrap ( err , "failed to retrieve cert from SSHCAAPI" )
}
signedPub , _ , _ , _ , err := gossh . ParseAuthorizedKey ( [ ] byte ( signedCertBytes ) )
if err != nil {
return nil , errors . Wrap ( err , "failed to parse SSH public key" )
}
cert , ok := signedPub . ( * gossh . Certificate )
if ! ok {
return nil , errors . Wrap ( err , "failed to assert public key as certificate" )
}
signer , err := gossh . NewSignerFromKey ( key )
if err != nil {
return nil , errors . Wrap ( err , "failed to create signer" )
}
certSigner , err := gossh . NewCertSigner ( cert , signer )
if err != nil {
return nil , errors . Wrap ( err , "failed to create cert signer" )
}
return certSigner , nil
}
2019-09-30 20:44:23 +00:00
// forwardChannelRequest sends request req to SSH channel sshChan, waits for reply, and sends the reply back.
func ( s * SSHProxy ) forwardChannelRequest ( sshChan gossh . Channel , req * gossh . Request ) error {
reply , err := sshChan . SendRequest ( req . Type , req . WantReply , req . Payload )
2019-09-06 21:37:58 +00:00
if err != nil {
2019-09-30 20:44:23 +00:00
return errors . Wrap ( err , "Failed to send request" )
2019-09-06 21:37:58 +00:00
}
2019-09-30 20:44:23 +00:00
if err := req . Reply ( reply , nil ) ; err != nil {
return errors . Wrap ( err , "Failed to reply to request" )
2019-09-06 21:37:58 +00:00
}
2019-09-30 20:44:23 +00:00
return nil
2019-09-06 21:37:58 +00:00
}
2019-09-30 20:44:23 +00:00
// logChannelRequest creates an audit log for different types of channel requests
func ( s * SSHProxy ) logChannelRequest ( req * gossh . Request , conn * gossh . ServerConn , ctx ssh . Context ) {
var eventType string
var event string
switch req . Type {
case "exec" :
var payload struct { Value string }
if err := gossh . Unmarshal ( req . Payload , & payload ) ; err != nil {
s . logger . WithError ( err ) . Errorf ( "Failed to unmarshal channel request payload: %s:%s" , req . Type , req . Payload )
}
event = payload . Value
2019-08-20 17:48:47 +00:00
2019-09-30 20:44:23 +00:00
eventType = auditEventExec
if strings . HasPrefix ( string ( req . Payload ) , "scp" ) {
eventType = auditEventScp
}
case "shell" :
eventType = auditEventShell
case "window-change" :
eventType = auditEventResize
2019-10-02 20:56:28 +00:00
default :
return
2019-08-29 17:40:21 +00:00
}
2019-09-30 20:44:23 +00:00
s . logAuditEvent ( conn , event , eventType , ctx )
}
2019-09-10 23:50:04 +00:00
2019-10-09 21:56:47 +00:00
func ( s * SSHProxy ) auditLogger ( ) ( io . WriteCloser , string , error ) {
2019-09-30 20:44:23 +00:00
sessionUUID , err := uuid . NewRandom ( )
2019-08-29 17:40:21 +00:00
if err != nil {
2019-10-09 21:56:47 +00:00
return nil , "" , errors . Wrap ( err , "failed to create sessionID" )
2019-08-29 17:40:21 +00:00
}
2019-09-30 20:44:23 +00:00
sessionID := sessionUUID . String ( )
2019-08-29 17:40:21 +00:00
2019-10-02 20:56:28 +00:00
writer , err := s . logManager . NewLogger ( fmt . Sprintf ( "%s-event.log" , sessionID ) , s . logger )
2019-08-29 17:40:21 +00:00
if err != nil {
2019-10-09 21:56:47 +00:00
return nil , "" , errors . Wrap ( err , "failed to create logger" )
2019-08-29 17:40:21 +00:00
}
2019-10-09 21:56:47 +00:00
return writer , sessionID , nil
2019-08-29 17:40:21 +00:00
}
2019-09-30 20:44:23 +00:00
func ( s * SSHProxy ) logAuditEvent ( conn * gossh . ServerConn , event , eventType string , ctx ssh . Context ) {
2019-10-02 20:56:28 +00:00
sessionID , sessionIDOk := ctx . Value ( sshContextSessionID ) . ( string )
writer , writerOk := ctx . Value ( sshContextEventLogger ) . ( io . WriteCloser )
if ! writerOk || ! sessionIDOk {
s . logger . Error ( "Failed to retrieve audit logger from context" )
2019-09-18 19:11:12 +00:00
return
}
2019-10-02 20:56:28 +00:00
2019-10-09 21:56:47 +00:00
var destination string
preamble , ok := ctx . Value ( sshContextPreamble ) . ( * SSHPreamble )
if ok {
destination = preamble . Destination
} else {
s . logger . Error ( "Failed to retrieve SSH preamble from context" )
2019-09-18 19:11:12 +00:00
}
2019-09-30 20:44:23 +00:00
ae := auditEvent {
2019-10-02 20:56:28 +00:00
Event : event ,
EventType : eventType ,
SessionID : sessionID ,
User : conn . User ( ) ,
Login : conn . User ( ) ,
Datetime : time . Now ( ) . UTC ( ) . Format ( time . RFC3339 ) ,
2019-10-09 21:56:47 +00:00
Hostname : s . hostname ,
2019-10-02 20:56:28 +00:00
Destination : destination ,
2019-09-06 21:37:58 +00:00
}
2019-09-30 20:44:23 +00:00
data , err := json . Marshal ( & ae )
2019-09-06 21:37:58 +00:00
if err != nil {
2019-09-10 23:50:04 +00:00
s . logger . WithError ( err ) . Error ( "Failed to marshal audit event. malformed audit object" )
2019-09-06 21:37:58 +00:00
return
}
line := string ( data ) + "\n"
2019-09-10 23:50:04 +00:00
if _ , err := writer . Write ( [ ] byte ( line ) ) ; err != nil {
s . logger . WithError ( err ) . Error ( "Failed to write audit event." )
}
2019-09-06 21:37:58 +00:00
}