diff --git a/go.mod b/go.mod index 74f3e43..76e018f 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.21.3 require ( github.com/FAU-CDI/wdresolve v0.0.0-20230108072141-c9c6779d7c41 github.com/alessio/shellescape v1.4.2 - github.com/compose-spec/compose-go v1.20.1 + github.com/compose-spec/compose-go v1.20.2 github.com/docker/docker v24.0.7+incompatible github.com/gliderlabs/ssh v0.3.5 github.com/gorilla/csrf v1.7.2 @@ -15,14 +15,14 @@ require ( github.com/pquerna/otp v1.4.0 github.com/rs/zerolog v1.31.0 github.com/tkw1536/goprogram v0.5.0 - github.com/tkw1536/pkglib v0.0.0-20231122155813-969c635025aa + github.com/tkw1536/pkglib v0.0.0-20231127091902-1ede10f3370e github.com/yuin/goldmark v1.6.0 github.com/yuin/goldmark-meta v1.1.0 - golang.org/x/crypto v0.15.0 - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa - golang.org/x/net v0.18.0 + golang.org/x/crypto v0.16.0 + golang.org/x/exp v0.0.0-20231127185646-65229373498e + golang.org/x/net v0.19.0 golang.org/x/sync v0.5.0 - golang.org/x/term v0.14.0 + golang.org/x/term v0.15.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.5.2 gorm.io/gorm v1.25.5 @@ -64,10 +64,10 @@ require ( github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect github.com/xeipuuv/gojsonschema v1.2.0 // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/sys v0.14.0 // indirect + golang.org/x/sys v0.15.0 // indirect golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect - golang.org/x/tools v0.15.0 // indirect + golang.org/x/tools v0.16.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect ) diff --git a/go.sum b/go.sum index ad9fbfa..bf9fb6f 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/boombuler/barcode v1.0.1 h1:NDBbPmhS+EqABEs5Kg3n/5ZNjy73Pz7SIV+KCeqyX github.com/boombuler/barcode v1.0.1/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/compose-spec/compose-go v1.20.1 h1:I6gCMGLl96kEf8XZwaozeTwnNfxA2eVsO46W+5ciTEg= github.com/compose-spec/compose-go v1.20.1/go.mod h1:+MdqXV4RA7wdFsahh/Kb8U0pAJqkg7mr4PM9tFKU8RM= +github.com/compose-spec/compose-go v1.20.2 h1:u/yfZHn4EaHGdidrZycWpxXgFffjYULlTbRfJ51ykjQ= +github.com/compose-spec/compose-go v1.20.2/go.mod h1:+MdqXV4RA7wdFsahh/Kb8U0pAJqkg7mr4PM9tFKU8RM= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -123,6 +125,8 @@ github.com/tkw1536/pkglib v0.0.0-20231121123232-879eff5cea2d h1:J8EvqdNY/qJpn8qo github.com/tkw1536/pkglib v0.0.0-20231121123232-879eff5cea2d/go.mod h1:Qi/vpuxuxo5D40O9jLUSmcUF01B5LmJqDxs8o8Lc6bg= github.com/tkw1536/pkglib v0.0.0-20231122155813-969c635025aa h1:HQxorKzWcH3D0In/G6Y24IT9KVVcGjeJGcZzN0T3b30= github.com/tkw1536/pkglib v0.0.0-20231122155813-969c635025aa/go.mod h1:Qi/vpuxuxo5D40O9jLUSmcUF01B5LmJqDxs8o8Lc6bg= +github.com/tkw1536/pkglib v0.0.0-20231127091902-1ede10f3370e h1:MnXtQ6JJwo29XkONrzrFRgJym48kWWTNa5lG4+uLyuQ= +github.com/tkw1536/pkglib v0.0.0-20231127091902-1ede10f3370e/go.mod h1:Qi/vpuxuxo5D40O9jLUSmcUF01B5LmJqDxs8o8Lc6bg= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -142,8 +146,12 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20231127185646-65229373498e h1:Gvh4YaCaXNs6dKTlfgismwWZKyjVZXwOPfIyUaqU3No= +golang.org/x/exp v0.0.0-20231127185646-65229373498e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= @@ -156,6 +164,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220826154423-83b083e8dc8b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.18.0 h1:mIYleuAkSbHh0tCv7RvjL3F6ZVbLjq4+R7zbOn3Kokg= golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -176,11 +186,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220722155259-a9ba230a4035/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.14.0 h1:LGK9IlZ8T9jvdy6cTdfKUCltatMFOehAQo9SRC46UQ8= golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -195,6 +209,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.16.0 h1:GO788SKMRunPIBCXiQyo2AaexLstOrVhuAL5YwsckQM= +golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/dis/component/server/admin/socket/proto/legacy.go b/internal/dis/component/server/admin/socket/proto/legacy.go new file mode 100644 index 0000000..410d2a5 --- /dev/null +++ b/internal/dis/component/server/admin/socket/proto/legacy.go @@ -0,0 +1,203 @@ +package proto + +import ( + "context" + "encoding/json" + "errors" + "io" + "sync" + "time" + + "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" + "github.com/tkw1536/pkglib/httpx/websocket" + "github.com/tkw1536/pkglib/recovery" +) + +var ( + errLegacyReadParamsTimeout = errors.New("timeout reading the first message") + errLegacyUnknownAction = errors.New("unknown action call") + errLegacyIncorrectParams = errors.New("invalid number of parameters") +) + +// Handle handles the legacy protocol version. +// This is mostly used for legacy clients. +// +// There are two kinds of messages: +// +// - text messages, which are used to send input and output. +// - binary messages, which are json-encoded and used for control flow. +// +// To call an action, a client should send a [LegacyCallMessage] struct. +// The server will then start handling input and output (via text messages). +// If the client sends a SignalMessage, the signal is propagnated to the underlying context. +// Finally it will send a ResultMessage once handling is complete. +// +// A corresponding client implementation of this can be found in ..../remote/proto.ts +func (am ActionMap) handleLegacyProtocol(auth *auth.Auth, conn *websocket.Connection) (name string, err error) { + var wg sync.WaitGroup + + // once we have finished executing send a binary message (indicating success) to the client. + defer func() { + // close the underlying connection, and then wait for everything to finish! + defer wg.Wait() + defer conn.Close() + + // recover from any errors + if e := recovery.Recover(recover()); e != nil { + err = e + } + + // generate a result message + var result LegacyResultMessage + if err == nil { + result.Success = true + } else { + result.Success = false + result.Message = err.Error() + if result.Message == "" { + result.Message = "unspecified error" + } + } + + // encode the result message to json! + var message websocket.Message + message.Type = websocket.BinaryMessage + message.Bytes, err = json.Marshal(result) + + // silently fail if the message fails to encode + // although this should not happen + if err != nil { + return + } + + // and tell the client about it! + <-conn.Write(message) + }() + + // create channels to receive text and bytes messages + textMessages := make(chan string, 10) + binaryMessages := make(chan []byte, 10) + + // start reading text and binary messages + // and redirect everything to the right channels + wg.Add(1) + go func() { + defer wg.Done() + + defer close(textMessages) + defer close(binaryMessages) + + for { + select { + case msg := <-conn.Read(): + if msg.Type == websocket.TextMessage { + textMessages <- string(msg.Bytes) + } + if msg.Type == websocket.BinaryMessage { + binaryMessages <- msg.Bytes + } + case <-conn.Context().Done(): + return + } + } + + }() + + var call LegacyCallMessage + select { + case buffer := <-binaryMessages: + if err := json.Unmarshal(buffer, &call); err != nil { + return "", errLegacyUnknownAction + } + + case <-time.After(1 * time.Second): + return "", errLegacyReadParamsTimeout + } + + // check that the given action exists! + // and has the right number of parameters! + action, ok := am[call.Call] + if !ok || action.Handle == nil { + return call.Call, errLegacyUnknownAction + } + if action.NumParams != len(call.Params) { + return call.Call, errLegacyIncorrectParams + } + + // check that we have the given permission + if err := auth.CheckScope(action.ScopeParam, action.scope(), conn.Request()); err != nil { + return call.Call, err + } + + // create a context to be canceled once done + ctx, cancel := context.WithCancel(conn.Context()) + defer cancel() + + // handle any signal messages + wg.Add(1) + go func() { + defer wg.Done() + var signal LegacySignalMessage + + for binary := range binaryMessages { + signal.Signal = "" + + // read the signal message + if err := json.Unmarshal(binary, &signal); err != nil { + continue + } + + // if we got a cancel message, do the cancellation! + if signal.Signal == LegacySignalCancel { + cancel() + } + } + }() + + // create a pipe to handle the input + // and start handling it + var inputR, inputW = io.Pipe() + defer inputW.Close() + + wg.Add(1) + go func() { + defer wg.Done() + + for text := range textMessages { + inputW.Write([]byte(text)) + } + }() + + // write the output to the client as it comes in! + // NOTE(twiesing): We may eventually need buffering here ... + output := WriterFunc(func(b []byte) (int, error) { + <-conn.WriteText(string(b)) + return len(b), nil + }) + + // handle the actual + return call.Call, action.Handle(ctx, inputR, output, call.Params...) +} + +// LegacyCallMessage is sent by the client to the server to invoke a remote procedure +type LegacyCallMessage struct { + Call string `json:"call"` + Params []string `json:"params,omitempty"` +} + +// LegacySignalMessage is sent from the client to the server to stop the current procedure +type LegacySignalMessage struct { + Signal LegacySignal `json:"signal"` +} + +type LegacySignal string + +const ( + LegacySignalCancel LegacySignal = "cancel" +) + +// LegacyResultMessage is sent by the server to the client to report the success of a remote procedure +type LegacyResultMessage struct { + Success bool `json:"success"` + Message string `json:"message,omitempty"` +} diff --git a/internal/dis/component/server/admin/socket/proto/proto.go b/internal/dis/component/server/admin/socket/proto/proto.go index b6cda86..323e179 100644 --- a/internal/dis/component/server/admin/socket/proto/proto.go +++ b/internal/dis/component/server/admin/socket/proto/proto.go @@ -1,13 +1,7 @@ package proto import ( - "context" - "encoding/json" "errors" - "fmt" - "io" - "sync" - "time" "github.com/FAU-CDI/wisski-distillery/internal/dis/component/auth" "github.com/tkw1536/pkglib/httpx/websocket" @@ -16,201 +10,27 @@ import ( // ActionMap handles a set of WebSocket actions type ActionMap map[string]Action -var errReadParamsTimeout = errors.New("timeout reading the first message") -var errUnknownAction = errors.New("unknown action call") -var errIncorrectParams = errors.New("invalid number of parameters") - -type errPanic struct{ value any } - -func (err errPanic) Error() string { - return fmt.Sprintf("fatal error: %v", err.value) -} - -// Handle handles a new incoming websocket connection using the given authentication. -// -// There are two kinds of messages: -// -// - text messages, which are used to send input and output. -// - binary messages, which are json-encoded and used for control flow. -// -// To call an action, a client should send a CallMessage struct. -// The server will then start handling input and output (via text messages). -// If the client sends a SignalMessage, the signal is propagnated to the underlying context. -// Finally it will send a ResultMessage once handling is complete. -// -// A corresponding client implementation of this can be found in ..../remote/proto.ts -func (am ActionMap) Handle(auth *auth.Auth, conn *websocket.Connection) (name string, err error) { - var wg sync.WaitGroup - - // once we have finished executing send a binary message (indicating success) to the client. - defer func() { - // close the underlying connection, and then wait for everything to finish! - defer wg.Wait() - defer conn.Close() - - // recover from any errors - if v := recover(); v != nil { - err = errPanic{value: v} - } - - // generate a result message - var result ResultMessage - if err == nil { - result.Success = true - } else { - result.Success = false - result.Message = err.Error() - if result.Message == "" { - result.Message = "unspecified error" - } - } - - // encode the result message to json! - var message websocket.Message - message.Type = websocket.BinaryMessage - message.Bytes, err = json.Marshal(result) - - // silently fail if the message fails to encode - // although this should not happen - if err != nil { - return - } - - // and tell the client about it! - <-conn.Write(message) - }() - - // create channels to receive text and bytes messages - textMessages := make(chan string, 10) - binaryMessages := make(chan []byte, 10) - - // start reading text and binary messages - // and redirect everything to the right channels - wg.Add(1) - go func() { - defer wg.Done() - - defer close(textMessages) - defer close(binaryMessages) - - for { - select { - case msg := <-conn.Read(): - if msg.Type == websocket.TextMessage { - textMessages <- string(msg.Bytes) - } - if msg.Type == websocket.BinaryMessage { - binaryMessages <- msg.Bytes - } - case <-conn.Context().Done(): - return - } - } - - }() - - var call CallMessage - select { - case buffer := <-binaryMessages: - if err := json.Unmarshal(buffer, &call); err != nil { - return "", errUnknownAction - } - - case <-time.After(1 * time.Second): - return "", errReadParamsTimeout - } - - // check that the given action exists! - // and has the right number of parameters! - action, ok := am[call.Call] - if !ok || action.Handle == nil { - return call.Call, errUnknownAction - } - if action.NumParams != len(call.Params) { - return call.Call, errIncorrectParams - } - - // check that we have the given permission - if err := auth.CheckScope(action.ScopeParam, action.scope(), conn.Request()); err != nil { - return call.Call, err - } - - // create a context to be canceled once done - ctx, cancel := context.WithCancel(conn.Context()) - defer cancel() - - // handle any signal messages - wg.Add(1) - go func() { - defer wg.Done() - var signal SignalMessage - - for binary := range binaryMessages { - signal.Signal = "" - - // read the signal message - if err := json.Unmarshal(binary, &signal); err != nil { - continue - } - - // if we got a cancel message, do the cancellation! - if signal.Signal == SignalCancel { - cancel() - } - } - }() - - // create a pipe to handle the input - // and start handling it - var inputR, inputW = io.Pipe() - defer inputW.Close() - - wg.Add(1) - go func() { - defer wg.Done() - - for text := range textMessages { - inputW.Write([]byte(text)) - } - }() - - // write the output to the client as it comes in! - // NOTE(twiesing): We may eventually need buffering here ... - output := WriteFunc(func(b []byte) (int, error) { - <-conn.WriteText(string(b)) - return len(b), nil - }) - - // handle the actual - return call.Call, action.Handle(ctx, inputR, output, call.Params...) -} - -// WriteFunc implements io.Writer using a function. -type WriteFunc func([]byte) (int, error) - -func (wf WriteFunc) Write(b []byte) (int, error) { - return wf(b) -} - -// CallMessage is sent by the client to the server to invoke a remote procedure -type CallMessage struct { - Call string `json:"call"` - Params []string `json:"params,omitempty"` -} - -// CancelMessage is sent from the client to the server to stop the current procedure -type SignalMessage struct { - Signal Signal `json:"signal"` -} - -type Signal string - -const ( - SignalCancel = "cancel" +var ( + errUnknownSubprotocol = errors.New("unknown subprotocol") + msgUnknownSubprotocol = websocket.NewTextMessage(errUnknownSubprotocol.Error()).MustPrepare() ) -// ResultMessage is sent by the server to the client to report the success of a remote procedure -type ResultMessage struct { - Success bool `json:"success"` - Message string `json:"message,omitempty"` +// Handle handles a new incoming websocket connection by switching on the subprotocol. +// See appropriate protocol handlers for documentation. +func (am ActionMap) Handle(auth *auth.Auth, conn *websocket.Connection) (name string, err error) { + // select based on the negotiated subprotocol + switch conn.Subprotocol() { + case "": + return am.handleLegacyProtocol(auth, conn) + default: + <-conn.WritePrepared(msgUnknownSubprotocol) + return "", errUnknownSubprotocol + } +} + +// WriterFunc implements io.Writer using a function. +type WriterFunc func([]byte) (int, error) + +func (wf WriterFunc) Write(b []byte) (int, error) { + return wf(b) }