From e0cf8a12a94673eae8eb2ca3206258a9f368d89c Mon Sep 17 00:00:00 2001 From: Altair-sh Date: Sun, 1 Nov 2026 20:43:08 +0100 Subject: [PATCH] implemented HTTP 1.0/1.1 server --- .gitignore | 3 +- cmd/serveg/main.go | 28 ++- internal/http/Connection.go | 38 ++++ internal/http/Request.go | 172 ++++++++++++++++++ internal/http/Response.go | 150 ++++++++++++++++ internal/http/StatusCodes.go | 220 ++++++++++++++++++++++++ internal/http/errors.go | 14 ++ internal/log/log.go | 5 +- internal/server/Config.go | 28 +-- internal/server/Server.go | 132 +++++++++++--- internal/string_helper/StringBuilder.go | 117 +++++++++++++ internal/string_helper/path.go | 104 +++++++++++ internal/string_helper/unsafe.go | 13 ++ 13 files changed, 956 insertions(+), 68 deletions(-) create mode 100644 internal/http/Connection.go create mode 100644 internal/http/Request.go create mode 100644 internal/http/Response.go create mode 100644 internal/http/StatusCodes.go create mode 100644 internal/http/errors.go create mode 100644 internal/string_helper/StringBuilder.go create mode 100644 internal/string_helper/path.go create mode 100644 internal/string_helper/unsafe.go diff --git a/.gitignore b/.gitignore index 0a7fba8..aa32608 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /serveg.toml /serveg -bin/ \ No newline at end of file +bin/ +__debug_bin* diff --git a/cmd/serveg/main.go b/cmd/serveg/main.go index e6ab27d..84728e6 100644 --- a/cmd/serveg/main.go +++ b/cmd/serveg/main.go @@ -22,29 +22,27 @@ func run() int { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - configPath := *flag.String("config", "serveg.toml", "path to config file") + configPath := flag.String("config", "serveg.toml", "path to config file") flag.Parse() - conf, err := server.ParseOrCreateConfig(configPath) + conf, err := server.ParseOrCreateConfig(*configPath) if err != nil { + logger.Error("can't parse config: ", err) + return 1 + } + + srv := server.Server{Conf: conf} + if err := srv.StartGoroutine(); err != nil { logger.Error(err) return 1 } - errCh := make(chan error, 1) - go func() { errCh <- server.Serve(conf) }() - logger.Info("server started") - - select { - // server failed to start - case err := <-errCh: - logger.Error(err) - return 1 // got SIGINT/SIGTERM - case <-ctx.Done(): - stop() // restore default behaviour: a second Ctrl+C kills immediately - logger.Info("shutting down") - } + <-ctx.Done() + stop() // restore default behaviour: a second Ctrl+C kills immediately + logger.Info("shutting down") + // waits for server stop + srv.Stop() return 0 } diff --git a/internal/http/Connection.go b/internal/http/Connection.go new file mode 100644 index 0000000..c19c0b1 --- /dev/null +++ b/internal/http/Connection.go @@ -0,0 +1,38 @@ +package http + +import ( + "bufio" + "net" + sh "serveg/internal/string_helper" + "strings" +) + +const maxHeaderBytes = 16 * 1024 +const initialWriteBufCap = 512 + +type mapStrStr map[string]string +type HeadersMap map[string]string + +type Connection struct { + socket *net.TCPConn + r *bufio.Reader + wbuf sh.StringBuilder +} + +func NewConnection(socket *net.TCPConn) *Connection { + return &Connection{ + socket: socket, + r: bufio.NewReaderSize(socket, maxHeaderBytes), + wbuf: sh.StringBuilder{Buf: make([]byte, 0, initialWriteBufCap)}, + } +} + +func HeaderValueContains(sourceSplit []string, target string) bool { + for _, v := range sourceSplit { + v = strings.TrimSpace(v) + if strings.EqualFold(v, target) { + return true + } + } + return false +} diff --git a/internal/http/Request.go b/internal/http/Request.go new file mode 100644 index 0000000..c3184cd --- /dev/null +++ b/internal/http/Request.go @@ -0,0 +1,172 @@ +package http + +import ( + "net/url" + sh "serveg/internal/string_helper" + "strings" +) + +type Request struct { + Method string + Protocol string + // url-encoded characters (%D0), query params (?v=123) + PathRaw string + Path string + QueryParams mapStrStr + // keys are in lower case + // duplicate keys are joined with ", " + Headers HeadersMap + // if true, don't close connection after response + WantsKeepAlive bool +} + +// Read request (body not supported) +// Returns valid request pointer. If err != nil, req is partially parsed. +// It doesn't enforce "Host" header because i don't need it in this project. +// +// if httpErr, ok := errors.AsType[*http.HttpError](err); ok { +// // Request is malformed, socket buffer contains unread garbage data. +// // Send error code and close connection. +// } else { +// // something bad happened with the connection, just close it +// } +func (conn *Connection) ReadRequest() (req *Request, err error) { + req = new(Request) + var readN int + // METHOD /path HTTP/ver + if err = conn.readFirstLine(req, &readN); err != nil { + return + } + // key: value + // ... + // empty line + if err = conn.readHeaders(req, &readN); err != nil { + return + } + + // This server doesn't accept request bodies. + // Reject connection rather than desync the stream. + contentLength := req.Headers["content-length"] + transferEncoding := req.Headers["transfer-encoding"] + if (contentLength != "" && contentLength != "0") || + transferEncoding != "" { + err = NewHttpError(StatusBadRequest, + "request bodies are not supported") + } + + // set WantsKeepAlive + connectionStrSplit := strings.Split(req.Headers["connection"], ",") + if req.Protocol == "HTTP/1.0" { + req.WantsKeepAlive = HeaderValueContains(connectionStrSplit, "keep-alive") + } else { + req.WantsKeepAlive = !HeaderValueContains(connectionStrSplit, "close") + } + + return +} + +func (conn *Connection) readLineBelowLimit(readN *int) (string, error) { + // read up to maxHeaderBytes + slice, notLineEnd, err := conn.r.ReadLine() + if err != nil { + // something is wrong with tcp socket + return "", err + } + if notLineEnd { + // buffer end was reached but no '\n' found + return "", NewHttpError(StatusBadRequest, "line end not found") + } + + *readN += len(slice) + 1 // \n + if *readN > maxHeaderBytes { + return "", NewHttpError(StatusRequestHeaderFieldsTooLarge, + "headers too large") + } + + return strings.TrimRight(string(slice), " \r\n"), nil +} + +func (conn *Connection) readFirstLine(req *Request, readN *int) error { + line, err := conn.readLineBelowLimit(readN) + if err != nil { + return err + } + // skip one empty line + // some clients send it between requests + if line == "" { + line, err = conn.readLineBelowLimit(readN) + if err != nil { + return err + } + } + + parts := strings.Split(line, " ") + if len(parts) != 3 || !strings.HasPrefix(parts[2], "HTTP/") { + return NewHttpError(StatusBadRequest, + "malformed request line '"+line+"'") + } + req.Method = parts[0] + // url-encoded characters (%D0), query params (?v=123) + req.PathRaw = parts[1] + req.Protocol = parts[2] + + if req.Protocol != "HTTP/1.0" && req.Protocol != "HTTP/1.1" { + return NewHttpError(StatusHTTPVersionNotSupported, + "request protocol not supported: "+req.Protocol) + } + + u, err := url.ParseRequestURI(req.PathRaw) + if err != nil { + return NewHttpError(StatusBadRequest, err.Error()) + } + + req.Path = sh.ResolveSafePath(u.Path) + if req.Path == "" { + return NewHttpError(StatusForbidden, + "unsafe request path '"+u.Path+"'") + } + req.QueryParams = make(mapStrStr) + for key, values := range u.Query() { + req.QueryParams[key] = values[0] + } + + return nil +} + +func (conn *Connection) readHeaders(req *Request, readN *int) error { + req.Headers = make(HeadersMap) + for { + line, err := conn.readLineBelowLimit(readN) + if err != nil { + return err + } + // empty line means end of headers + if line == "" { + break + } + + i := strings.IndexByte(line, ':') + if i < 1 { + return NewHttpError(StatusBadRequest, + "not a header line '"+line+"'") + } + key := line[:i] + firstB, lastB := key[0], key[i-1] + if firstB == ' ' || firstB == '\t' || + lastB == ' ' || lastB == '\t' { + return NewHttpError(StatusBadRequest, + "header name must not start or end with space") + } + key = strings.ToLower(key) + + // val can be empty + val := strings.TrimSpace(line[i+1:]) + // if header repeats, join values with ", " + existing := req.Headers[key] + if existing != "" { + val = existing + ", " + val + } + req.Headers[key] = val + } + return nil +} diff --git a/internal/http/Response.go b/internal/http/Response.go new file mode 100644 index 0000000..e559499 --- /dev/null +++ b/internal/http/Response.go @@ -0,0 +1,150 @@ +package http + +import ( + "errors" + "io" + "net" + sh "serveg/internal/string_helper" + "strconv" + "strings" + "time" +) + +type responseBodyType uint8 + +const ( + responseBodyEmpty = responseBodyType(iota) + responseBodySmall + responseBodyBig +) + +const TextPlain = "text/plain; charset=utf-8" + +type Response struct { + Status StatusCode + Headers HeadersMap // keys are in lower case + omitBody bool // don't add body and content-* headers for HEAD request + bodyType responseBodyType + bodySmall []byte // small body is sent in one write() call with headers + bodyBig io.WriterTo // big body, sent as multiple write() calls to socket +} + +const TimeFormat = "Mon, 02 Jan 2006 15:04:05 GMT" + +func (conn *Connection) CreateResponse( + status StatusCode, req *Request, wantsClose bool, +) *Response { + res := &Response{ + Status: status, + Headers: HeadersMap{ + "date": time.Now().UTC().Format(TimeFormat), + }, + } + + res.omitBody = req.Method == "HEAD" + if !res.omitBody && + status >= 200 && + status != StatusNoContent && + status != StatusNotModified { + // content-length 0 is mandatory for most responses + res.Headers["content-length"] = "0" + } + + if wantsClose || !req.WantsKeepAlive { + res.Headers["connection"] = "close" + } else if req.Protocol == "HTTP/1.0" { + res.Headers["connection"] = "keep-alive" + } + + return res +} + +func (res *Response) AttachBodySmall(body []byte, contentType string) { + if res.omitBody { + return + } + + if res.bodyType != responseBodyEmpty { + panic("response already has body") + } + res.bodyType = responseBodySmall + res.bodySmall = body + lenStr := strconv.FormatUint(uint64(len(body)), 10) + res.Headers["content-length"] = lenStr + res.Headers["content-type"] = contentType +} +func (res *Response) AttachBodySmallStr(body string, contentType string) { + res.AttachBodySmall(sh.UnsafeGetStrBytes(body), contentType) +} + +func (res *Response) AttachBodyBig(body io.WriterTo, + size uint64, contentType string) { + + if res.omitBody { + return + } + if res.bodyType != responseBodyEmpty { + panic("response already has body") + } + res.bodyType = responseBodyBig + res.bodyBig = body + lenStr := strconv.FormatUint(size, 10) + res.Headers["content-length"] = lenStr + res.Headers["content-type"] = contentType +} + +var ErrInvalidHeader = errors.New("http: header key or value contains CR or LF") + +func (conn *Connection) SendResponse(res *Response) error { + sb := &conn.wbuf + sb.Reset() + + // first line + // space before \r\n is required by standard + sb.AddS("HTTP/1.1 ").AddU(uint64(res.Status)).AddS(" \r\n") + + // headers + for k, v := range res.Headers { + // prevent response splitting / header injection + if strings.ContainsAny(k, "\r\n:") || strings.ContainsAny(v, "\r\n") { + return ErrInvalidHeader + } + sb.AddS(k).AddS(": ").AddS(v).AddS("\r\n") + } + // empty line + sb.AddS("\r\n") + + switch res.bodyType { + case responseBodyEmpty: + // net.Conn.Write follows the io.Writer contract: it returns a non-nil + // error whenever n < len(p), so no loop is needed. + if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil { + return err + } + case responseBodySmall: + // write headers and body in one socket.Write() call + bbb := net.Buffers{sb.Buf, res.bodySmall} + if _, err := bbb.WriteTo(conn.socket); err != nil { + return err + } + case responseBodyBig: + // write headers, then write big body + if _, err := conn.socket.Write(conn.wbuf.Buf); err != nil { + return err + } + if _, err := res.bodyBig.WriteTo(conn.socket); err != nil { + return err + } + } + return nil +} + +func (conn *Connection) SendErrorResponse( + httpErr *HttpError, req *Request, wantsClose bool, +) error { + + res := conn.CreateResponse(httpErr.Status, req, wantsClose) + // attach error message if method allows + res.AttachBodySmallStr(httpErr.Error(), TextPlain) + return conn.SendResponse(res) +} diff --git a/internal/http/StatusCodes.go b/internal/http/StatusCodes.go new file mode 100644 index 0000000..cb8c410 --- /dev/null +++ b/internal/http/StatusCodes.go @@ -0,0 +1,220 @@ +package http + +import ( + "strconv" +) + +type StatusCode uint16 + +func (c StatusCode) String() string { + numStr := strconv.FormatUint(uint64(c), 10) + return statusText(c) + " (" + numStr + ")" +} + +/// Copied from /usr/lib/go/src/net/http/status.go +/// Copyright 2009 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// HTTP status codes as registered with IANA. +// See: https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml +const ( + StatusContinue StatusCode = 100 // RFC 9110, 15.2.1 + StatusSwitchingProtocols StatusCode = 101 // RFC 9110, 15.2.2 + StatusProcessing StatusCode = 102 // RFC 2518, 10.1 + StatusEarlyHints StatusCode = 103 // RFC 8297 + + StatusOK StatusCode = 200 // RFC 9110, 15.3.1 + StatusCreated StatusCode = 201 // RFC 9110, 15.3.2 + StatusAccepted StatusCode = 202 // RFC 9110, 15.3.3 + StatusNonAuthoritativeInfo StatusCode = 203 // RFC 9110, 15.3.4 + StatusNoContent StatusCode = 204 // RFC 9110, 15.3.5 + StatusResetContent StatusCode = 205 // RFC 9110, 15.3.6 + StatusPartialContent StatusCode = 206 // RFC 9110, 15.3.7 + StatusMultiStatus StatusCode = 207 // RFC 4918, 11.1 + StatusAlreadyReported StatusCode = 208 // RFC 5842, 7.1 + StatusIMUsed StatusCode = 226 // RFC 3229, 10.4.1 + + StatusMultipleChoices StatusCode = 300 // RFC 9110, 15.4.1 + StatusMovedPermanently StatusCode = 301 // RFC 9110, 15.4.2 + StatusFound StatusCode = 302 // RFC 9110, 15.4.3 + StatusSeeOther StatusCode = 303 // RFC 9110, 15.4.4 + StatusNotModified StatusCode = 304 // RFC 9110, 15.4.5 + StatusUseProxy StatusCode = 305 // RFC 9110, 15.4.6 + _ StatusCode = 306 // RFC 9110, 15.4.7 (Unused) + StatusTemporaryRedirect StatusCode = 307 // RFC 9110, 15.4.8 + StatusPermanentRedirect StatusCode = 308 // RFC 9110, 15.4.9 + + StatusBadRequest StatusCode = 400 // RFC 9110, 15.5.1 + StatusUnauthorized StatusCode = 401 // RFC 9110, 15.5.2 + StatusPaymentRequired StatusCode = 402 // RFC 9110, 15.5.3 + StatusForbidden StatusCode = 403 // RFC 9110, 15.5.4 + StatusNotFound StatusCode = 404 // RFC 9110, 15.5.5 + StatusMethodNotAllowed StatusCode = 405 // RFC 9110, 15.5.6 + StatusNotAcceptable StatusCode = 406 // RFC 9110, 15.5.7 + StatusProxyAuthRequired StatusCode = 407 // RFC 9110, 15.5.8 + StatusRequestTimeout StatusCode = 408 // RFC 9110, 15.5.9 + StatusConflict StatusCode = 409 // RFC 9110, 15.5.10 + StatusGone StatusCode = 410 // RFC 9110, 15.5.11 + StatusLengthRequired StatusCode = 411 // RFC 9110, 15.5.12 + StatusPreconditionFailed StatusCode = 412 // RFC 9110, 15.5.13 + StatusRequestEntityTooLarge StatusCode = 413 // RFC 9110, 15.5.14 + StatusRequestURITooLong StatusCode = 414 // RFC 9110, 15.5.15 + StatusUnsupportedMediaType StatusCode = 415 // RFC 9110, 15.5.16 + StatusRequestedRangeNotSatisfiable StatusCode = 416 // RFC 9110, 15.5.17 + StatusExpectationFailed StatusCode = 417 // RFC 9110, 15.5.18 + StatusTeapot StatusCode = 418 // RFC 9110, 15.5.19 (Unused) + StatusMisdirectedRequest StatusCode = 421 // RFC 9110, 15.5.20 + StatusUnprocessableEntity StatusCode = 422 // RFC 9110, 15.5.21 + StatusLocked StatusCode = 423 // RFC 4918, 11.3 + StatusFailedDependency StatusCode = 424 // RFC 4918, 11.4 + StatusTooEarly StatusCode = 425 // RFC 8470, 5.2. + StatusUpgradeRequired StatusCode = 426 // RFC 9110, 15.5.22 + StatusPreconditionRequired StatusCode = 428 // RFC 6585, 3 + StatusTooManyRequests StatusCode = 429 // RFC 6585, 4 + StatusRequestHeaderFieldsTooLarge StatusCode = 431 // RFC 6585, 5 + StatusUnavailableForLegalReasons StatusCode = 451 // RFC 7725, 3 + + StatusInternalServerError StatusCode = 500 // RFC 9110, 15.6.1 + StatusNotImplemented StatusCode = 501 // RFC 9110, 15.6.2 + StatusBadGateway StatusCode = 502 // RFC 9110, 15.6.3 + StatusServiceUnavailable StatusCode = 503 // RFC 9110, 15.6.4 + StatusGatewayTimeout StatusCode = 504 // RFC 9110, 15.6.5 + StatusHTTPVersionNotSupported StatusCode = 505 // RFC 9110, 15.6.6 + StatusVariantAlsoNegotiates StatusCode = 506 // RFC 2295, 8.1 + StatusInsufficientStorage StatusCode = 507 // RFC 4918, 11.5 + StatusLoopDetected StatusCode = 508 // RFC 5842, 7.2 + StatusNotExtended StatusCode = 510 // RFC 2774, 7 + StatusNetworkAuthenticationRequired StatusCode = 511 // RFC 6585, 6 +) + +func statusText(c StatusCode) string { + switch c { + case StatusContinue: + return "Continue" + case StatusSwitchingProtocols: + return "Switching Protocols" + case StatusProcessing: + return "Processing" + case StatusEarlyHints: + return "Early Hints" + case StatusOK: + return "OK" + case StatusCreated: + return "Created" + case StatusAccepted: + return "Accepted" + case StatusNonAuthoritativeInfo: + return "Non-Authoritative Information" + case StatusNoContent: + return "No Content" + case StatusResetContent: + return "Reset Content" + case StatusPartialContent: + return "Partial Content" + case StatusMultiStatus: + return "Multi-Status" + case StatusAlreadyReported: + return "Already Reported" + case StatusIMUsed: + return "IM Used" + case StatusMultipleChoices: + return "Multiple Choices" + case StatusMovedPermanently: + return "Moved Permanently" + case StatusFound: + return "Found" + case StatusSeeOther: + return "See Other" + case StatusNotModified: + return "Not Modified" + case StatusUseProxy: + return "Use Proxy" + case StatusTemporaryRedirect: + return "Temporary Redirect" + case StatusPermanentRedirect: + return "Permanent Redirect" + case StatusBadRequest: + return "Bad Request" + case StatusUnauthorized: + return "Unauthorized" + case StatusPaymentRequired: + return "Payment Required" + case StatusForbidden: + return "Forbidden" + case StatusNotFound: + return "Not Found" + case StatusMethodNotAllowed: + return "Method Not Allowed" + case StatusNotAcceptable: + return "Not Acceptable" + case StatusProxyAuthRequired: + return "Proxy Authentication Required" + case StatusRequestTimeout: + return "Request Timeout" + case StatusConflict: + return "Conflict" + case StatusGone: + return "Gone" + case StatusLengthRequired: + return "Length Required" + case StatusPreconditionFailed: + return "Precondition Failed" + case StatusRequestEntityTooLarge: + return "Request Entity Too Large" + case StatusRequestURITooLong: + return "Request URI Too Long" + case StatusUnsupportedMediaType: + return "Unsupported Media Type" + case StatusRequestedRangeNotSatisfiable: + return "Requested Range Not Satisfiable" + case StatusExpectationFailed: + return "Expectation Failed" + case StatusTeapot: + return "I'm a teapot" + case StatusMisdirectedRequest: + return "Misdirected Request" + case StatusUnprocessableEntity: + return "Unprocessable Entity" + case StatusLocked: + return "Locked" + case StatusFailedDependency: + return "Failed Dependency" + case StatusTooEarly: + return "Too Early" + case StatusUpgradeRequired: + return "Upgrade Required" + case StatusPreconditionRequired: + return "Precondition Required" + case StatusTooManyRequests: + return "Too Many Requests" + case StatusRequestHeaderFieldsTooLarge: + return "Request Header Fields Too Large" + case StatusUnavailableForLegalReasons: + return "Unavailable For Legal Reasons" + case StatusInternalServerError: + return "Internal Server Error" + case StatusNotImplemented: + return "Not Implemented" + case StatusBadGateway: + return "Bad Gateway" + case StatusServiceUnavailable: + return "Service Unavailable" + case StatusGatewayTimeout: + return "Gateway Timeout" + case StatusHTTPVersionNotSupported: + return "HTTP Version Not Supported" + case StatusVariantAlsoNegotiates: + return "Variant Also Negotiates" + case StatusInsufficientStorage: + return "Insufficient Storage" + case StatusLoopDetected: + return "Loop Detected" + case StatusNotExtended: + return "Not Extended" + case StatusNetworkAuthenticationRequired: + return "Network Authentication Required" + default: + return "INVALID STATUS CODE" + } +} diff --git a/internal/http/errors.go b/internal/http/errors.go new file mode 100644 index 0000000..788424e --- /dev/null +++ b/internal/http/errors.go @@ -0,0 +1,14 @@ +package http + +type HttpError struct { + Status StatusCode + Msg string +} + +func (e *HttpError) Error() string { + return e.Status.String() + ": " + e.Msg +} + +func NewHttpError(code StatusCode, msg string) *HttpError { + return &HttpError{Status: code, Msg: msg} +} diff --git a/internal/log/log.go b/internal/log/log.go index c6091e5..e9f4f84 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -37,6 +37,7 @@ package log import ( "fmt" "os" + sh "serveg/internal/string_helper" "sync" "time" ) @@ -164,9 +165,9 @@ func buildMessage(context string, severity Severity, a ...any) message { return message{text, color} } -func (msg message) print() { +func (msg *message) print() { SetColorFg(msg.color) - if _, err := os.Stdout.Write([]byte(msg.text)); err != nil { + if _, err := os.Stdout.Write(sh.UnsafeGetStrBytes(msg.text)); err != nil { panic(err) } } diff --git a/internal/server/Config.go b/internal/server/Config.go index 269fa68..f536227 100644 --- a/internal/server/Config.go +++ b/internal/server/Config.go @@ -1,23 +1,21 @@ package server import ( - "encoding" "errors" "fmt" "io/fs" - "net" "os" "github.com/BurntSushi/toml" ) +// TODO: add example config file type Config struct { - Port uint16 `toml:"port"` - Ip IP `toml:"ip"` + Addr string `toml:"address"` } func ReadConfig(path string) (*Config, error) { - var config = new(Config) + var config = DefaultConfig() tomlMeta, err := toml.DecodeFile(path, config) if err != nil { return nil, err @@ -33,8 +31,7 @@ func ReadConfig(path string) (*Config, error) { func DefaultConfig() *Config { return &Config{ - Port: 8080, - Ip: IP{net.IPv4(127, 0, 0, 1)}, + Addr: "127.0.0.1:8080", } } @@ -62,20 +59,3 @@ func WriteConfig(config *Config, path string) (err error) { enc.Indent = " " // 4 return enc.Encode(config) } - -type IP struct { - net.IP -} - -var _ encoding.TextUnmarshaler = (*IP)(nil) -var _ encoding.TextMarshaler = IP{} - -func (a *IP) UnmarshalText(text []byte) error { - s := string(text) - ip := net.ParseIP(s) - if ip == nil { - return errors.New("can't parse '" + s + "' as IP address") - } - a.IP = ip - return nil -} diff --git a/internal/server/Server.go b/internal/server/Server.go index 1e2d0a9..6c7d552 100644 --- a/internal/server/Server.go +++ b/internal/server/Server.go @@ -1,59 +1,139 @@ package server import ( + "errors" + "io" "net" + "os" + "serveg/internal/http" "serveg/internal/log" "strconv" + "sync" + "syscall" "time" ) -func Serve(conf *Config) error { - logger := log.LoggerContext{Name: "server"} - addr := net.JoinHostPort(conf.Ip.String(), strconv.Itoa(int(conf.Port))) +type Server struct { + Conf *Config + listener net.Listener + logger log.LoggerContext + wg sync.WaitGroup +} - logger.Info("listening at", addr) - srv, err := net.Listen("tcp", addr) - if err != nil { - return err +func (srv *Server) StartGoroutine() (err error) { + srv.logger = log.LoggerContext{Name: "server"} + srv.logger.Info("listening at ", srv.Conf.Addr) + srv.listener, err = net.Listen("tcp", srv.Conf.Addr) + if err == nil { + srv.wg.Go(srv.serve) } - defer func() { _ = srv.Close() }() + return +} +func (srv *Server) serve() { + srv.logger.Info("server started") for i := int64(0); ; i++ { - conn, err := srv.Accept() + conn, err := srv.listener.Accept() if err != nil { - logger.Warn(err) + if errors.Is(err, net.ErrClosed) { + srv.logger.Info("listener socket closed") + return + } + srv.logger.Warn(err) continue } - go handleConnection(conn, i) + // TODO: add connection to map. SetDeadline(Now()) in Stop(). + // srv.wg.Go(func() { handleConnection(conn.(*net.TCPConn), i) }) + go handleConnection(conn.(*net.TCPConn), i) } } -func handleConnection(conn net.Conn, n int64) { +func (srv *Server) Stop() { + srv.logger.Info("stopping server") + _ = srv.listener.Close() + srv.wg.Wait() +} + +func handleConnection(socket *net.TCPConn, n int64) { logger := log.LoggerContext{Name: "conn-" + strconv.FormatInt(n, 10)} defer func() { if r := recover(); r != nil { logger.Error("panic: ", r) } - _ = conn.Close() + _ = socket.Close() + logger.Info("connection closed") }() - buf := make([]byte, 4096) - + conn := http.NewConnection(socket) for { - if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { - logger.Warn(err) - break - } - n, err := conn.Read(buf) + err := socket.SetDeadline(time.Now().Add(30 * time.Second)) if err != nil { logger.Warn(err) - break + return } - msgBytes := buf[:n] - logger.Info("msg: `", string(msgBytes), "`") - if _, err := conn.Write(msgBytes); err != nil { - logger.Warn(err) - break + + req, err := conn.ReadRequest() + if err != nil { + logConnError(logger, err) + + if httpErr, ok := errors.AsType[*http.HttpError](err); ok { + // return error code to client + logger.Info("sending error response") + // req is partially parsed, may use fields + if err = conn.SendErrorResponse(httpErr, req, true); err != nil { + logger.Warn("can't send error response") + return + } + } + // close the connection + logger.Info("lingering close") + lingeringClose(socket) + return + } + logger.Info(req.Method, " ", req.Path, " ", req.Protocol, + "\nHeaders: ", req.Headers) + + resBody := "Hi\n" + time.Now().Format(time.DateTime) + + res := conn.CreateResponse(http.StatusOK, req, false) + res.AttachBodySmallStr(resBody, http.TextPlain) + logger.Info("sending response: ", res.Status, + "\nHeaders: ", res.Headers) + err = conn.SendResponse(res) + if err != nil { + logConnError(logger, err) + return + } + + // it is set by CreateResponse + if res.Headers["connection"] == "close" { + logger.Info("lingering close") + lingeringClose(socket) + return } } } + +func logConnError(logger log.LoggerContext, err error) { + if errors.Is(err, os.ErrDeadlineExceeded) { + logger.Info("time out") + } else if errors.Is(err, io.EOF) || + errors.Is(err, syscall.EPIPE) || + errors.Is(err, syscall.ECONNRESET) { + + logger.Info("closed by client") + } else { + logger.Warn(err) + } +} + +// lingeringClose: shut down our side, then drain the client's unread data, +// so close() doesn't send RST and destroy the response before it's read. +// Limits: 500 ms, 32 KB +func lingeringClose(socket *net.TCPConn) { + if socket.CloseWrite() != nil { + return + } + _ = socket.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + _, _ = io.Copy(io.Discard, io.LimitReader(socket, 32*1024)) +} diff --git a/internal/string_helper/StringBuilder.go b/internal/string_helper/StringBuilder.go new file mode 100644 index 0000000..c2aa2c3 --- /dev/null +++ b/internal/string_helper/StringBuilder.go @@ -0,0 +1,117 @@ +package string_helper + +import ( + "io" + "slices" + "strconv" + "unicode/utf8" +) + +type StringBuilder struct { + // allocate it with size of estimated output + Buf []byte +} + +// compile-time interface checks +var ( + _ io.Writer = (*StringBuilder)(nil) + _ io.StringWriter = (*StringBuilder)(nil) + _ io.ByteWriter = (*StringBuilder)(nil) +) + +// Write implements [io.Writer]. It never returns an error. +func (b *StringBuilder) Write(p []byte) (int, error) { + b.Buf = append(b.Buf, p...) + return len(p), nil +} + +// WriteString implements [io.StringWriter]. It never returns an error. +func (b *StringBuilder) WriteString(s string) (int, error) { + b.Buf = append(b.Buf, s...) + return len(s), nil +} + +// WriteByte implements [io.ByteWriter]. It never returns an error. +func (b *StringBuilder) WriteByte(c byte) error { + b.Buf = append(b.Buf, c) + return nil +} + +// Reset empties the builder but keeps the allocated capacity for reuse. +func (b *StringBuilder) Reset() { b.Buf = b.Buf[:0] } + +// Grow ensures there is room for at least n more bytes +func (b *StringBuilder) Grow(n int) { b.Buf = slices.Grow(b.Buf, n) } + +func (b *StringBuilder) Len() int { return len(b.Buf) } + +func (b *StringBuilder) AddS(v string) *StringBuilder { + b.Buf = append(b.Buf, v...) + return b +} + +func (b *StringBuilder) AddB(v byte) *StringBuilder { + b.Buf = append(b.Buf, v) + return b +} + +func (b *StringBuilder) AddR(v rune) *StringBuilder { + b.Buf = utf8.AppendRune(b.Buf, v) + return b +} + +func (b *StringBuilder) AddBool(v bool) *StringBuilder { + b.Buf = strconv.AppendBool(b.Buf, v) + return b +} + +// numberBase: 2 <= base <= 36 +func (b *StringBuilder) AddIExt(v int64, numberBase int) *StringBuilder { + b.Buf = strconv.AppendInt(b.Buf, v, numberBase) + return b +} +func (b *StringBuilder) AddI(v int64) *StringBuilder { return b.AddIExt(v, 10) } +func (b *StringBuilder) AddI32(v int32) *StringBuilder { return b.AddI(int64(v)) } +func (b *StringBuilder) AddI16(v int16) *StringBuilder { return b.AddI(int64(v)) } +func (b *StringBuilder) AddI8(v int8) *StringBuilder { return b.AddI(int64(v)) } + +// numberBase: 2 <= base <= 36 +func (b *StringBuilder) AddUExt(v uint64, numberBase int) *StringBuilder { + b.Buf = strconv.AppendUint(b.Buf, v, numberBase) + return b +} +func (b *StringBuilder) AddU(v uint64) *StringBuilder { return b.AddUExt(v, 10) } +func (b *StringBuilder) AddU32(v uint32) *StringBuilder { return b.AddU(uint64(v)) } +func (b *StringBuilder) AddU16(v uint16) *StringBuilder { return b.AddU(uint64(v)) } +func (b *StringBuilder) AddU8(v uint8) *StringBuilder { return b.AddU(uint64(v)) } + +// fmtChar: see [strconv.FormatFloat] +// precision: number of digits after . (-1 for max precision) +func (b *StringBuilder) AddF32Ext(v float32, fmtChar byte, precision int) *StringBuilder { + b.Buf = strconv.AppendFloat(b.Buf, float64(v), fmtChar, precision, 32) + return b +} +func (b *StringBuilder) AddF32(v float32) *StringBuilder { + return b.AddF32Ext(v, 'g', -1) +} + +// fmtChar: see [strconv.FormatFloat] +// precision: number of digits after . (-1 for max precision) +func (b *StringBuilder) AddF64Ext(v float64, fmtChar byte, precision int) *StringBuilder { + b.Buf = strconv.AppendFloat(b.Buf, v, fmtChar, precision, 64) + return b +} +func (b *StringBuilder) AddF64(v float64) *StringBuilder { + return b.AddF64Ext(v, 'g', -1) +} + +// creates copy of buffer +func (b *StringBuilder) String() string { + return string(b.Buf) +} + +// doesn't copy buffer, +// so you shouldn't modify b.Buf before using the string +func (b *StringBuilder) StringUnsafe() string { + return UnsafeBytesToStr(b.Buf) +} diff --git a/internal/string_helper/path.go b/internal/string_helper/path.go new file mode 100644 index 0000000..ad98643 --- /dev/null +++ b/internal/string_helper/path.go @@ -0,0 +1,104 @@ +package string_helper + +import ( + "strings" +) + +// Returns path cleaned from "./", "/./", "//" +// or empty string if path is not safe. +// Path is considered unsafe if it: +// - contains ".." segments +// - ends with '.' +// - contains `\:*?"<>|` +// - contains windows-restricted names COM, CON, ... +func ResolveSafePath(decodedPath string) string { + var sb StringBuilder + sb.Grow(len(decodedPath)) + var seg string + for rest := decodedPath; rest != ""; { + seg, rest = nextSegment(rest) + if seg == "" || seg == "." { + continue + } + if !isSafeSegment(seg) { + return "" + } + sb.AddB('/') + sb.AddS(seg) + } + if sb.Len() == 0 { + return "/" + } + return sb.StringUnsafe() +} + +// nextSegment splits off the first '/'-separated segment of s. +func nextSegment(s string) (seg, rest string) { + seg, rest, _ = strings.Cut(s, "/") + return +} + +// isSafeSegment reports whether a single non-empty path segment is safe to +// use as a file name. +func isSafeSegment(seg string) bool { + if seg == ".." { + return false + } + // Windows strips trailing dots "a.txt." would open "a.txt" + if last := seg[len(seg)-1]; last == '.' { + return false + } + for i := 0; i < len(seg); i++ { + c := seg[i] + if c < 0x20 || c == 0x7F { + return false + } + // invalid in Windows file names, ':' also selects NTFS streams + if strings.IndexByte(`\:*?"<>|`, c) >= 0 { + return false + } + } + return !isReservedWindowsName(seg) +} + +// isReservedWindowsName reports whether seg names a Windows device: +// "CON", "con.txt", "COM1.tar.gz", ... +func isReservedWindowsName(seg string) bool { + base := seg + if i := strings.IndexByte(seg, '.'); i >= 0 { + base = seg[:i] + } + switch len(base) { + case 3: + return asciiEqualUpper(base, "CON") || asciiEqualUpper(base, "PRN") || + asciiEqualUpper(base, "AUX") || asciiEqualUpper(base, "NUL") + case 4: + prefix := base[:3] + return (asciiEqualUpper(prefix, "COM") || asciiEqualUpper(prefix, "LPT")) && + '0' <= base[3] && base[3] <= '9' + } + return false +} + +// asciiUpper upper-cases ASCII letters only (like std::toupper in the "C" locale). +func asciiUpper(c byte) byte { + if 'a' <= c && c <= 'z' { + return c - ('a' - 'A') + } + return c +} + +// asciiEqualUpper compares s with upper (which must be upper-case ASCII), ignoring +// ASCII case. Unlike strings.EqualFold it does no Unicode folding, so e.g. the +// Kelvin sign 'K' (U+212A) does not match 'K'. +func asciiEqualUpper(s, upper string) bool { + if len(s) != len(upper) { + return false + } + for i := 0; i < len(s); i++ { + if asciiUpper(s[i]) != upper[i] { + return false + } + } + return true +} diff --git a/internal/string_helper/unsafe.go b/internal/string_helper/unsafe.go new file mode 100644 index 0000000..79b44e6 --- /dev/null +++ b/internal/string_helper/unsafe.go @@ -0,0 +1,13 @@ +package string_helper + +import "unsafe" + +func UnsafeGetStrBytes(s string) []byte { + pointr := unsafe.StringData(s) + return unsafe.Slice(pointr, len(s)) +} + +func UnsafeBytesToStr(b []byte) string { + pointr := unsafe.SliceData(b) + return unsafe.String(pointr, len(b)) +}