Compare commits
15 Commits
fccc976b4e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
cf2c07899c
|
|||
|
5be98f04c1
|
|||
|
38c65df769
|
|||
|
e12b1ffe3e
|
|||
|
93b1dfef79
|
|||
|
8c32b74838
|
|||
|
437a285cc8
|
|||
|
19fd94a675
|
|||
|
42a613279d
|
|||
|
e1eade237c
|
|||
|
66e2b0ff2c
|
|||
|
088f4a55e5
|
|||
|
336388041e
|
|||
|
df0bf9bed7
|
|||
|
f85afce949
|
19
.gitea/workflows/test.yaml
Normal file
19
.gitea/workflows/test.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: x86_64
|
||||
container: golang:1.26
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 --branch="${GITHUB_REF_NAME}" "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" .
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
./htdocs
|
||||
htdocs
|
||||
47
README.md
Normal file
47
README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# go-http-server
|
||||
|
||||
A minimal TCP-based HTTP server in Go for learning and experimentation. It demonstrates manual request-line and header parsing and simple connection handling.
|
||||
|
||||
Requires
|
||||
- Go 1.26 or newer.
|
||||
|
||||
Quick start
|
||||
1. Build
|
||||
- cd into the project root (the directory that contains `go-http-server`) and run:
|
||||
```
|
||||
cd go-http-server
|
||||
go build -o go-http-server
|
||||
```
|
||||
2. Run
|
||||
- Start the server:
|
||||
```
|
||||
./go-http-server
|
||||
```
|
||||
- Or during development:
|
||||
```
|
||||
go run .
|
||||
```
|
||||
|
||||
Testing
|
||||
- Run unit tests:
|
||||
```
|
||||
cd go-http-server
|
||||
go test ./...
|
||||
```
|
||||
|
||||
What’s in the repo
|
||||
- `main.go` — listener and connection lifecycle.
|
||||
- `readheaders.go` — `readHeaders` and `parseRequestLine` logic; returns a `Headers` struct.
|
||||
- `response_helper` — helpers for writing responses.
|
||||
- `config` — runtime configuration (listen address, profiling toggle).
|
||||
|
||||
Notes
|
||||
- The parser expects a request-line with exactly three tokens: `METHOD URI PROTO`.
|
||||
- Header lines without a colon are ignored; values are trimmed.
|
||||
- The server supports simple keep-alive behavior when the `Connection: keep-alive` header is present.
|
||||
|
||||
Contributing
|
||||
- Open issues or PRs. Add tests for parsing or new behavior and run `go test ./...` before submitting.
|
||||
|
||||
License
|
||||
- MIT
|
||||
49
config/config.go
Normal file
49
config/config.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
NetInterface string `json:"net_interface"`
|
||||
Htdocs string `json:"htdocs"`
|
||||
Profile bool `json:"profile"`
|
||||
}
|
||||
|
||||
var static_config *Config = nil
|
||||
|
||||
func GetConfig() *Config {
|
||||
if static_config != nil {
|
||||
return static_config
|
||||
}
|
||||
|
||||
file, err := os.Open("config.json")
|
||||
if err != nil {
|
||||
fmt.Println("No config file found")
|
||||
return &Config{
|
||||
NetInterface: "127.0.0.1:80",
|
||||
Htdocs: "./htdocs",
|
||||
Profile: false,
|
||||
}
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var config Config
|
||||
err = json.NewDecoder(file).Decode(&config)
|
||||
if err != nil {
|
||||
fmt.Println("Error decoding config file:", err)
|
||||
return &Config{
|
||||
NetInterface: "127.0.0.1:80",
|
||||
Htdocs: "./htdocs",
|
||||
Profile: false,
|
||||
}
|
||||
}
|
||||
static_config = &config
|
||||
return &config
|
||||
}
|
||||
|
||||
func ResetConfig() {
|
||||
static_config = nil
|
||||
}
|
||||
6
go.mod
6
go.mod
@@ -1,3 +1,9 @@
|
||||
module tilok.dev/go-http-server
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/felixge/fgprof v0.9.3 // indirect
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd // indirect
|
||||
github.com/pkg/profile v1.7.0 // indirect
|
||||
)
|
||||
|
||||
21
go.sum
Normal file
21
go.sum
Normal file
@@ -0,0 +1,21 @@
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
|
||||
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
||||
github.com/ianlancetaylor/demangle v0.0.0-20210905161508-09a460cdf81d/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
|
||||
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
|
||||
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
112
http_handle.go
Normal file
112
http_handle.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"tilok.dev/go-http-server/config"
|
||||
rh "tilok.dev/go-http-server/response_helper"
|
||||
)
|
||||
|
||||
func HandleHTTPRequest(headers Headers, conn net.Conn) {
|
||||
if strings.Contains(headers.Uri, "..") {
|
||||
conn.Write([]byte("HTTP/1.1 403 Fuck you\r\n\r\n"))
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
config := config.GetConfig()
|
||||
_, err := os.Stat(config.Htdocs)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
CreateDefaultHtdocs(config.Htdocs)
|
||||
} else {
|
||||
slog.Error("Failed to stat directory", "error", err)
|
||||
panic("Failed to stat directory")
|
||||
}
|
||||
}
|
||||
|
||||
desiredPath := path.Join(config.Htdocs, headers.Uri)
|
||||
if headers.Uri == "/" {
|
||||
desiredPath = path.Join(config.Htdocs, "index.html")
|
||||
}
|
||||
|
||||
file, err := os.Open(desiredPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
rh.RespondWithStatusCode(404, conn)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
rh.RespondWithStatusCode(403, conn)
|
||||
return
|
||||
}
|
||||
|
||||
slog.Error("Unhandled error case", "err", err)
|
||||
rh.RespondWithStatusCode(500, conn)
|
||||
return
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
sendFile(file, conn)
|
||||
}
|
||||
|
||||
func sendFile(file *os.File, conn net.Conn) {
|
||||
reader := bufio.NewReader(file)
|
||||
defer file.Close()
|
||||
|
||||
fileStat, err := file.Stat()
|
||||
if err != nil {
|
||||
slog.Error("Failed to stat file", "error", err)
|
||||
rh.RespondWithStatusCode(500, conn)
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(fileStat.Name(), ".")
|
||||
ext := "." + parts[len(parts)-1]
|
||||
mime_type := rh.ExtensionToMimetype[ext]
|
||||
length := fileStat.Size()
|
||||
|
||||
if mime_type == "" {
|
||||
mime_type = "application/octet-stream"
|
||||
}
|
||||
|
||||
header := fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: %s\r\nContent-Length: %d\r\nServer: Tilo's Go HTTP Server\r\n\r\n", mime_type, length)
|
||||
conn.Write([]byte(header))
|
||||
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := reader.Read(buf)
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
slog.Error("Failed to read file", "error", err)
|
||||
rh.RespondWithStatusCode(500, conn)
|
||||
return
|
||||
}
|
||||
conn.Write(buf[:n])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func CreateDefaultHtdocs(dirPath string) error {
|
||||
err := os.MkdirAll(dirPath, 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = os.WriteFile(path.Join(dirPath, "index.html"), []byte("<DOCTYPE html><html><head><title>Hello World</title></head><body><h1>Hello World</h1></body></html>"), 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
30
main.go
30
main.go
@@ -4,14 +4,21 @@ import (
|
||||
"bufio"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/profile"
|
||||
"tilok.dev/go-http-server/config"
|
||||
rh "tilok.dev/go-http-server/response_helper"
|
||||
)
|
||||
|
||||
func main() {
|
||||
//TODO: Make interface configurable
|
||||
interf := "127.0.0.1:8080"
|
||||
listener, err := net.Listen("tcp", interf)
|
||||
conf := config.GetConfig()
|
||||
|
||||
if conf.Profile {
|
||||
defer profile.Start().Stop()
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", conf.NetInterface)
|
||||
if err != nil {
|
||||
slog.Error("Could not create listener", "err", err.Error())
|
||||
}
|
||||
@@ -30,7 +37,16 @@ func main() {
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn) {
|
||||
startTime := time.Now()
|
||||
defer conn.Close()
|
||||
|
||||
durr, err := time.ParseDuration("5m")
|
||||
if err != nil {
|
||||
slog.Error("Could not parse duration", "err", err.Error())
|
||||
return
|
||||
}
|
||||
plus5min := startTime.Add(durr)
|
||||
conn.SetDeadline(plus5min)
|
||||
reader := bufio.NewReader(conn)
|
||||
headers := readHeaders(reader)
|
||||
if headers == nil {
|
||||
@@ -39,5 +55,11 @@ func handleConnection(conn net.Conn) {
|
||||
}
|
||||
|
||||
slog.Info("Received request", "method", headers.Method, "uri", headers.Uri, "proto", headers.Proto, "client", conn.RemoteAddr().String())
|
||||
headers.debugPrintHeaders()
|
||||
|
||||
conn.SetDeadline(time.Now().Add(durr))
|
||||
HandleHTTPRequest(*headers, conn)
|
||||
|
||||
if headers.KV["Connection"] == "keep-alive" {
|
||||
handleConnection(conn)
|
||||
}
|
||||
}
|
||||
|
||||
104
readheaders_test.go
Normal file
104
readheaders_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseRequestLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantMethod string
|
||||
wantUri string
|
||||
wantProto string
|
||||
}{
|
||||
{"basic GET", "GET / HTTP/1.1\n", "GET", "/", "HTTP/1.1"},
|
||||
{"POST with CRLF", "POST /path/resource HTTP/2.0\r\n", "POST", "/path/resource", "HTTP/2.0"},
|
||||
{"extra spaces", " GET /spaced HTTP/1.0 \n", "GET", "/spaced", "HTTP/1.0"},
|
||||
{"invalid single token", "INVALIDLINE\n", "", "", ""},
|
||||
{"too many parts", "TOO MANY PARTS A B C\n", "", "", ""},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, u, p := parseRequestLine(tc.input)
|
||||
if m != tc.wantMethod || u != tc.wantUri || p != tc.wantProto {
|
||||
t.Fatalf("parseRequestLine(%q) = %q,%q,%q; want %q,%q,%q", tc.input, m, u, p, tc.wantMethod, tc.wantUri, tc.wantProto)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaders_Valid(t *testing.T) {
|
||||
// mix of CRLF and values with extra spaces, plus an invalid header line (no ':') which should be ignored
|
||||
req := "" +
|
||||
"GET /test HTTP/1.1\r\n" +
|
||||
"Host: example.com\r\n" +
|
||||
"X-Empty: value with spaces \r\n" +
|
||||
"InvalidHeaderLineWithoutColon\r\n" +
|
||||
"Connection: keep-alive\r\n" +
|
||||
"\r\n"
|
||||
|
||||
r := bufio.NewReader(strings.NewReader(req))
|
||||
h := readHeaders(r)
|
||||
if h == nil {
|
||||
t.Fatal("expected headers, got nil")
|
||||
}
|
||||
|
||||
if h.Method != "GET" || h.Uri != "/test" || h.Proto != "HTTP/1.1" {
|
||||
t.Fatalf("unexpected request line: got Method=%q Uri=%q Proto=%q", h.Method, h.Uri, h.Proto)
|
||||
}
|
||||
|
||||
if got := h.KV["Host"]; got != "example.com" {
|
||||
t.Fatalf("Host header = %q; want %q", got, "example.com")
|
||||
}
|
||||
|
||||
if got := h.KV["Connection"]; got != "keep-alive" {
|
||||
t.Fatalf("Connection header = %q; want %q", got, "keep-alive")
|
||||
}
|
||||
|
||||
// value should be trimmed
|
||||
if got := h.KV["X-Empty"]; got != "value with spaces" {
|
||||
t.Fatalf("X-Empty header = %q; want trimmed %q", got, "value with spaces")
|
||||
}
|
||||
|
||||
// invalid header line without ':' should be ignored
|
||||
if _, ok := h.KV["InvalidHeaderLineWithoutColon"]; ok {
|
||||
t.Fatalf("headers with no ':' should be ignored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaders_EmptyReader_ReturnsNil(t *testing.T) {
|
||||
r := bufio.NewReader(strings.NewReader(""))
|
||||
h := readHeaders(r)
|
||||
if h != nil {
|
||||
t.Fatalf("expected nil for empty reader, got %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaders_MalformedRequestLine_ReturnsNil(t *testing.T) {
|
||||
r := bufio.NewReader(strings.NewReader("BADLINE\r\nHost: example.com\r\n\r\n"))
|
||||
h := readHeaders(r)
|
||||
if h != nil {
|
||||
t.Fatalf("expected nil for malformed request line, got %v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadHeaders_StopsAtFirstBlankLine(t *testing.T) {
|
||||
req := "" +
|
||||
"GET /abc HTTP/1.1\r\n" +
|
||||
"Header1: one\r\n" +
|
||||
"\r\n" +
|
||||
"Header2: should-not-be-read\r\n" // this should not be read as part of headers
|
||||
|
||||
r := bufio.NewReader(strings.NewReader(req))
|
||||
h := readHeaders(r)
|
||||
if h == nil {
|
||||
t.Fatal("expected headers, got nil")
|
||||
}
|
||||
if _, ok := h.KV["Header2"]; ok {
|
||||
t.Fatalf("Header2 should not be present; headers reading should stop at blank line")
|
||||
}
|
||||
}
|
||||
@@ -12,3 +12,70 @@ func RespondWithStatusCode(statusCode int, conn net.Conn) {
|
||||
conn.Write([]byte("Content-Length: 0\r\n"))
|
||||
conn.Write([]byte("\r\n"))
|
||||
}
|
||||
|
||||
var ExtensionToMimetype = map[string]string{
|
||||
".aac": "audio/aac",
|
||||
".avi": "video/x-msvideo",
|
||||
".avif": "image/avif",
|
||||
".bin": "application/octet-stream",
|
||||
".bmp": "image/bmp",
|
||||
".bz": "application/x-bzip",
|
||||
".bz2": "application/x-bzip2",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".csv": "text/csv; charset=utf-8",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".eot": "application/vnd.ms-fontobject",
|
||||
".epub": "application/epub+zip",
|
||||
".gif": "image/gif",
|
||||
".gz": "application/gzip",
|
||||
".htm": "text/html; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jar": "application/java-archive",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".md": "text/markdown; charset=utf-8",
|
||||
".mjs": "text/javascript; charset=utf-8",
|
||||
".mp3": "audio/mpeg",
|
||||
".mp4": "video/mp4",
|
||||
".mpeg": "video/mpeg",
|
||||
".mpg": "video/mpeg",
|
||||
".odp": "application/vnd.oasis.opendocument.presentation",
|
||||
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
||||
".odt": "application/vnd.oasis.opendocument.text",
|
||||
".oga": "audio/ogg",
|
||||
".ogg": "audio/ogg",
|
||||
".ogv": "video/ogg",
|
||||
".otf": "font/otf",
|
||||
".pdf": "application/pdf",
|
||||
".png": "image/png",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".rar": "application/vnd.rar",
|
||||
".rtf": "application/rtf",
|
||||
".svg": "image/svg+xml",
|
||||
".tar": "application/x-tar",
|
||||
".tif": "image/tiff",
|
||||
".tiff": "image/tiff",
|
||||
".ts": "video/mp2t",
|
||||
".ttf": "font/ttf",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".wav": "audio/wav",
|
||||
".weba": "audio/webm",
|
||||
".webm": "video/webm",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".xhtml": "application/xhtml+xml; charset=utf-8",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".xml": "application/xml; charset=utf-8",
|
||||
".yaml": "application/yaml; charset=utf-8",
|
||||
".yml": "application/yaml; charset=utf-8",
|
||||
".zip": "application/zip",
|
||||
".7z": "application/x-7z-compressed",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user