From ec1275db2454a180b2c5e91c9338ccf4e1a309c6 Mon Sep 17 00:00:00 2001 From: Altair-sh Date: Thu, 29 Oct 2026 18:04:51 +0100 Subject: [PATCH] tcp echo server --- .gitignore | 2 + Config.go | 83 ++++++++++++++++ common/log/ConsoleColor.go | 44 +++++++++ common/log/log.go | 191 +++++++++++++++++++++++++++++++++++++ go.mod | 8 ++ go.sum | 9 ++ main.go | 100 +++++++++++++++++++ 7 files changed, 437 insertions(+) create mode 100644 .gitignore create mode 100644 Config.go create mode 100644 common/log/ConsoleColor.go create mode 100644 common/log/log.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f769868 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +serveg.toml +serveg diff --git a/Config.go b/Config.go new file mode 100644 index 0000000..56bd2e9 --- /dev/null +++ b/Config.go @@ -0,0 +1,83 @@ +package main + +import ( + "encoding" + "errors" + "fmt" + "io/fs" + "net" + "os" + + "github.com/BurntSushi/toml" +) + +type Config struct { + Port uint16 `toml:"port"` + Ip IP `toml:"ip"` +} + +var ConfigPath = "serveg.toml" + +func ReadConfig(path string) (*Config, error) { + var config = new(Config) + tomlMeta, err := toml.DecodeFile(path, config) + if err != nil { + return nil, err + } + + // check if toml contains unknown keys + if undecodedKeys := tomlMeta.Undecoded(); len(undecodedKeys) > 0 { + return nil, fmt.Errorf("%s: unknown keys: %v", path, undecodedKeys) + } + + return config, nil +} + +func DefaultConfig() *Config { + return &Config{ + Port: 8080, + Ip: IP{net.IPv4(127, 0, 0, 1)}, + } +} + +func ParseOrCreateDefault() (*Config, error) { + config, err := ReadConfig(ConfigPath) + if !errors.Is(err, fs.ErrNotExist) { + return config, err // either a parsed config, or a real error + } + config = DefaultConfig() + return config, WriteConfig(config, ConfigPath) +} + +func WriteConfig(config *Config, path string) (err error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + defer func() { + // Close can report a failed write, so its error matters + if closeErr := f.Close(); err == nil { + err = closeErr + } + }() + enc := toml.NewEncoder(f) + 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/common/log/ConsoleColor.go b/common/log/ConsoleColor.go new file mode 100644 index 0000000..9ba67b9 --- /dev/null +++ b/common/log/ConsoleColor.go @@ -0,0 +1,44 @@ +package log + +import ct "github.com/daviddengcn/go-colortext" + +type ConsoleColor struct { + colorBase ct.Color + bright bool +} + +var ( + None = ConsoleColor{} + Black = ConsoleColor{colorBase: ct.Black} + DarkRed = ConsoleColor{colorBase: ct.Red} + DarkGreen = ConsoleColor{colorBase: ct.Green} + DarkYellow = ConsoleColor{colorBase: ct.Yellow} + DarkBlue = ConsoleColor{colorBase: ct.Blue} + DarkMagenta = ConsoleColor{colorBase: ct.Magenta} + DarkCyan = ConsoleColor{colorBase: ct.Cyan} + Gray = ConsoleColor{colorBase: ct.White} + DarkGray = ConsoleColor{colorBase: ct.Black, bright: true} + Red = ConsoleColor{colorBase: ct.Red, bright: true} + Green = ConsoleColor{colorBase: ct.Green, bright: true} + Yellow = ConsoleColor{colorBase: ct.Yellow, bright: true} + Blue = ConsoleColor{colorBase: ct.Blue, bright: true} + Magenta = ConsoleColor{colorBase: ct.Magenta, bright: true} + Cyan = ConsoleColor{colorBase: ct.Cyan, bright: true} + White = ConsoleColor{colorBase: ct.White, bright: true} +) + +func SetColorFg(fg ConsoleColor) { + ct.Foreground(fg.colorBase, fg.bright) +} + +func SetColorBg(bg ConsoleColor) { + ct.Background(bg.colorBase, bg.bright) +} + +func SetColor(fg, bg ConsoleColor) { + ct.ChangeColor(fg.colorBase, fg.bright, bg.colorBase, bg.bright) +} + +func ResetColor() { + ct.ResetColor() +} diff --git a/common/log/log.go b/common/log/log.go new file mode 100644 index 0000000..c6091e5 --- /dev/null +++ b/common/log/log.go @@ -0,0 +1,191 @@ +// Package log is a console logger with colored output that can run +// synchronously or asynchronously. +// +// Usage in main: +// +// func main() { +// os.Exit(run()) +// } +// +// func run() int { +// // flushes the message queue, logs panics, resets console color +// defer log.Defer() +// +// // you can change Logger settings before StartGoroutine() +// // Logger.TimeFormat = ... +// +// // optional: enables async mode, see "Modes" +// log.StartGoroutine() +// +// logger := log.LoggerContext{Name: "main"} +// logger.Info("server started") +// // [2026-10-29 15:41:22][main/INFO] server started +// +// return 0 +// } +// +// Don't call os.Exit in run(): deferred functions don't run on os.Exit, so +// queued messages would be lost. Return an exit code from run() instead. +// +// Modes: +// - Sync (default): Log prints the message immediately in the calling goroutine. +// - Async (after StartGoroutine): Log pushes the message into a buffered +// channel and a worker goroutine prints it. Log blocks only when the +// buffer is full. +package log + +import ( + "fmt" + "os" + "sync" + "time" +) + +type Severity int32 + +const ( + SeverityTrace Severity = iota + SeverityDebug + SeverityInfo + SeverityWarn + SeverityError +) + +var _SeverityNames = [...]string{"TRACE", "DEBUG", "INFO", "WARN", "ERROR"} + +func (s Severity) String() string { + return _SeverityNames[int(s)] +} + +type message struct { + text string + color ConsoleColor +} + +type AsyncLogger struct { + TimeFormat string + ColorMap map[Severity]ConsoleColor + QueueMaxSize int // N messages to enqueue without blocking the goroutine + + messageQueue chan message + waitWorker sync.WaitGroup + mutex sync.RWMutex + running bool +} + +var Logger = NewLogger() + +func NewLogger() *AsyncLogger { + return &AsyncLogger{ + TimeFormat: "2006-01-02 15:04:05", + ColorMap: map[Severity]ConsoleColor{ + SeverityTrace: DarkGray, + SeverityDebug: Magenta, + SeverityInfo: Gray, + SeverityWarn: Yellow, + SeverityError: Red, + }, + QueueMaxSize: 100, + } +} + +func StartGoroutine() { + Logger.mutex.Lock() + Logger.running = true + Logger.mutex.Unlock() + // init it here to allow users change Logger.QueueMaxSize before StartGoroutine() + Logger.messageQueue = make(chan message, Logger.QueueMaxSize) + Logger.waitWorker.Go(worker) +} + +// call this as `defer log.Defer()` in `main()“ +func Defer() { + defer ResetColor() + if r := recover(); r != nil { + Log("main", SeverityError, "Application panicked: ", r) + closeAndPrintRemaining() + // re-panic after flushing logs so stack trace prints normally + panic(r) + } else { + // application shutting down normally + closeAndPrintRemaining() + } +} + +type LoggerContext struct { + Name string +} + +func (ctx *LoggerContext) Trace(a ...any) { + Log(ctx.Name, SeverityTrace, a...) +} +func (ctx *LoggerContext) Debug(a ...any) { + Log(ctx.Name, SeverityDebug, a...) +} +func (ctx *LoggerContext) Info(a ...any) { + Log(ctx.Name, SeverityInfo, a...) +} +func (ctx *LoggerContext) Warn(a ...any) { + Log(ctx.Name, SeverityWarn, a...) +} +func (ctx *LoggerContext) Error(a ...any) { + Log(ctx.Name, SeverityError, a...) +} + +func Log(context string, severity Severity, a ...any) { + msg := buildMessage(context, severity, a...) + + Logger.mutex.RLock() // many goroutines can hold RLock at once + defer Logger.mutex.RUnlock() + if !Logger.running { + // worker is stopped or hasn't start yet + // print synchronously + msg.print() + return + } + + Logger.messageQueue <- msg +} + +func buildMessage(context string, severity Severity, a ...any) message { + timeStr := time.Now().Format(Logger.TimeFormat) + severityStr := severity.String() + var contextAndSeverity string + if context == "" { + contextAndSeverity = severityStr + } else { + contextAndSeverity = context + "/" + severityStr + } + + prefix := "[" + timeStr + "][" + contextAndSeverity + "] " + text := prefix + fmt.Sprint(a...) + "\n" + color := Logger.ColorMap[severity] + + return message{text, color} +} + +func (msg message) print() { + SetColorFg(msg.color) + if _, err := os.Stdout.Write([]byte(msg.text)); err != nil { + panic(err) + } +} + +func worker() { + for msg := range Logger.messageQueue { + msg.print() + } +} + +func closeAndPrintRemaining() { + // waits until no Log() is mid-send + Logger.mutex.Lock() + if !Logger.running { + Logger.mutex.Unlock() + return + } + Logger.running = false + close(Logger.messageQueue) + Logger.mutex.Unlock() + Logger.waitWorker.Wait() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..612965a --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module serveg + +go 1.27.3 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/daviddengcn/go-colortext v1.0.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..221a8d6 --- /dev/null +++ b/go.sum @@ -0,0 +1,9 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/daviddengcn/go-colortext v1.0.0 h1:ANqDyC0ys6qCSvuEK7l3g5RaehL/Xck9EX8ATG8oKsE= +github.com/daviddengcn/go-colortext v1.0.0/go.mod h1:zDqEI5NVUop5QPpVJUxE9UO10hRnmkD5G4Pmri9+m4c= +github.com/golangplus/bytes v0.0.0-20160111154220-45c989fe5450/go.mod h1:Bk6SMAONeMXrxql8uvOKuAZSu8aM5RUGv+1C6IJaEho= +github.com/golangplus/bytes v1.0.0/go.mod h1:AdRaCFwmc/00ZzELMWb01soso6W1R/++O1XL80yAn+A= +github.com/golangplus/fmt v1.0.0/go.mod h1:zpM0OfbMCjPtd2qkTD/jX2MgiFCqklhSUFyDW44gVQE= +github.com/golangplus/testing v1.0.0 h1:+ZeeiKZENNOMkTTELoSySazi+XaEhVO0mb+eanrSEUQ= +github.com/golangplus/testing v1.0.0/go.mod h1:ZDreixUV3YzhoVraIDyOzHrr76p6NUh6k/pPg/Q3gYA= diff --git a/main.go b/main.go new file mode 100644 index 0000000..46daec2 --- /dev/null +++ b/main.go @@ -0,0 +1,100 @@ +package main + +import ( + "context" + "net" + "os" + "os/signal" + "serveg/common/log" + "strconv" + "syscall" + "time" +) + +func main() { + os.Exit(run()) +} + +// main with int return value +func run() int { + defer log.Defer() + log.StartGoroutine() + logger := log.LoggerContext{Name: "main"} + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + //defer stop() + + conf, err := ParseOrCreateDefault() + if err != nil { + logger.Error(err) + return 1 + } + + errCh := make(chan error, 1) + go func() { errCh <- server(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") + } + + return 0 +} + +func server(conf *Config) error { + logger := log.LoggerContext{Name: "server"} + addr := net.JoinHostPort(conf.Ip.String(), strconv.Itoa(int(conf.Port))) + + logger.Info("listening at", addr) + srv, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer func() { _ = srv.Close() }() + + for i := int64(0); ; i++ { + conn, err := srv.Accept() + if err != nil { + logger.Warn(err) + continue + } + go handleConnection(conn, i) + } +} + +func handleConnection(conn net.Conn, n int64) { + logger := log.LoggerContext{Name: "conn-" + strconv.FormatInt(n, 10)} + defer func() { + if r := recover(); r != nil { + logger.Error("panic: ", r) + } + _ = conn.Close() + }() + + buf := make([]byte, 4096) + + for { + if err := conn.SetDeadline(time.Now().Add(30 * time.Second)); err != nil { + logger.Warn(err) + break + } + n, err := conn.Read(buf) + if err != nil { + logger.Warn(err) + break + } + msgBytes := buf[:n] + logger.Info("msg: `", string(msgBytes), "`") + if _, err := conn.Write(msgBytes); err != nil { + logger.Warn(err) + break + } + } +}