From 84ee5940ccee2138b850db6d9f3f72ec85ad08f5 Mon Sep 17 00:00:00 2001 From: zyxkad Date: Wed, 5 Jun 2024 20:07:50 -0600 Subject: [PATCH] add logFile API --- api.go | 146 ++++++++++++++++++++++++++++++--- dashboard/src/api/v0.ts | 55 ++++++++++++- handler.go | 4 + log/logger.go | 40 ++++++++-- notify/webpush/webpush.go | 2 +- scripts/decrypt-log.go | 164 ++++++++++++++++++++++++++++++++++++++ scripts/docker-run.sh | 2 +- utils/crypto.go | 76 +++++++++++++++++- 8 files changed, 465 insertions(+), 24 deletions(-) create mode 100644 scripts/decrypt-log.go diff --git a/api.go b/api.go index 2034cd4a..f155f828 100644 --- a/api.go +++ b/api.go @@ -20,13 +20,17 @@ package main import ( + "compress/gzip" "context" "crypto/subtle" "encoding/json" "errors" "fmt" + "io" "mime" "net/http" + "os" + "path/filepath" "strconv" "strings" "sync/atomic" @@ -143,29 +147,55 @@ func (cr *Cluster) initAPIv0() http.Handler { }) }) - mux.HandleFunc("/ping", cr.apiV0Ping) + mux.HandleFunc("/ping", cr.apiV1Ping) mux.HandleFunc("/status", cr.apiV0Status) mux.Handle("/stat/", http.StripPrefix("/stat/", (http.HandlerFunc)(cr.apiV0Stat))) - mux.HandleFunc("/challenge", cr.apiV0Challenge) + mux.HandleFunc("/challenge", cr.apiV1Challenge) mux.HandleFunc("/login", cr.apiV0Login) mux.Handle("/requestToken", cr.apiAuthHandleFunc(cr.apiV0RequestToken)) - mux.Handle("/logout", cr.apiAuthHandleFunc(cr.apiV0Logout)) + mux.Handle("/logout", cr.apiAuthHandleFunc(cr.apiV1Logout)) - mux.HandleFunc("/log.io", cr.apiV0LogIO) - mux.Handle("/pprof", cr.apiAuthHandleFunc(cr.apiV0Pprof)) + mux.HandleFunc("/log.io", cr.apiV1LogIO) + mux.Handle("/pprof", cr.apiAuthHandleFunc(cr.apiV1Pprof)) mux.HandleFunc("/subscribeKey", cr.apiV0SubscribeKey) mux.Handle("/subscribe", cr.apiAuthHandleFunc(cr.apiV0Subscribe)) mux.Handle("/subscribe_email", cr.apiAuthHandleFunc(cr.apiV0SubscribeEmail)) mux.Handle("/webhook", cr.apiAuthHandleFunc(cr.apiV0Webhook)) + mux.Handle("/log_files", cr.apiAuthHandleFunc(cr.apiV0LogFiles)) + mux.Handle("/log_file/", cr.apiAuthHandle(http.StripPrefix("/log_file/", (http.HandlerFunc)(cr.apiV0LogFile)))) + next := cr.apiRateLimiter.WrapHandler(mux) return (http.HandlerFunc)(func(rw http.ResponseWriter, req *http.Request) { cr.authMiddleware(rw, req, next) }) } -func (cr *Cluster) apiV0Ping(rw http.ResponseWriter, req *http.Request) { +func (cr *Cluster) initAPIv1() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) { + writeJson(rw, http.StatusNotFound, Map{ + "error": "404 not found", + "path": req.URL.Path, + }) + }) + + mux.HandleFunc("/ping", cr.apiV1Ping) + + mux.HandleFunc("/challenge", cr.apiV1Challenge) + mux.Handle("/logout", cr.apiAuthHandleFunc(cr.apiV1Logout)) + + mux.HandleFunc("/log.io", cr.apiV1LogIO) + mux.Handle("/pprof", cr.apiAuthHandleFunc(cr.apiV1Pprof)) + + next := cr.apiRateLimiter.WrapHandler(mux) + return (http.HandlerFunc)(func(rw http.ResponseWriter, req *http.Request) { + cr.authMiddleware(rw, req, next) + }) +} + +func (cr *Cluster) apiV1Ping(rw http.ResponseWriter, req *http.Request) { if checkRequestMethodOrRejectWithJson(rw, req, http.MethodGet) { return } @@ -173,7 +203,7 @@ func (cr *Cluster) apiV0Ping(rw http.ResponseWriter, req *http.Request) { authed := getRequestTokenType(req) == tokenTypeAuth writeJson(rw, http.StatusOK, Map{ "version": build.BuildVersion, - "time": time.Now(), + "time": time.Now().UnixMilli(), "authed": authed, }) } @@ -235,7 +265,7 @@ func (cr *Cluster) apiV0Stat(rw http.ResponseWriter, req *http.Request) { writeJson(rw, http.StatusOK, (json.RawMessage)(data)) } -func (cr *Cluster) apiV0Challenge(rw http.ResponseWriter, req *http.Request) { +func (cr *Cluster) apiV1Challenge(rw http.ResponseWriter, req *http.Request) { if checkRequestMethodOrRejectWithJson(rw, req, http.MethodGet) { return } @@ -368,7 +398,7 @@ func (cr *Cluster) apiV0RequestToken(rw http.ResponseWriter, req *http.Request) }) } -func (cr *Cluster) apiV0Logout(rw http.ResponseWriter, req *http.Request) { +func (cr *Cluster) apiV1Logout(rw http.ResponseWriter, req *http.Request) { if checkRequestMethodOrRejectWithJson(rw, req, http.MethodPost) { return } @@ -378,7 +408,7 @@ func (cr *Cluster) apiV0Logout(rw http.ResponseWriter, req *http.Request) { rw.WriteHeader(http.StatusNoContent) } -func (cr *Cluster) apiV0LogIO(rw http.ResponseWriter, req *http.Request) { +func (cr *Cluster) apiV1LogIO(rw http.ResponseWriter, req *http.Request) { addr, _ := req.Context().Value(RealAddrCtxKey).(string) conn, err := cr.wsUpgrader.Upgrade(rw, req, nil) @@ -594,7 +624,7 @@ func (cr *Cluster) apiV0LogIO(rw http.ResponseWriter, req *http.Request) { } } -func (cr *Cluster) apiV0Pprof(rw http.ResponseWriter, req *http.Request) { +func (cr *Cluster) apiV1Pprof(rw http.ResponseWriter, req *http.Request) { if checkRequestMethodOrRejectWithJson(rw, req, http.MethodGet) { return } @@ -982,6 +1012,100 @@ func (cr *Cluster) apiV0WebhookDELETE(rw http.ResponseWriter, req *http.Request, rw.WriteHeader(http.StatusNoContent) } +func (cr *Cluster) apiV0LogFiles(rw http.ResponseWriter, req *http.Request) { + if checkRequestMethodOrRejectWithJson(rw, req, http.MethodGet) { + return + } + files := log.ListLogs() + type FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + } + data := make([]FileInfo, 0, len(files)) + for _, file := range files { + if s, err := os.Stat(filepath.Join(log.BaseDir(), file)); err == nil { + data = append(data, FileInfo{ + Name: file, + Size: s.Size(), + }) + } + } + writeJson(rw, http.StatusOK, Map{ + "files": data, + }) +} + +func (cr *Cluster) apiV0LogFile(rw http.ResponseWriter, req *http.Request) { + if checkRequestMethodOrRejectWithJson(rw, req, http.MethodGet) { + return + } + query := req.URL.Query() + fd, err := os.Open(filepath.Join(log.BaseDir(), req.URL.Path)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeJson(rw, http.StatusNotFound, Map{ + "error": "file not exists", + "message": "Cannot find log file", + "path": req.URL.Path, + }) + return + } + writeJson(rw, http.StatusInternalServerError, Map{ + "error": "cannot open file", + "message": err.Error(), + }) + return + } + defer fd.Close() + name := filepath.Base(req.URL.Path) + isGzip := filepath.Ext(name) == ".gz" + if query.Get("no_encrypt") == "1" { + var modTime time.Time + if stat, err := fd.Stat(); err == nil { + modTime = stat.ModTime() + } + rw.Header().Set("Cache-Control", "public, max-age=60, stale-while-revalidate=600") + if isGzip { + rw.Header().Set("Content-Type", "application/octet-stream") + } else { + rw.Header().Set("Content-Type", "text/plain; charset=utf-8") + } + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name)) + http.ServeContent(rw, req, name, modTime, fd) + } else { + if !isGzip { + name += ".gz" + } + rw.Header().Set("Content-Type", "application/octet-stream") + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", name+".encrypted")) + cr.apiV0LogFileEncrypted(rw, req, fd, !isGzip) + } +} + +func (cr *Cluster) apiV0LogFileEncrypted(rw http.ResponseWriter, req *http.Request, r io.Reader, useGzip bool) { + rw.WriteHeader(http.StatusOK) + if useGzip { + pr, pw := io.Pipe() + defer pr.Close() + go func(r io.Reader) { + gw := gzip.NewWriter(pw) + if _, err := io.Copy(gw, r); err != nil { + pw.CloseWithError(err) + return + } + if err := gw.Close(); err != nil { + pw.CloseWithError(err) + return + } + pw.Close() + }(r) + r = pr + } + if err := utils.EncryptStream(rw, r, utils.DeveloporPublicKey); err != nil { + log.Errorf("Cannot write encrypted log stream: %v", err) + } +} + type Map = map[string]any var errUnknownContent = errors.New("unknown content-type") diff --git a/dashboard/src/api/v0.ts b/dashboard/src/api/v0.ts index 9c271cd3..c1a65a55 100644 --- a/dashboard/src/api/v0.ts +++ b/dashboard/src/api/v0.ts @@ -76,7 +76,7 @@ export interface StatusRes { async function requestToken( token: string, path: string, - query?: { [key: string]: string }, + query?: { [key: string]: string | undefined }, ): Promise { const res = await axios.post( `/api/v0/requestToken`, @@ -122,7 +122,7 @@ export async function getStat(name: string, token?: string | null): Promise { - const u = new URL(window.location.toString()) + const u = new URL(window.location.origin) u.pathname = `/api/v0/challenge` u.searchParams.set('action', action) const res = await axios.get(u.toString()) @@ -173,7 +173,7 @@ export async function getPprofURL(token: string, opts: PprofOptions): Promise { }, }) } + +export interface FileInfo { + name: string + size: number +} + +interface LogFilesRes { + files: FileInfo[] +} + +export async function getLogFiles(token: string): Promise { + const res = await axios.get(`/api/v0/log_files`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }) + return res.data.files +} + +export async function getLogFile(token: string, name: string, noEncrypt?: boolean): Promise { + const LOGFILE_URL = `${window.location.origin}/api/v0/log_file` + const u = new URL(name, LOGFILE_URL) + if (noEncrypt) { + u.searchParams.set('no_encrypt', '1') + } + const res = await axios.get(u.toString(), { + headers: { + Authorization: `Bearer ${token}`, + }, + responseType: 'arraybuffer', + }) + return res.data +} + +export async function getLogFileURL(token: string, name: string, noEncrypt?: boolean): Promise { + const LOGFILE_URL = `${window.location.origin}/api/v0/log_file/` + if (name.startsWith('/')) { + name = name.substr(1) + } + const u = new URL(name, LOGFILE_URL) + if (noEncrypt) { + u.searchParams.set('no_encrypt', '1') + } + const tk = await requestToken(token, u.pathname, { + no_encrypt: u.searchParams.get('no_encrypt') || '', + }) + u.searchParams.set('_t', tk) + return u.toString() +} diff --git a/handler.go b/handler.go index 7a15bed4..d80f2b0e 100644 --- a/handler.go +++ b/handler.go @@ -120,6 +120,7 @@ func (cr *Cluster) GetHandler() http.Handler { cr.apiRateLimiter.SetAnonymousRateLimit(config.RateLimit.Anonymous) cr.apiRateLimiter.SetLoggedRateLimit(config.RateLimit.Logged) cr.handlerAPIv0 = http.StripPrefix("/api/v0", cr.cliIdHandle(cr.initAPIv0())) + cr.handlerAPIv1 = http.StripPrefix("/api/v1", cr.cliIdHandle(cr.initAPIv1())) cr.hijackHandler = http.StripPrefix("/bmclapi", cr.hijackProxy) handler := utils.NewHttpMiddleWareHandler(cr) @@ -431,6 +432,9 @@ func (cr *Cluster) ServeHTTP(rw http.ResponseWriter, req *http.Request) { case "v0": cr.handlerAPIv0.ServeHTTP(rw, req) return + case "v1": + cr.handlerAPIv1.ServeHTTP(rw, req) + return } case rawpath == "/robots.txt": http.ServeContent(rw, req, "robots.txt", time.Time{}, strings.NewReader(robotTxtContent)) diff --git a/log/logger.go b/log/logger.go index 0027b87f..14d7a3b7 100644 --- a/log/logger.go +++ b/log/logger.go @@ -29,6 +29,7 @@ import ( "os" "path/filepath" "runtime/debug" + "sort" "strings" "sync" "sync/atomic" @@ -36,14 +37,14 @@ import ( ) var ( - logdir string = "logs" + logDir string = "logs" logSlots int = 7 logfile atomic.Pointer[os.File] logStdout atomic.Pointer[io.Writer] logTimeFormat string = "15:04:05" - accessLogFileName = filepath.Join(logdir, "access.log") + accessLogFileName = filepath.Join(logDir, "access.log") accessLogFile atomic.Pointer[os.File] maxAccessLogFileSize int64 = 1024 * 1024 * 10 // 10MB @@ -292,11 +293,36 @@ func RecoverPanic(then func(err any)) { } } +func BaseDir() string { + return logDir +} + +func ListLogs() (files []string) { + entries, err := os.ReadDir(logDir) + if err != nil { + return + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + base, _, ok := strings.Cut(entry.Name(), ".log") + if !ok { + continue + } + _ = base + // if _, err := time.Parse("20060102-15", base); err == nil + files = append(files, entry.Name()) + } + sort.Strings(files) + return +} + func flushLogfile() { - if _, err := os.Stat(logdir); errors.Is(err, os.ErrNotExist) { - os.MkdirAll(logdir, 0755) + if _, err := os.Stat(logDir); errors.Is(err, os.ErrNotExist) { + os.MkdirAll(logDir, 0755) } - lfile, err := os.OpenFile(filepath.Join(logdir, time.Now().Format("20060102-15.log")), + lfile, err := os.OpenFile(filepath.Join(logDir, time.Now().Format("20060102-15.log")), os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) if err != nil { Error("Cannot create new log file:", err) @@ -310,11 +336,11 @@ func flushLogfile() { } func removeExpiredLogFiles(before string) { - if files, err := os.ReadDir(logdir); err == nil { + if files, err := os.ReadDir(logDir); err == nil { for _, f := range files { n := f.Name() if strings.HasSuffix(n, ".log") && n < before { - p := filepath.Join(logdir, n) + p := filepath.Join(logDir, n) Debugf("Remove expired log %q", p) os.Remove(p) } diff --git a/notify/webpush/webpush.go b/notify/webpush/webpush.go index 44ae3ad6..aaf3ca1a 100644 --- a/notify/webpush/webpush.go +++ b/notify/webpush/webpush.go @@ -225,7 +225,7 @@ func readECPrivateKey(path string) (key *ecdsa.PrivateKey, err error) { return x509.ParseECPrivateKey(blk.Bytes) } } - return nil, errors.New(`Cannot found "EC PRIVATE KEY" in pem blocks`) + return nil, errors.New(`Cannot find "EC PRIVATE KEY" in pem blocks`) } func (p *Plugin) GetPublicKey() []byte { diff --git a/scripts/decrypt-log.go b/scripts/decrypt-log.go new file mode 100644 index 00000000..d84c71a5 --- /dev/null +++ b/scripts/decrypt-log.go @@ -0,0 +1,164 @@ +/** + * OpenBmclAPI (Golang Edition) + * Copyright (C) 2024 Kevin Z + * All rights reserved + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package main + +import ( + "compress/gzip" + "crypto/aes" + "crypto/cipher" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + "sync" +) + +func ParseRSAPrivateKey(content []byte) (key *rsa.PrivateKey, err error) { + for { + var blk *pem.Block + blk, content = pem.Decode(content) + if blk == nil { + break + } + if blk.Type == "RSA PRIVATE KEY" { + k, err := x509.ParsePKCS8PrivateKey(blk.Bytes) + if err != nil { + return nil, err + } + return k.(*rsa.PrivateKey), nil + } + } + return nil, errors.New(`Cannot find "RSA PRIVATE KEY" in pem blocks`) +} + +func DecryptStream(w io.Writer, r io.Reader, key *rsa.PrivateKey) (err error) { + encryptedAESKey := make([]byte, key.Size()) + if _, err = io.ReadFull(r, encryptedAESKey); err != nil { + return + } + aesKey, err := rsa.DecryptPKCS1v15(nil, key, encryptedAESKey[:]) + if err != nil { + return + } + blk, err := aes.NewCipher(aesKey) + if err != nil { + return + } + iv := make([]byte, blk.BlockSize()) + if _, err = io.ReadFull(r, iv); err != nil { + return + } + sr := &cipher.StreamReader{ + S: cipher.NewCTR(blk, iv), + R: r, + } + buf := make([]byte, blk.BlockSize()*256) + if _, err = io.CopyBuffer(w, sr, buf); err != nil { + return + } + return +} + +func main() { + flag.Parse() + keyFile, logFile := flag.Arg(0), flag.Arg(1) + enableGzip := strings.Contains(logFile, ".gz.") + keyContent, err := os.ReadFile(keyFile) + if err != nil { + fmt.Println("Cannot read", keyFile, ":", err) + os.Exit(1) + } + key, err := ParseRSAPrivateKey(keyContent) + if err != nil { + fmt.Println("Cannot parse key from", keyFile, ":", err) + os.Exit(1) + } + fd, err := os.Open(logFile) + if err != nil { + fmt.Println("Cannot open", logFile, ":", err) + os.Exit(1) + } + defer fd.Close() + dst, err := createNewFile(logFile) + if err != nil { + fmt.Println("Cannot create new file:", err) + os.Exit(1) + } + dstName := dst.Name() + defer dst.Close() + var w io.WriteCloser = dst + var wg sync.WaitGroup + if enableGzip { + pr, pw := io.Pipe() + wg.Add(1) + go func(w io.WriteCloser) { + defer wg.Done() + defer w.Close() + gr, err := gzip.NewReader(pr) + if err != nil { + dst.Close() + os.Remove(dstName) + fmt.Println("Cannot decompress data:", err) + os.Exit(2) + } + defer gr.Close() + if _, err = io.Copy(w, gr); err != nil { + dst.Close() + os.Remove(dstName) + fmt.Println("Cannot copy decompressed data:", err) + os.Exit(2) + } + }(w) + w = pw + } + if err := DecryptStream(w, fd, key); err != nil { + dst.Close() + os.Remove(dstName) + fmt.Println("Decrypt error:", err) + os.Exit(2) + } + w.Close() + wg.Wait() +} + +func createNewFile(name string) (fd *os.File, err error) { + basename, _, _ := strings.Cut(name, ".") + fd, err = os.OpenFile(basename+".log", os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + if !errors.Is(err, os.ErrExist) { + return + } + } + for i := 2; i < 100; i++ { + fd, err = os.OpenFile(fmt.Sprintf("%s.%d.log", basename, i), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err == nil { + return + } + if !errors.Is(err, os.ErrExist) { + return + } + } + return +} diff --git a/scripts/docker-run.sh b/scripts/docker-run.sh index 02539216..7d55db24 100755 --- a/scripts/docker-run.sh +++ b/scripts/docker-run.sh @@ -19,7 +19,7 @@ docker pull craftmine/go-openbmclapi:latest || { echo "[ERROR] Failed to pull docker image 'craftmine/go-openbmclapi:latest'" if ! docker images craftmine/go-openbmclapi | grep latest; then - echo "Can not found docker image 'craftmine/go-openbmclapi:latest'" + echo "Cannot find docker image 'craftmine/go-openbmclapi:latest'" exit 1 fi } diff --git a/utils/crypto.go b/utils/crypto.go index 0e18809f..55c90c0b 100644 --- a/utils/crypto.go +++ b/utils/crypto.go @@ -20,16 +20,21 @@ package utils import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" "crypto/rand" + "crypto/rsa" "crypto/sha256" "crypto/subtle" + "crypto/x509" "encoding/base64" "encoding/hex" + "encoding/pem" "errors" "io" "os" "path/filepath" - "crypto/hmac" ) func ComparePasswd(p1, p2 string) bool { @@ -92,3 +97,72 @@ func LoadOrCreateHmacKey(dataDir string) (key []byte, err error) { } return } + +const developorRSAKey = ` +-----BEGIN RSA PUBLIC KEY----- +MIIBCgKCAQEAqIvK9cVuDtD/V4w7/xIPI2mnv2VV0CTQfelDaEB4vonsblwIp3VV +1S3oYXY8thyCscBKG/AkryKHS0U1TXoIMPDai3vkLDL5sY4mmh4aFCoGKdRmNNyr +kAjaLo51gnadCMbaoxVNzQ1naJvZjU02ClJvBjtTETUzrqFnx8th04P0bSZHZEwV +vmCniuzzuAcNI92hpoSEqp4WGqINp2hoAFnoHENgnAsb94jJX4VfWbMySR1O+ykz +RkGvslKPhvv/YkCxy0Xi2FgXnb+xw/CXevkTvu7WSVodvZ0OtAHPTp6kCTWt3tun +PN0d0McYg73htD0ItifOcJQWPPnFZKezUQIDAQAB +-----END RSA PUBLIC KEY-----` + +var DeveloporPublicKey *rsa.PublicKey + +func init() { + var err error + DeveloporPublicKey, err = ParseRSAPublicKey(([]byte)(developorRSAKey)) + if err != nil { + panic(err) + } +} + +func ParseRSAPublicKey(content []byte) (key *rsa.PublicKey, err error) { + for { + var blk *pem.Block + blk, content = pem.Decode(content) + if blk == nil { + break + } + if blk.Type == "RSA PUBLIC KEY" { + return x509.ParsePKCS1PublicKey(blk.Bytes) + } + } + return nil, errors.New(`Cannot find "RSA PUBLIC KEY" in pem blocks`) +} + +func EncryptStream(w io.Writer, r io.Reader, publicKey *rsa.PublicKey) (err error) { + var aesKey [16]byte + if _, err = io.ReadFull(rand.Reader, aesKey[:]); err != nil { + return + } + // rsa.EncryptOAEP(sha256.New, rand.Reader, publicKey, aesKey[:], ([]byte)("aes-stream-key")) + encryptedAESKey, err := rsa.EncryptPKCS1v15(rand.Reader, publicKey, aesKey[:]) + if err != nil { + return + } + blk, err := aes.NewCipher(aesKey[:]) + if err != nil { + return + } + iv := make([]byte, blk.BlockSize()) + if _, err = io.ReadFull(rand.Reader, iv); err != nil { + return + } + sw := &cipher.StreamWriter{ + S: cipher.NewCTR(blk, iv), + W: w, + } + if _, err = w.Write(encryptedAESKey); err != nil { + return + } + if _, err = w.Write(iv); err != nil { + return + } + buf := make([]byte, blk.BlockSize()*256) + if _, err = io.CopyBuffer(sw, r, buf); err != nil { + return + } + return +}