Compare commits

..

7 Commits

Author SHA1 Message Date
2f231d4b12 Update Dockerfile 2025-05-26 16:51:01 +03:00
68dfd3cadd Update README.md 2025-05-26 16:50:26 +03:00
de320db166 Add go workspace. 2025-05-26 16:46:38 +03:00
7ea36d23dd Improve build system and Docker configuration
- Switch Dockerfile base image from golang:1.23-bookworm to debian:12-slim
- Update Dockerfile to use pre-built binary instead of building in container
- Fix Docker CMD to use new CLI flag format with --listen and --root-path
- Update Makefile to build binary to ./dist/ directory with CGO_ENABLED=0
- Make build-docker target depend on build target for efficiency
- Change clean target to remove ./dist directory instead of single binary
2025-05-26 13:29:33 +03:00
28008a320d Refactor error handling and logging system
- Replace custom errors package with xerrors for structured error handling
- Remove local logging wrapper and use git.antanst.com/antanst/logging
- Add proper error codes and user messages in server responses
- Improve connection handling with better error categorization
- Update certificate path to use local certs/ directory
- Add request size validation (1024 byte limit)
- Remove panic-on-error configuration option
- Enhance error logging with connection IDs and remote addresses
2025-05-26 13:28:16 +03:00
c78d7898f9 Update Go module dependencies and version
- Upgrade Go version from 1.23.4 to 1.24.3
- Replace zerolog dependency with local xerrors and logging modules
- Add local module replacements for git.antanst.com/antanst/xerrors and logging
- Remove unused color and system dependencies
- Keep gabriel-vasile/mimetype and matoous/go-nanoid dependencies
2025-05-26 13:28:01 +03:00
4456308d48 Replace environment variable config with CLI flag configuration
- Migrate from environment variables to CLI flags for configuration
- Add support for --listen, --root-path, --dir-indexing, --log-level, --response-timeout flags
- Remove config validation error struct as it's no longer needed
- Update .gitignore to exclude /dist directory
- Simplify configuration loading with flag.Parse()
2025-05-26 13:27:44 +03:00
14 changed files with 174 additions and 440 deletions

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@
**/*~ **/*~
/.idea /.idea
/run.sh /run.sh
/dist
/go.work*

View File

@@ -1,15 +1,16 @@
FROM golang:1.23-bookworm FROM debian:12-slim
RUN apt-get update && apt-get upgrade -y RUN apt-get update && apt-get upgrade -y
RUN useradd -u 1000 -m user RUN useradd -u 1000 -m user
COPY ./gemserve /app/gemserve COPY ./dist/gemserve /app/gemserve
WORKDIR /app WORKDIR /app
RUN chmod +x /app/gemserve && \ RUN chmod +x /app/gemserve && \
chown -R user:user /app chown -R root:root /app && \
chmod -R 755 /app
USER user USER user
CMD ["/app/gemserve","0.0.0.0:1965"] CMD ["/app/gemserve","--listen","0.0.0.0:1965","--root-path","/srv"]

View File

@@ -4,7 +4,7 @@ export PATH := $(PATH)
all: fmt lintfix tidy test clean build all: fmt lintfix tidy test clean build
clean: clean:
rm -f ./gemserve rm -rf ./dist
debug: debug:
@echo "PATH: $(PATH)" @echo "PATH: $(PATH)"
@@ -19,7 +19,8 @@ test:
go test ./... go test ./...
tidy: tidy:
go mod tidy go work sync
#go mod tidy
# Format code # Format code
fmt: fmt:
@@ -35,9 +36,10 @@ lintfix: fmt
golangci-lint run --fix golangci-lint run --fix
build: build:
go build -o ./gemserve ./main.go mkdir -p ./dist
CGO_ENABLED=0 go build -o ./dist/gemserve ./main.go
build-docker: build-docker: build
docker build -t gemserve . docker build -t gemserve .
show-updates: show-updates:

View File

@@ -18,17 +18,12 @@ make #run tests and build
Run: Run:
```shell ```shell
LOG_LEVEL=info \ ./dist/gemserve
PANIC_ON_UNEXPECTED_ERROR=true \
RESPONSE_TIMEOUT=10 \ #seconds
ROOT_PATH=./srv \
DIR_INDEXING_ENABLED=false \
./gemserve 0.0.0.0:1965
``` ```
You'll need TLS keys, you can use `certs/generate.sh` You'll need TLS keys, you can use `certs/generate.sh`
for quick generation. for quick generation.
## TODO ## TODO
- [ ] Make TLS keys path configurable via venv - [ ] Make TLS keys path configurable
- [ ] Fix slowloris (proper response timeouts) - [ ] Fix slowloris (proper response timeouts)

View File

@@ -8,7 +8,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"gemserve/errors" "git.antanst.com/antanst/xerrors"
) )
type URL struct { type URL struct {
@@ -28,7 +28,7 @@ func (u *URL) Scan(value interface{}) error {
} }
b, ok := value.(string) b, ok := value.(string)
if !ok { if !ok {
return errors.NewFatalError(fmt.Errorf("database scan error: expected string, got %T", value)) return xerrors.NewError(fmt.Errorf("database scan error: expected string, got %T", value), 0, "Database scan error", true)
} }
parsedURL, err := ParseURL(b, "", false) parsedURL, err := ParseURL(b, "", false)
if err != nil { if err != nil {
@@ -67,12 +67,10 @@ func ParseURL(input string, descr string, normalize bool) (*URL, error) {
} else { } else {
u, err = url.Parse(input) u, err = url.Parse(input)
if err != nil { if err != nil {
return nil, errors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input)) return nil, xerrors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input), 0, "URL parse error", false)
} }
} }
if u.Scheme != "gemini" {
return nil, errors.NewError(fmt.Errorf("error parsing URL: not a gemini URL: %s", input))
}
protocol := u.Scheme protocol := u.Scheme
hostname := u.Hostname() hostname := u.Hostname()
strPort := u.Port() strPort := u.Port()
@@ -82,7 +80,7 @@ func ParseURL(input string, descr string, normalize bool) (*URL, error) {
} }
port, err := strconv.Atoi(strPort) port, err := strconv.Atoi(strPort)
if err != nil { if err != nil {
return nil, errors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input)) return nil, xerrors.NewError(fmt.Errorf("error parsing URL: %w: %s", err, input), 0, "URL parse error", false)
} }
full := fmt.Sprintf("%s://%s:%d%s", protocol, hostname, port, urlPath) full := fmt.Sprintf("%s://%s:%d%s", protocol, hostname, port, urlPath)
// full field should also contain query params and url fragments // full field should also contain query params and url fragments
@@ -128,13 +126,13 @@ func NormalizeURL(rawURL string) (*url.URL, error) {
// Parse the URL // Parse the URL
u, err := url.Parse(rawURL) u, err := url.Parse(rawURL)
if err != nil { if err != nil {
return nil, errors.NewError(fmt.Errorf("error normalizing URL: %w: %s", err, rawURL)) return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: %w: %s", err, rawURL), 0, "URL normalization error", false)
} }
if u.Scheme == "" { if u.Scheme == "" {
return nil, errors.NewError(fmt.Errorf("error normalizing URL: No scheme: %s", rawURL)) return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: No scheme: %s", rawURL), 0, "Missing URL scheme", false)
} }
if u.Host == "" { if u.Host == "" {
return nil, errors.NewError(fmt.Errorf("error normalizing URL: No host: %s", rawURL)) return nil, xerrors.NewError(fmt.Errorf("error normalizing URL: No host: %s", rawURL), 0, "Missing URL host", false)
} }
// Convert scheme to lowercase // Convert scheme to lowercase

View File

@@ -1,126 +1,69 @@
package config package config
import ( import (
"flag"
"fmt" "fmt"
"log/slog"
"os" "os"
"strconv" "strings"
"github.com/rs/zerolog"
) )
// Environment variable names. // Config holds the application configuration loaded from CLI flags.
const (
EnvLogLevel = "LOG_LEVEL"
EnvResponseTimeout = "RESPONSE_TIMEOUT"
EnvPanicOnUnexpectedError = "PANIC_ON_UNEXPECTED_ERROR"
EnvRootPath = "ROOT_PATH"
EnvDirIndexingEnabled = "DIR_INDEXING_ENABLED"
)
// Config holds the application configuration loaded from environment variables.
type Config struct { type Config struct {
LogLevel zerolog.Level // Logging level (debug, info, warn, error) LogLevel slog.Level // Logging level (debug, info, warn, error)
ResponseTimeout int // Timeout for responses in seconds ResponseTimeout int // Timeout for responses in seconds
PanicOnUnexpectedError bool // Panic on unexpected errors when visiting a URL RootPath string // Path to serve files from
RootPath string // Path to serve files from DirIndexingEnabled bool // Allow client to browse directories or not
DirIndexingEnabled bool // Allow client to browse directories or not Listen string // Address to listen on
} }
var CONFIG Config //nolint:gochecknoglobals var CONFIG Config //nolint:gochecknoglobals
// parsePositiveInt parses and validates positive integer values. // parseLogLevel parses a log level string into slog.Level
func parsePositiveInt(param, value string) (int, error) { func parseLogLevel(level string) (slog.Level, error) {
val, err := strconv.Atoi(value) switch strings.ToLower(level) {
if err != nil { case "debug":
return 0, ValidationError{ return slog.LevelDebug, nil
Param: param, case "info":
Value: value, return slog.LevelInfo, nil
Reason: "must be a valid integer", case "warn", "warning":
} return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
default:
return slog.LevelInfo, fmt.Errorf("invalid log level: %s", level)
} }
if val <= 0 {
return 0, ValidationError{
Param: param,
Value: value,
Reason: "must be positive",
}
}
return val, nil
} }
func parseBool(param, value string) (bool, error) { // GetConfig loads and validates configuration from CLI flags
val, err := strconv.ParseBool(value)
if err != nil {
return false, ValidationError{
Param: param,
Value: value,
Reason: "cannot be converted to boolean",
}
}
return val, nil
}
// GetConfig loads and validates configuration from environment variables
func GetConfig() *Config { func GetConfig() *Config {
config := &Config{} // Define CLI flags with defaults
logLevel := flag.String("log-level", "info", "Logging level (debug, info, warn, error)")
responseTimeout := flag.Int("response-timeout", 30, "Timeout for responses in seconds")
rootPath := flag.String("root-path", "", "Path to serve files from")
dirIndexing := flag.Bool("dir-indexing", false, "Allow client to browse directories")
listen := flag.String("listen", "localhost:1965", "Address to listen on")
// Map of environment variables to their parsing functions flag.Parse()
parsers := map[string]func(string) error{
EnvLogLevel: func(v string) error { // Parse and validate log level
level, err := zerolog.ParseLevel(v) level, err := parseLogLevel(*logLevel)
if err != nil { if err != nil {
return ValidationError{ _, _ = fmt.Fprintf(os.Stderr, "Invalid log level '%s': must be one of: debug, info, warn, error\n", *logLevel)
Param: EnvLogLevel, os.Exit(1)
Value: v,
Reason: "must be one of: debug, info, warn, error",
}
}
config.LogLevel = level
return nil
},
EnvResponseTimeout: func(v string) error {
val, err := parsePositiveInt(EnvResponseTimeout, v)
if err != nil {
return err
}
config.ResponseTimeout = val
return nil
},
EnvPanicOnUnexpectedError: func(v string) error {
val, err := parseBool(EnvPanicOnUnexpectedError, v)
if err != nil {
return err
}
config.PanicOnUnexpectedError = val
return nil
},
EnvRootPath: func(v string) error {
config.RootPath = v
return nil
},
EnvDirIndexingEnabled: func(v string) error {
val, err := parseBool(EnvDirIndexingEnabled, v)
if err != nil {
return err
}
config.DirIndexingEnabled = val
return nil
},
} }
// Process each environment variable // Validate response timeout
for envVar, parser := range parsers { if *responseTimeout <= 0 {
value, ok := os.LookupEnv(envVar) _, _ = fmt.Fprintf(os.Stderr, "Invalid response timeout '%d': must be positive\n", *responseTimeout)
if !ok { os.Exit(1)
_, _ = fmt.Fprintf(os.Stderr, "Missing required environment variable: %s\n", envVar)
os.Exit(1)
}
if err := parser(value); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Configuration error: %v\n", err)
os.Exit(1)
}
} }
return config return &Config{
LogLevel: level,
ResponseTimeout: *responseTimeout,
RootPath: *rootPath,
DirIndexingEnabled: *dirIndexing,
Listen: *listen,
}
} }

View File

@@ -1,14 +0,0 @@
package config
import "fmt"
// ValidationError represents a config validation error
type ValidationError struct {
Param string
Value string
Reason string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("invalid value '%s' for %s: %s", e.Value, e.Param, e.Reason)
}

View File

@@ -1,114 +0,0 @@
package errors
import (
"errors"
"fmt"
"runtime"
"strings"
)
type fatal interface {
Fatal() bool
}
func IsFatal(err error) bool {
te, ok := errors.Unwrap(err).(fatal)
return ok && te.Fatal()
}
func As(err error, target any) bool {
return errors.As(err, target)
}
func Is(err, target error) bool {
return errors.Is(err, target)
}
func Unwrap(err error) error {
return errors.Unwrap(err)
}
type Error struct {
Err error
Stack string
fatal bool
}
func (e *Error) Error() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%v\n", e.Err))
return sb.String()
}
func (e *Error) ErrorWithStack() string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("%v\n", e.Err))
sb.WriteString(fmt.Sprintf("Stack Trace:\n%s", e.Stack))
return sb.String()
}
func (e *Error) Fatal() bool {
return e.fatal
}
func (e *Error) Unwrap() error {
return e.Err
}
func NewError(err error) error {
if err == nil {
return nil
}
// Check if it's already of our own
// Error type, so we don't add stack twice.
var asError *Error
if errors.As(err, &asError) {
return err
}
// Get the stack trace
var stack strings.Builder
buf := make([]uintptr, 50)
n := runtime.Callers(2, buf)
frames := runtime.CallersFrames(buf[:n])
// Format the stack trace
for {
frame, more := frames.Next()
// Skip runtime and standard library frames
if !strings.Contains(frame.File, "runtime/") {
stack.WriteString(fmt.Sprintf("\t%s:%d - %s\n", frame.File, frame.Line, frame.Function))
}
if !more {
break
}
}
return &Error{
Err: err,
Stack: stack.String(),
}
}
func NewFatalError(err error) error {
if err == nil {
return nil
}
// Check if it's already of our own
// Error type.
var asError *Error
if errors.As(err, &asError) {
return err
}
err2 := NewError(err)
err2.(*Error).fatal = true
return err2
}
var ConnectionError error = fmt.Errorf("connection error")
func NewConnectionError(err error) error {
return fmt.Errorf("%w: %w", ConnectionError, err)
}

View File

@@ -1,71 +0,0 @@
package errors
import (
"errors"
"fmt"
"testing"
)
type CustomError struct {
Err error
}
func (e *CustomError) Error() string { return e.Err.Error() }
func IsCustomError(err error) bool {
var asError *CustomError
return errors.As(err, &asError)
}
func TestWrapping(t *testing.T) {
t.Parallel()
originalErr := errors.New("original error")
err1 := NewError(originalErr)
if !errors.Is(err1, originalErr) {
t.Errorf("original error is not wrapped")
}
if !Is(err1, originalErr) {
t.Errorf("original error is not wrapped")
}
unwrappedErr := errors.Unwrap(err1)
if !errors.Is(unwrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
if !Is(unwrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
unwrappedErr = Unwrap(err1)
if !errors.Is(unwrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
if !Is(unwrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
wrappedErr := fmt.Errorf("wrapped: %w", originalErr)
if !errors.Is(wrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
if !Is(wrappedErr, originalErr) {
t.Errorf("original error is not wrapped")
}
}
func TestNewError(t *testing.T) {
t.Parallel()
originalErr := &CustomError{errors.New("err1")}
if !IsCustomError(originalErr) {
t.Errorf("TestNewError fail #1")
}
err1 := NewError(originalErr)
if !IsCustomError(err1) {
t.Errorf("TestNewError fail #2")
}
wrappedErr1 := fmt.Errorf("wrapped %w", err1)
if !IsCustomError(wrappedErr1) {
t.Errorf("TestNewError fail #3")
}
unwrappedErr1 := Unwrap(wrappedErr1)
if !IsCustomError(unwrappedErr1) {
t.Errorf("TestNewError fail #4")
}
}

12
go.mod
View File

@@ -1,16 +1,12 @@
module gemserve module gemserve
go 1.23.4 go 1.24.3
require ( require (
git.antanst.com/antanst/logging v0.0.1
git.antanst.com/antanst/xerrors v0.0.1
github.com/gabriel-vasile/mimetype v1.4.8 github.com/gabriel-vasile/mimetype v1.4.8
github.com/matoous/go-nanoid/v2 v2.1.0 github.com/matoous/go-nanoid/v2 v2.1.0
github.com/rs/zerolog v1.33.0
) )
require ( require golang.org/x/net v0.33.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.29.0 // indirect
)

16
go.sum
View File

@@ -1,30 +1,14 @@
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE= github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM= github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,23 +0,0 @@
package logging
import (
"fmt"
zlog "github.com/rs/zerolog/log"
)
func LogDebug(format string, args ...interface{}) {
zlog.Debug().Msg(fmt.Sprintf(format, args...))
}
func LogInfo(format string, args ...interface{}) {
zlog.Info().Msg(fmt.Sprintf(format, args...))
}
func LogWarn(format string, args ...interface{}) {
zlog.Warn().Msg(fmt.Sprintf(format, args...))
}
func LogError(format string, args ...interface{}) {
zlog.Error().Err(fmt.Errorf(format, args...)).Msg("")
}

115
main.go
View File

@@ -3,6 +3,7 @@ package main
import ( import (
"bytes" "bytes"
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
@@ -13,46 +14,40 @@ import (
"time" "time"
"gemserve/config" "gemserve/config"
"gemserve/errors"
"gemserve/logging"
"gemserve/server" "gemserve/server"
"gemserve/uid" "gemserve/uid"
"github.com/rs/zerolog" logging "git.antanst.com/antanst/logging"
zlog "github.com/rs/zerolog/log" "git.antanst.com/antanst/xerrors"
) )
var fatalErrors chan error
func main() { func main() {
config.CONFIG = *config.GetConfig() config.CONFIG = *config.GetConfig()
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
zerolog.SetGlobalLevel(config.CONFIG.LogLevel) logging.InitSlogger(config.CONFIG.LogLevel)
zlog.Logger = zlog.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: "[2006-01-02 15:04:05]"})
err := runApp() err := runApp()
if err != nil { if err != nil {
fmt.Printf("%v\n", err) panic(fmt.Sprintf("Fatal Error: %v", err))
logging.LogError("%v", err)
os.Exit(1)
} }
os.Exit(0)
} }
func runApp() error { func runApp() error {
logging.LogInfo("Starting up. Press Ctrl+C to exit") logging.LogInfo("Starting up. Press Ctrl+C to exit")
var listenHost string listenHost := config.CONFIG.Listen
if len(os.Args) != 2 {
listenHost = "0.0.0.0:1965"
} else {
listenHost = os.Args[1]
}
signals := make(chan os.Signal, 1) signals := make(chan os.Signal, 1)
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
serverErrors := make(chan error) fatalErrors = make(chan error)
go func() { go func() {
err := startServer(listenHost) err := startServer(listenHost)
if err != nil { if err != nil {
serverErrors <- errors.NewFatalError(err) fatalErrors <- xerrors.NewError(err, 0, "Server startup failed", true)
} }
}() }()
@@ -61,16 +56,16 @@ func runApp() error {
case <-signals: case <-signals:
logging.LogWarn("Received SIGINT or SIGTERM signal, exiting") logging.LogWarn("Received SIGINT or SIGTERM signal, exiting")
return nil return nil
case serverError := <-serverErrors: case fatalError := <-fatalErrors:
return errors.NewFatalError(serverError) return xerrors.NewError(fatalError, 0, "Server error", true)
} }
} }
} }
func startServer(listenHost string) (err error) { func startServer(listenHost string) (err error) {
cert, err := tls.LoadX509KeyPair("/certs/cert", "/certs/key") cert, err := tls.LoadX509KeyPair("certs/server.crt", "certs/server.key")
if err != nil { if err != nil {
return errors.NewFatalError(fmt.Errorf("failed to load certificate: %w", err)) return xerrors.NewError(fmt.Errorf("failed to load certificate: %w", err), 0, "Certificate loading failed", true)
} }
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
@@ -80,7 +75,7 @@ func startServer(listenHost string) (err error) {
listener, err := tls.Listen("tcp", listenHost, tlsConfig) listener, err := tls.Listen("tcp", listenHost, tlsConfig)
if err != nil { if err != nil {
return errors.NewFatalError(fmt.Errorf("failed to create listener: %w", err)) return xerrors.NewError(fmt.Errorf("failed to create listener: %w", err), 0, "Listener creation failed", true)
} }
defer func(listener net.Listener) { defer func(listener net.Listener) {
// If we've got an error closing the // If we've got an error closing the
@@ -88,7 +83,7 @@ func startServer(listenHost string) (err error) {
// the original error (if not nil) // the original error (if not nil)
errClose := listener.Close() errClose := listener.Close()
if errClose != nil && err == nil { if errClose != nil && err == nil {
err = errors.NewFatalError(err) err = xerrors.NewError(err, 0, "Listener close failed", true)
} }
}(listener) }(listener)
@@ -102,16 +97,16 @@ func startServer(listenHost string) (err error) {
} }
go func() { go func() {
err := handleConnection(conn.(*tls.Conn)) remoteAddr := conn.RemoteAddr().String()
connId := uid.UID()
err := handleConnection(conn.(*tls.Conn), connId, remoteAddr)
if err != nil { if err != nil {
var asErr *errors.Error var asErr *xerrors.XError
if errors.As(err, &asErr) { if errors.As(err, &asErr) && asErr.IsFatal {
logging.LogError("Unexpected error: %v", err.(*errors.Error).ErrorWithStack()) fatalErrors <- asErr
return
} else { } else {
logging.LogError("Unexpected error: %v", err) logging.LogWarn("%s %s Connection failed: %d %s (%v)", connId, remoteAddr, asErr.Code, asErr.UserMsg, err)
}
if config.CONFIG.PanicOnUnexpectedError {
panic("Encountered unexpected error")
} }
} }
}() }()
@@ -121,56 +116,68 @@ func startServer(listenHost string) (err error) {
func closeConnection(conn *tls.Conn) error { func closeConnection(conn *tls.Conn) error {
err := conn.CloseWrite() err := conn.CloseWrite()
if err != nil { if err != nil {
return errors.NewConnectionError(fmt.Errorf("failed to close TLS connection: %w", err)) return xerrors.NewError(fmt.Errorf("failed to close TLS connection: %w", err), 50, "Connection close failed", false)
} }
err = conn.Close() err = conn.Close()
if err != nil { if err != nil {
return errors.NewConnectionError(fmt.Errorf("failed to close connection: %w", err)) return xerrors.NewError(fmt.Errorf("failed to close connection: %w", err), 50, "Connection close failed", false)
} }
return nil return nil
} }
func handleConnection(conn *tls.Conn) (err error) { func handleConnection(conn *tls.Conn, connId string, remoteAddr string) (err error) {
remoteAddr := conn.RemoteAddr().String()
connId := uid.UID()
start := time.Now() start := time.Now()
var outputBytes []byte var outputBytes []byte
defer func(conn *tls.Conn) { defer func(conn *tls.Conn) {
// Three possible cases here:
// - We don't have an error
// - We have a ConnectionError, which we don't propagate up
// - We have an unexpected error.
end := time.Now() end := time.Now()
tookMs := end.Sub(start).Milliseconds() tookMs := end.Sub(start).Milliseconds()
var responseHeader string var responseHeader string
if err != nil {
_, _ = conn.Write([]byte("50 server error")) // On non-errors, just log response and close connection.
responseHeader = "50 server error" if err == nil {
// We don't propagate connection errors up. // Log non-erroneous responses
if errors.Is(err, errors.ConnectionError) {
logging.LogInfo("%s %s %v", connId, remoteAddr, err)
err = nil
}
} else {
if i := bytes.Index(outputBytes, []byte{'\r'}); i >= 0 { if i := bytes.Index(outputBytes, []byte{'\r'}); i >= 0 {
responseHeader = string(outputBytes[:i]) responseHeader = string(outputBytes[:i])
} }
logging.LogInfo("%s %s response %s (%dms)", connId, remoteAddr, responseHeader, tookMs)
_ = closeConnection(conn)
return
} }
logging.LogInfo("%s %s response %s (%dms)", connId, remoteAddr, responseHeader, tookMs)
var code int
var responseMsg string
var xErr *xerrors.XError
if errors.As(err, &xErr) {
// On fatal errors, immediatelly return the error.
if xErr.IsFatal {
_ = closeConnection(conn)
return
}
code = xErr.Code
responseMsg = xErr.UserMsg
} else {
code = 50
responseMsg = "server error"
}
responseHeader = fmt.Sprintf("%d %s", code, responseMsg)
_, _ = conn.Write([]byte(responseHeader))
_ = closeConnection(conn) _ = closeConnection(conn)
}(conn) }(conn)
// Gemini is supposed to have a 1kb limit // Gemini is supposed to have a 1kb limit
// on input requests. // on input requests.
buffer := make([]byte, 1024) buffer := make([]byte, 1025)
n, err := conn.Read(buffer) n, err := conn.Read(buffer)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return errors.NewConnectionError(fmt.Errorf("failed to read connection data: %w", err)) return xerrors.NewError(fmt.Errorf("failed to read connection data: %w", err), 59, "Connection read failed", false)
} }
if n == 0 { if n == 0 {
return errors.NewConnectionError(fmt.Errorf("client did not send data")) return xerrors.NewError(fmt.Errorf("client did not send data"), 59, "No data received", false)
}
if n > 1024 {
return xerrors.NewError(fmt.Errorf("client request size %d > 1024 bytes", n), 59, "Request too large", false)
} }
dataBytes := buffer[:n] dataBytes := buffer[:n]

View File

@@ -2,16 +2,19 @@ package server
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"gemserve/common" "gemserve/common"
"gemserve/config" "gemserve/config"
"gemserve/errors" logging "git.antanst.com/antanst/logging"
"gemserve/logging" "git.antanst.com/antanst/xerrors"
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
) )
@@ -20,18 +23,43 @@ type ServerConfig interface {
RootPath() string RootPath() string
} }
func checkRequestURL(url *common.URL) error {
if url.Protocol != "gemini" {
return xerrors.NewError(fmt.Errorf("invalid URL"), 53, "URL Protocol not Gemini, proxying refused", false)
}
_, portStr, err := net.SplitHostPort(config.CONFIG.Listen)
if err != nil {
return xerrors.NewError(fmt.Errorf("failed to parse listen address: %w", err), 50, "Server configuration error", false)
}
listenPort, err := strconv.Atoi(portStr)
if err != nil {
return xerrors.NewError(fmt.Errorf("failed to parse listen port: %w", err), 50, "Server configuration error", false)
}
if url.Port != listenPort {
return xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), 53, "invalid URL port, proxying refused", false)
}
return nil
}
func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) { func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, error) {
trimmedInput := strings.TrimSpace(input) trimmedInput := strings.TrimSpace(input)
// url will have a cleaned and normalized path after this // url will have a cleaned and normalized path after this
url, err := common.ParseURL(trimmedInput, "", true) url, err := common.ParseURL(trimmedInput, "", true)
if err != nil { if err != nil {
return nil, errors.NewConnectionError(fmt.Errorf("failed to parse URL: %w", err)) return nil, xerrors.NewError(fmt.Errorf("failed to parse URL: %w", err), 59, "Invalid URL", false)
} }
logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path) logging.LogDebug("%s %s normalized URL path: %s", connId, conn.RemoteAddr(), url.Path)
err = checkRequestURL(url)
if err != nil {
return nil, err
}
serverRootPath := config.CONFIG.RootPath serverRootPath := config.CONFIG.RootPath
localPath, err := calculateLocalPath(url.Path, serverRootPath) localPath, err := calculateLocalPath(url.Path, serverRootPath)
if err != nil { if err != nil {
return nil, errors.NewConnectionError(err) return nil, xerrors.NewError(err, 59, "Invalid path", false)
} }
logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath) logging.LogDebug("%s %s request file path: %s", connId, conn.RemoteAddr(), localPath)
@@ -40,7 +68,7 @@ func GenerateResponse(conn *tls.Conn, connId string, input string) ([]byte, erro
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return []byte("51 not found\r\n"), nil return []byte("51 not found\r\n"), nil
} else if err != nil { } else if err != nil {
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err)) return nil, xerrors.NewError(fmt.Errorf("%s %s failed to access path: %w", connId, conn.RemoteAddr(), err), 0, "Path access failed", false)
} }
// Handle directory. // Handle directory.
@@ -55,7 +83,7 @@ func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localP
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) { if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return []byte("51 not found\r\n"), nil return []byte("51 not found\r\n"), nil
} else if err != nil { } else if err != nil {
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err)) return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read file: %w", connId, conn.RemoteAddr(), err), 0, "File read failed", false)
} }
var mimeType string var mimeType string
@@ -64,7 +92,7 @@ func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localP
} else { } else {
mimeType = mimetype.Detect(data).String() mimeType = mimetype.Detect(data).String()
} }
headerBytes := []byte(fmt.Sprintf("20 %s\r\n", mimeType)) headerBytes := []byte(fmt.Sprintf("20 %s; lang=en\r\n", mimeType))
response := append(headerBytes, data...) response := append(headerBytes, data...)
return response, nil return response, nil
} }
@@ -72,7 +100,7 @@ func generateResponseFile(conn *tls.Conn, connId string, url *common.URL, localP
func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPath string) (output []byte, err error) { func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPath string) (output []byte, err error) {
entries, err := os.ReadDir(localPath) entries, err := os.ReadDir(localPath)
if err != nil { if err != nil {
return nil, errors.NewConnectionError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err)) return nil, xerrors.NewError(fmt.Errorf("%s %s failed to read directory: %w", connId, conn.RemoteAddr(), err), 0, "Directory read failed", false)
} }
if config.CONFIG.DirIndexingEnabled { if config.CONFIG.DirIndexingEnabled {
@@ -87,7 +115,7 @@ func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPa
} }
} }
data := []byte(strings.Join(contents, "")) data := []byte(strings.Join(contents, ""))
headerBytes := []byte("20 text/gemini;\r\n") headerBytes := []byte("20 text/gemini; lang=en\r\n")
response := append(headerBytes, data...) response := append(headerBytes, data...)
return response, nil return response, nil
} else { } else {
@@ -100,7 +128,7 @@ func generateResponseDir(conn *tls.Conn, connId string, url *common.URL, localPa
func calculateLocalPath(input string, basePath string) (string, error) { func calculateLocalPath(input string, basePath string) (string, error) {
// Check for invalid characters early // Check for invalid characters early
if strings.ContainsAny(input, "\\") { if strings.ContainsAny(input, "\\") {
return "", errors.NewError(fmt.Errorf("invalid characters in path: %s", input)) return "", xerrors.NewError(fmt.Errorf("invalid characters in path: %s", input), 0, "Invalid path characters", false)
} }
// If IsLocal(path) returns true, then Join(base, path) // If IsLocal(path) returns true, then Join(base, path)
@@ -116,7 +144,7 @@ func calculateLocalPath(input string, basePath string) (string, error) {
localPath, err := filepath.Localize(filePath) localPath, err := filepath.Localize(filePath)
if err != nil || !filepath.IsLocal(localPath) { if err != nil || !filepath.IsLocal(localPath) {
return "", errors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err)) return "", xerrors.NewError(fmt.Errorf("could not construct local path from %s: %s", input, err), 0, "Invalid local path", false)
} }
filePath = path.Join(basePath, localPath) filePath = path.Join(basePath, localPath)