Initial commit.
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
some basics: - minimal README - some TODOs - server and request handler framework - contribs: file serving, request logging - server examples - CI setup
This commit is contained in:
commit
ff05d62013
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
kind: pipeline
|
||||
name: verify
|
||||
|
||||
steps:
|
||||
- name: test
|
||||
image: golang
|
||||
commands:
|
||||
- go test -v ./...
|
|
@ -0,0 +1,10 @@
|
|||
Gus: The small web application framework
|
||||
========================================
|
||||
|
||||
Gus is named after Virgil "Gus" Grissom, one of the pilots in the Gemini program and commander of Gemini 3.
|
||||
|
||||
Gus is, to my knowledge, the first gemini server conceived as a _framework_. The goal is to provide the go-to set of Go libraries for interacting with the gemini protocol as a server or client, somewhat analagous to net/http in the standard library.
|
||||
|
||||
Thus `gus/gemini` defines types such as `Request` and `Response`, useful interfaces such as a `Handler` abstraction, the concept of `Middleware`, and a `Server` which brings together a `net.Listener`, a `tls.Config`, and a `Handler` to actually serve the protocol. It *does not*, however, contain any logic for serving files from the filesystem or things of that nature.
|
||||
|
||||
Many of the utilities needed to build an _actually useful_ server are in `gus/contrib` sub-packages, and there are examples of how to compose them in the `examples` directory.
|
|
@ -0,0 +1,13 @@
|
|||
- [x] server
|
||||
- [x] TLS configuration from cert+key files
|
||||
- [ ] client
|
||||
- [x] contrib - filesystem handling
|
||||
- [x] serving files
|
||||
- [x] directory index files
|
||||
- [x] directory listing
|
||||
- [ ] reject symlinks pointing outside fs root
|
||||
- [ ] filtering middleware
|
||||
- [x] contrib - request logging
|
||||
- [ ] contrib - CGI
|
||||
- [ ] contrib - shared hosting helper
|
||||
- [ ] contrib - TLS auth
|
|
@ -0,0 +1,174 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
// DirectoryDefault handles directory path requests by looking for specific filenames.
|
||||
//
|
||||
// If any of the supported filenames are found, the contents of the file is returned
|
||||
// as the gemini response.
|
||||
//
|
||||
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
|
||||
//
|
||||
// When it encounters a directory path which doesn't end in a trailing slash (/) it
|
||||
// redirects to a URL with the trailing slash appended. This is necessary for relative
|
||||
// links into the directory's contents to function.
|
||||
//
|
||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
||||
// it will also produce "51 Not Found" responses for directory paths.
|
||||
func DirectoryDefault(fileSystem fs.FS, fileNames ...string) gemini.Handler {
|
||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
||||
path, dirFile, resp := handleDir(req, fileSystem)
|
||||
if resp != nil {
|
||||
return resp
|
||||
}
|
||||
defer dirFile.Close()
|
||||
|
||||
entries, err := dirFile.ReadDir(0)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
for _, fileName := range fileNames {
|
||||
for _, entry := range entries {
|
||||
if entry.Name() == fileName {
|
||||
file, err := fileSystem.Open(path + "/" + fileName)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
return gemini.Success(mediaType(fileName), file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
}
|
||||
|
||||
// DirectoryListing produces a gemtext listing of the contents of any requested directories.
|
||||
//
|
||||
// It returns "51 Not Found" for any paths which don't correspond to a filesystem directory.
|
||||
//
|
||||
// When it encounters a directory path which doesn't end in a trailing slash (/) it
|
||||
// redirects to a URL with the trailing slash appended. This is necessary for relative
|
||||
// links into the directory's contents to function.
|
||||
//
|
||||
// It requires that files from the provided fs.FS implement fs.ReadDirFile. If they don't,
|
||||
// it will also produce "51 Not Found" responses for directory paths.
|
||||
//
|
||||
// The template is provided the following namespace:
|
||||
// - .FullPath: the complete path to the listed directory
|
||||
// - .DirName: the name of the directory itself
|
||||
// - .Entries: the []fs.DirEntry of the directory contents
|
||||
//
|
||||
// The template argument may be nil, in which case a simple default template is used.
|
||||
func DirectoryListing(fileSystem fs.FS, template *template.Template) gemini.Handler {
|
||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
||||
path, dirFile, resp := handleDir(req, fileSystem)
|
||||
if resp != nil {
|
||||
return resp
|
||||
}
|
||||
defer dirFile.Close()
|
||||
|
||||
if template == nil {
|
||||
template = defaultDirListTemplate
|
||||
}
|
||||
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
environ, err := dirlistNamespace(path, dirFile)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
if err := template.Execute(buf, environ); err != nil {
|
||||
gemini.Failure(err)
|
||||
}
|
||||
|
||||
return gemini.Success("text/gemini", buf)
|
||||
}
|
||||
}
|
||||
|
||||
var defaultDirListTemplate = template.Must(template.New("directory_listing").Parse(`
|
||||
# {{ .DirName }}
|
||||
{{ range .Entries }}
|
||||
=> {{ .Name }}{{ if .IsDir }}/{{ end -}}
|
||||
{{ end }}
|
||||
=> ../
|
||||
`[1:]))
|
||||
|
||||
func dirlistNamespace(path string, dirFile fs.ReadDirFile) (map[string]any, error) {
|
||||
entries, err := dirFile.ReadDir(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].Name() < entries[j].Name()
|
||||
})
|
||||
|
||||
var dirname string
|
||||
if path == "." {
|
||||
dirname = "(root)"
|
||||
} else {
|
||||
dirname = path[strings.LastIndex(path, "/")+1:]
|
||||
}
|
||||
|
||||
m := map[string]any{
|
||||
"FullPath": path,
|
||||
"DirName": dirname,
|
||||
"Entries": entries,
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func handleDir(req *gemini.Request, fileSystem fs.FS) (string, fs.ReadDirFile, *gemini.Response) {
|
||||
path := strings.Trim(req.Path, "/")
|
||||
if path == "" {
|
||||
path = "."
|
||||
}
|
||||
|
||||
file, err := fileSystem.Open(path)
|
||||
if isNotFound(err) {
|
||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, gemini.Failure(err)
|
||||
}
|
||||
|
||||
isDir, err := fileIsDir(file)
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return "", nil, gemini.Failure(err)
|
||||
}
|
||||
|
||||
if !isDir {
|
||||
file.Close()
|
||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(req.Path, "/") {
|
||||
file.Close()
|
||||
url := *req.URL
|
||||
url.Path += "/"
|
||||
return "", nil, gemini.Redirect(url.String())
|
||||
}
|
||||
|
||||
dirFile, ok := file.(fs.ReadDirFile)
|
||||
if !ok {
|
||||
file.Close()
|
||||
return "", nil, gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
|
||||
return path, dirFile, nil
|
||||
}
|
|
@ -0,0 +1,55 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"strings"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
// FileHandler builds a handler function which serves up a file system.
|
||||
func FileHandler(fileSystem fs.FS) gemini.Handler {
|
||||
return func(ctx context.Context, req *gemini.Request) *gemini.Response {
|
||||
file, err := fileSystem.Open(strings.TrimPrefix(req.Path, "/"))
|
||||
if isNotFound(err) {
|
||||
return gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
isDir, err := fileIsDir(file)
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
if isDir {
|
||||
return gemini.NotFound("Resource does not exist.")
|
||||
}
|
||||
|
||||
return gemini.Success(mediaType(req.Path), file)
|
||||
}
|
||||
}
|
||||
|
||||
func mediaType(filePath string) string {
|
||||
if strings.HasSuffix(filePath, ".gmi") {
|
||||
// This may not be present in the listings searched by mime.TypeByExtension,
|
||||
// so provide a dedicated fast path for it here.
|
||||
return "text/gemini"
|
||||
}
|
||||
|
||||
slashIdx := strings.LastIndex(filePath, "/")
|
||||
dotIdx := strings.LastIndex(filePath[slashIdx+1:], ".")
|
||||
if dotIdx == -1 {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
ext := filePath[slashIdx+dotIdx:]
|
||||
|
||||
mtype := mime.TypeByExtension(ext)
|
||||
if mtype == "" {
|
||||
return "application/octet-stream"
|
||||
}
|
||||
return mtype
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
package fs
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
func isNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var pathErr *fs.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
e := pathErr.Err
|
||||
return errors.Is(e, fs.ErrInvalid) || errors.Is(e, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func fileIsDir(file fs.File) (bool, error) {
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return info.IsDir(), nil
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
kitlog "github.com/go-kit/log"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func Requests(out io.Writer, logger kitlog.Logger) gemini.Middleware {
|
||||
if logger == nil {
|
||||
logger = kitlog.NewLogfmtLogger(kitlog.NewSyncWriter(out))
|
||||
}
|
||||
|
||||
return func(next gemini.Handler) gemini.Handler {
|
||||
return func(ctx context.Context, r *gemini.Request) (resp *gemini.Response) {
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
logger.Log(
|
||||
"msg", "request",
|
||||
"ts", end,
|
||||
"dur", end.Sub(start),
|
||||
"url", r.URL,
|
||||
"status", resp.Status,
|
||||
)
|
||||
}()
|
||||
|
||||
return next(ctx, r)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get TLS files from the environment
|
||||
certfile, keyfile := envConfig()
|
||||
|
||||
// build a TLS configuration suitable for gemini
|
||||
tlsconf, err := gemini.FileTLS(certfile, keyfile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// set up the network listener
|
||||
listener, err := net.Listen("tcp4", ":1965")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// add request logging to the request handler
|
||||
handler := guslog.Requests(os.Stdout, nil)(cowsayHandler)
|
||||
|
||||
// run the server
|
||||
gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
|
||||
}
|
||||
|
||||
func cowsayHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
|
||||
// prompt for a query if there is none already
|
||||
if req.RawQuery == "" {
|
||||
return gemini.Input("enter a phrase")
|
||||
}
|
||||
|
||||
// find the "cowsay" executable
|
||||
binpath, err := exec.LookPath("cowsay")
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
// build the command and set the query to be passed to its stdin
|
||||
cmd := exec.CommandContext(ctx, binpath)
|
||||
cmd.Stdin = bytes.NewBufferString(req.UnescapedQuery())
|
||||
|
||||
// set up a pipe so we can read the command's stdout
|
||||
rd, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
// start the command
|
||||
if err := cmd.Start(); err != nil {
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
// read the complete stdout contents, clean up the process on error
|
||||
buf, err := io.ReadAll(rd)
|
||||
if err != nil {
|
||||
cmd.Process.Kill()
|
||||
cmd.Wait()
|
||||
return gemini.Failure(err)
|
||||
}
|
||||
|
||||
// wait for the process to close
|
||||
cmd.Wait()
|
||||
|
||||
// pass the buffer to the response wrapped in ``` toggles,
|
||||
// and include a link to start over
|
||||
out := io.MultiReader(
|
||||
bytes.NewBufferString("```\n"),
|
||||
bytes.NewBuffer(buf),
|
||||
bytes.NewBufferString("\n```\n=> . again"),
|
||||
)
|
||||
return gemini.Success("text/gemini", out)
|
||||
}
|
||||
|
||||
func envConfig() (string, string) {
|
||||
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_CERTIFICATE environment variable")
|
||||
}
|
||||
|
||||
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
|
||||
}
|
||||
|
||||
return certfile, keyfile
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"tildegit.org/tjp/gus/contrib/fs"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get TLS files from the environment
|
||||
certfile, keyfile := envConfig()
|
||||
|
||||
// build a TLS configuration suitable for gemini
|
||||
tlsconf, err := gemini.FileTLS(certfile, keyfile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// set up the network listen
|
||||
listener, err := net.Listen("tcp4", ":1965")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// build the request handler
|
||||
fileSystem := os.DirFS(".")
|
||||
// Fallthrough tries each handler in succession until it gets something other than "51 Not Found"
|
||||
handler := gemini.Fallthrough(
|
||||
// first see if they're fetching a directory and we have <dir>/index.gmi
|
||||
fs.DirectoryDefault(fileSystem, "index.gmi"),
|
||||
// next (still if they requested a directory) build a directory listing response
|
||||
fs.DirectoryListing(fileSystem, nil),
|
||||
// finally, try to find a file at the request path and respond with that
|
||||
fs.FileHandler(fileSystem),
|
||||
)
|
||||
// add request logging to stdout
|
||||
handler = guslog.Requests(os.Stdout, nil)(handler)
|
||||
|
||||
// run the server
|
||||
gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
|
||||
}
|
||||
|
||||
func envConfig() (string, string) {
|
||||
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_CERTIFICATE environment variable")
|
||||
}
|
||||
|
||||
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
|
||||
}
|
||||
|
||||
return certfile, keyfile
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
guslog "tildegit.org/tjp/gus/contrib/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Get TLS files from the environment
|
||||
certfile, keyfile := envConfig()
|
||||
|
||||
// build a TLS configuration suitable for gemini
|
||||
tlsconf, err := gemini.FileTLS(certfile, keyfile)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// set up the network listener
|
||||
listener, err := net.Listen("tcp4", ":1965")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// add stdout logging to the request handler
|
||||
handler := guslog.Requests(os.Stdout, nil)(inspectHandler)
|
||||
|
||||
// run the server
|
||||
gemini.NewServer(context.Background(), tlsconf, listener, handler).Serve()
|
||||
}
|
||||
|
||||
func envConfig() (string, string) {
|
||||
certfile, ok := os.LookupEnv("SERVER_CERTIFICATE")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_CERTIFICATE environment variable")
|
||||
}
|
||||
|
||||
keyfile, ok := os.LookupEnv("SERVER_PRIVATEKEY")
|
||||
if !ok {
|
||||
log.Fatal("missing SERVER_PRIVATEKEY environment variable")
|
||||
}
|
||||
|
||||
return certfile, keyfile
|
||||
}
|
||||
|
||||
func inspectHandler(ctx context.Context, req *gemini.Request) *gemini.Response {
|
||||
// build and return a ```-wrapped description of the connection TLS state
|
||||
body := "```\n" + displayTLSState(req.TLSState) + "\n```"
|
||||
return gemini.Success("text/gemini", bytes.NewBufferString(body))
|
||||
}
|
||||
|
||||
func displayTLSState(state *tls.ConnectionState) string {
|
||||
builder := &strings.Builder{}
|
||||
|
||||
builder.WriteString("Version: ")
|
||||
builder.WriteString(map[uint16]string{
|
||||
tls.VersionTLS10: "TLSv1.0",
|
||||
tls.VersionTLS11: "TLSv1.1",
|
||||
tls.VersionTLS12: "TLSv1.2",
|
||||
tls.VersionTLS13: "TLSv1.3",
|
||||
tls.VersionSSL30: "SSLv3",
|
||||
}[state.Version])
|
||||
builder.WriteString("\n")
|
||||
|
||||
builder.WriteString(fmt.Sprintf("Handshake complete: %t\n", state.HandshakeComplete))
|
||||
builder.WriteString(fmt.Sprintf("Did resume: %t\n", state.DidResume))
|
||||
builder.WriteString(fmt.Sprintf("Cipher suite: %x\n", state.CipherSuite))
|
||||
builder.WriteString(fmt.Sprintf("Negotiated protocol: %q\n", state.NegotiatedProtocol))
|
||||
builder.WriteString(fmt.Sprintf("Server name: %s\n", state.ServerName))
|
||||
|
||||
builder.WriteString(fmt.Sprintf("Certificates (%d)\n", len(state.PeerCertificates)))
|
||||
for i, cert := range state.PeerCertificates {
|
||||
builder.WriteString(fmt.Sprintf(" #%d: %s\n", i+1, fingerprint(cert)))
|
||||
}
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func fingerprint(cert *x509.Certificate) []byte {
|
||||
raw := md5.Sum(cert.Raw)
|
||||
dst := make([]byte, hex.EncodedLen(len(raw)))
|
||||
hex.Encode(dst, raw[:])
|
||||
return dst
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package gemini
|
||||
|
||||
import "context"
|
||||
|
||||
// Handler is a function which can turn a gemini request into a gemini response.
|
||||
//
|
||||
// A Handler MUST NOT return a nil response. Errors should be returned in the form
|
||||
// of error responses (4x, 5x, 6x response status). If the Handler should not be
|
||||
// responsible for the requested resource it can return a "51 Not Found" response.
|
||||
type Handler func(context.Context, *Request) *Response
|
||||
|
||||
// Middleware is a handle decorator.
|
||||
//
|
||||
// It returns a handler which may call the passed-in handler or not, or may
|
||||
// transform the request or response in some way.
|
||||
type Middleware func(Handler) Handler
|
||||
|
||||
func Fallthrough(handlers ...Handler) Handler {
|
||||
return func(ctx context.Context, req *Request) *Response {
|
||||
for _, handler := range handlers {
|
||||
response := handler(ctx, req)
|
||||
if response.Status != StatusNotFound {
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
return NotFound("Resource does not exist.")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// InvalidRequestLineEnding indicates that a gemini request didn't end with "\r\n".
|
||||
var InvalidRequestLineEnding = errors.New("invalid request line ending")
|
||||
|
||||
// Request represents a request over the gemini protocol.
|
||||
type Request struct {
|
||||
*url.URL
|
||||
|
||||
TLSState *tls.ConnectionState
|
||||
}
|
||||
|
||||
// ParseRequest parses a single gemini request from a reader.
|
||||
func ParseRequest(rdr io.Reader) (*Request, error) {
|
||||
line, err := bufio.NewReader(rdr).ReadString('\n')
|
||||
if err != io.EOF && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(line) < 2 || line[len(line)-2:] != "\r\n" {
|
||||
return nil, InvalidRequestLineEnding
|
||||
}
|
||||
|
||||
u, err := url.Parse(line[:len(line)-2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if u.Scheme == "" {
|
||||
u.Scheme = "gemini"
|
||||
}
|
||||
|
||||
return &Request{URL: u}, nil
|
||||
}
|
||||
|
||||
// UnescapedQuery performs %XX unescaping on the URL query segment.
|
||||
//
|
||||
// Like URL.Query(), it silently drops malformed %-encoded sequences.
|
||||
func (req Request) UnescapedQuery() string {
|
||||
unescaped, _ := url.QueryUnescape(req.RawQuery)
|
||||
return unescaped
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package gemini_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func TestParseRequest(t *testing.T) {
|
||||
table := []struct {
|
||||
input string
|
||||
scheme string
|
||||
host string
|
||||
path string
|
||||
query string
|
||||
fragment string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
input: "gemini://foo.com/bar?baz#qux\r\n",
|
||||
scheme: "gemini",
|
||||
host: "foo.com",
|
||||
path: "/bar",
|
||||
query: "baz",
|
||||
fragment: "qux",
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
input: "//foo.com/path\r\n",
|
||||
scheme: "gemini",
|
||||
host: "foo.com",
|
||||
path: "/path",
|
||||
query: "",
|
||||
fragment: "",
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
input: "/path\r\n",
|
||||
scheme: "gemini",
|
||||
host: "",
|
||||
path: "/path",
|
||||
query: "",
|
||||
fragment: "",
|
||||
err: nil,
|
||||
},
|
||||
{
|
||||
input: "gemini://invalid.com/line/ending",
|
||||
scheme: "",
|
||||
host: "",
|
||||
path: "",
|
||||
query: "",
|
||||
fragment: "",
|
||||
err: gemini.InvalidRequestLineEnding,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
t.Run(test.input, func(t *testing.T) {
|
||||
req, err := gemini.ParseRequest(bytes.NewBufferString(test.input))
|
||||
if err != test.err {
|
||||
t.Fatalf("expected error %q, got %q", test.err, err)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Scheme != test.scheme {
|
||||
t.Errorf("expected scheme %q, got %q", test.scheme, req.Scheme)
|
||||
}
|
||||
if req.Host != test.host {
|
||||
t.Errorf("expected host %q, got %q", test.host, req.Host)
|
||||
}
|
||||
if req.Path != test.path {
|
||||
t.Errorf("expected path %q, got %q", test.path, req.Path)
|
||||
}
|
||||
if req.RawQuery != test.query {
|
||||
t.Errorf("expected query %q, got %q", test.query, req.RawQuery)
|
||||
}
|
||||
if req.Fragment != test.fragment {
|
||||
t.Errorf("expected fragment %q, got %q", test.fragment, req.Fragment)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,308 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// StatusCategory represents the various types of responses.
|
||||
type StatusCategory int
|
||||
|
||||
const (
|
||||
// StatusCategoryInput is for responses which request additional input.
|
||||
//
|
||||
// The META line will be the prompt to display to the user.
|
||||
StatusCategoryInput StatusCategory = iota*10 + 10
|
||||
// StatusCategorySuccess is for successful responses.
|
||||
//
|
||||
// The META line will be the resource's mime type.
|
||||
// This is the only response status which indicates the presence of a response body,
|
||||
// and it will contain the resource itself.
|
||||
StatusCategorySuccess
|
||||
// StatusCategoryRedirect is for responses which direct the client to an alternative URL.
|
||||
//
|
||||
// The META line will contain the new URL the client should try.
|
||||
StatusCategoryRedirect
|
||||
// StatusCategoryTemporaryFailure is for responses which indicate a transient server-side failure.
|
||||
//
|
||||
// The META line may contain a line with more information about the error.
|
||||
StatusCategoryTemporaryFailure
|
||||
// StatusCategoryPermanentFailure is for permanent failure responses.
|
||||
//
|
||||
// The META line may contain a line with more information about the error.
|
||||
StatusCategoryPermanentFailure
|
||||
// StatusCategoryCertificateRequired indicates client certificate related issues.
|
||||
//
|
||||
// The META line may contain a line with more information about the error.
|
||||
StatusCategoryCertificateRequired
|
||||
)
|
||||
|
||||
// Status is the integer status code of a gemini response.
|
||||
type Status int
|
||||
|
||||
const (
|
||||
// StatusInput indicates a required query parameter at the requested URL.
|
||||
StatusInput Status = Status(StatusCategoryInput) + iota
|
||||
// StatusSensitiveInput indicates a sensitive query parameter is required.
|
||||
StatusSensitiveInput
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusSuccess is a successful response.
|
||||
StatusSuccess = Status(StatusCategorySuccess) + iota
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusTemporaryRedirect indicates a temporary redirect to another URL.
|
||||
StatusTemporaryRedirect = Status(StatusCategoryRedirect) + iota
|
||||
// StatusPermanentRedirect indicates that the resource should always be requested at the new URL.
|
||||
StatusPermanentRedirect
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusTemporaryFailure indicates that the request failed and there is no response body.
|
||||
StatusTemporaryFailure = Status(StatusCategoryTemporaryFailure) + iota
|
||||
// StatusServerUnavailable occurs when the server is unavailable due to overload or maintenance.
|
||||
StatusServerUnavailable
|
||||
// StatusCGIError is the result of a failure of a CGI script.
|
||||
StatusCGIError
|
||||
// StatusProxyError indicates that the server is acting as a proxy and the outbound request failed.
|
||||
StatusProxyError
|
||||
// StatusSlowDown tells the client that rate limiting is in effect.
|
||||
//
|
||||
// Unlike other statuses in this category, the META line is an integer indicating how
|
||||
// many more seconds the client must wait before sending another request.
|
||||
StatusSlowDown
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusPermanentFailure is a server failure which should be expected to continue indefinitely.
|
||||
StatusPermanentFailure = Status(StatusCategoryPermanentFailure) + iota
|
||||
// StatusNotFound means the resource doesn't exist but it may in the future.
|
||||
StatusNotFound
|
||||
// StatusGone occurs when a resource will not be available any longer.
|
||||
StatusGone
|
||||
// StatusProxyRequestRefused means the server is unwilling to act as a proxy for the resource.
|
||||
StatusProxyRequestRefused
|
||||
// StatusBadRequest indicates that the request was malformed somehow.
|
||||
StatusBadRequest = Status(StatusCategoryPermanentFailure) + 9
|
||||
)
|
||||
|
||||
const (
|
||||
// StatusClientCertificateRequired is returned when a certificate was required but not provided.
|
||||
StatusClientCertificateRequired = Status(StatusCategoryCertificateRequired) + iota
|
||||
// StatusCertificateNotAuthorized means the certificate doesn't grant access to the requested resource.
|
||||
StatusCertificateNotAuthorized
|
||||
// StatusCertificateNotValid means the provided client certificate is invalid.
|
||||
StatusCertificateNotValid
|
||||
)
|
||||
|
||||
// StatusCategory returns the category a specific status belongs to.
|
||||
func (s Status) Category() StatusCategory {
|
||||
return StatusCategory(s / 10)
|
||||
}
|
||||
|
||||
// Response contains everything in a gemini protocol response.
|
||||
type Response struct {
|
||||
// Status is the status code of the response.
|
||||
Status Status
|
||||
|
||||
// Meta is the status-specific line of additional information.
|
||||
Meta string
|
||||
|
||||
// Body is the response body, if any.
|
||||
Body io.Reader
|
||||
|
||||
reader io.Reader
|
||||
}
|
||||
|
||||
// Input builds an input-prompting response.
|
||||
func Input(prompt string) *Response {
|
||||
return &Response{
|
||||
Status: StatusInput,
|
||||
Meta: prompt,
|
||||
}
|
||||
}
|
||||
|
||||
// SensitiveInput builds a password-prompting response.
|
||||
func SensitiveInput(prompt string) *Response {
|
||||
return &Response{
|
||||
Status: StatusSensitiveInput,
|
||||
Meta: prompt,
|
||||
}
|
||||
}
|
||||
|
||||
// Success builds a success response with resource body.
|
||||
func Success(mediatype string, body io.Reader) *Response {
|
||||
return &Response{
|
||||
Status: StatusSuccess,
|
||||
Meta: mediatype,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect builds a redirect response.
|
||||
func Redirect(url string) *Response {
|
||||
return &Response{
|
||||
Status: StatusTemporaryRedirect,
|
||||
Meta: url,
|
||||
}
|
||||
}
|
||||
|
||||
// PermanentRedirect builds a response with a permanent redirect.
|
||||
func PermanentRedirect(url string) *Response {
|
||||
return &Response{
|
||||
Status: StatusPermanentRedirect,
|
||||
Meta: url,
|
||||
}
|
||||
}
|
||||
|
||||
// Failure builds a temporary failure response from an error.
|
||||
func Failure(err error) *Response {
|
||||
return &Response{
|
||||
Status: StatusTemporaryFailure,
|
||||
Meta: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// Unavailable build a "server unavailable" response.
|
||||
func Unavailable(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusServerUnavailable,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// CGIError builds a "cgi error" response.
|
||||
func CGIError(err string) *Response {
|
||||
return &Response{
|
||||
Status: StatusCGIError,
|
||||
Meta: err,
|
||||
}
|
||||
}
|
||||
|
||||
// ProxyError builds a proxy error response.
|
||||
func ProxyError(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusProxyError,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// SlowDown builds a "slow down" response with the number of seconds until the resource is available.
|
||||
func SlowDown(seconds int) *Response {
|
||||
return &Response{
|
||||
Status: StatusSlowDown,
|
||||
Meta: strconv.Itoa(seconds),
|
||||
}
|
||||
}
|
||||
|
||||
// PermanentFailure builds a "permanent failure" from an error.
|
||||
func PermanentFailure(err error) *Response {
|
||||
return &Response{
|
||||
Status: StatusPermanentFailure,
|
||||
Meta: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// NotFound builds a "resource not found" response.
|
||||
func NotFound(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusNotFound,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// Gone builds a "resource gone" response.
|
||||
func Gone(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusGone,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// RefuseProxy builds a "proxy request refused" response.
|
||||
func RefuseProxy(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusProxyRequestRefused,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// BadRequest builds a "bad request" response.
|
||||
func BadRequest(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusBadRequest,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// RequireCert builds a "client certificate required" response.
|
||||
func RequireCert(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusClientCertificateRequired,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// CertAuthFailure builds a "certificate not authorized" response.
|
||||
func CertAuthFailure(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusCertificateNotAuthorized,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// CertInvalid builds a "client certificate not valid" response.
|
||||
func CertInvalid(msg string) *Response {
|
||||
return &Response{
|
||||
Status: StatusCertificateNotValid,
|
||||
Meta: msg,
|
||||
}
|
||||
}
|
||||
|
||||
// Read implements io.Reader for Response.
|
||||
func (r *Response) Read(b []byte) (int, error) {
|
||||
r.ensureReader()
|
||||
return r.reader.Read(b)
|
||||
}
|
||||
|
||||
// WriteTo implements io.WriterTo for Response.
|
||||
func (r *Response) WriteTo(dst io.Writer) (int64, error) {
|
||||
r.ensureReader()
|
||||
return r.reader.(io.WriterTo).WriteTo(dst)
|
||||
}
|
||||
|
||||
// Close implements io.Closer and ensures the body gets closed.
|
||||
func (r *Response) Close() error {
|
||||
if r != nil {
|
||||
if cl, ok := r.Body.(io.Closer); ok {
|
||||
return cl.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Response) ensureReader() {
|
||||
if r.reader != nil {
|
||||
return
|
||||
}
|
||||
|
||||
hdr := bytes.NewBuffer(r.headerLine())
|
||||
if r.Body != nil {
|
||||
r.reader = io.MultiReader(hdr, r.Body)
|
||||
} else {
|
||||
r.reader = hdr
|
||||
}
|
||||
}
|
||||
|
||||
func (r Response) headerLine() []byte {
|
||||
buf := make([]byte, len(r.Meta)+5)
|
||||
_ = strconv.AppendInt(buf[:0], int64(r.Status), 10)
|
||||
buf[2] = ' '
|
||||
copy(buf[3:], r.Meta)
|
||||
buf[len(buf)-2] = '\r'
|
||||
buf[len(buf)-1] = '\n'
|
||||
return buf
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
package gemini_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"tildegit.org/tjp/gus/gemini"
|
||||
)
|
||||
|
||||
func TestBuildResponses(t *testing.T) {
|
||||
table := []struct {
|
||||
name string
|
||||
response *gemini.Response
|
||||
status gemini.Status
|
||||
meta string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "input response",
|
||||
response: gemini.Input("prompt here"),
|
||||
status: gemini.StatusInput,
|
||||
meta: "prompt here",
|
||||
},
|
||||
{
|
||||
name: "sensitive input response",
|
||||
response: gemini.SensitiveInput("password please"),
|
||||
status: gemini.StatusSensitiveInput,
|
||||
meta: "password please",
|
||||
},
|
||||
{
|
||||
name: "success response",
|
||||
response: gemini.Success("text/gemini", bytes.NewBufferString("body text here")),
|
||||
status: gemini.StatusSuccess,
|
||||
meta: "text/gemini",
|
||||
body: "body text here",
|
||||
},
|
||||
{
|
||||
name: "temporary redirect",
|
||||
response: gemini.Redirect("/foo/bar"),
|
||||
status: gemini.StatusTemporaryRedirect,
|
||||
meta: "/foo/bar",
|
||||
},
|
||||
{
|
||||
name: "permanent redirect",
|
||||
response: gemini.PermanentRedirect("/baz/qux"),
|
||||
status: gemini.StatusPermanentRedirect,
|
||||
meta: "/baz/qux",
|
||||
},
|
||||
{
|
||||
name: "fail response",
|
||||
response: gemini.Failure(errors.New("a failure")),
|
||||
status: gemini.StatusTemporaryFailure,
|
||||
meta: "a failure",
|
||||
},
|
||||
{
|
||||
name: "server unavailable",
|
||||
response: gemini.Unavailable("server unavailable"),
|
||||
status: gemini.StatusServerUnavailable,
|
||||
meta: "server unavailable",
|
||||
},
|
||||
{
|
||||
name: "cgi error",
|
||||
response: gemini.CGIError("some cgi error msg"),
|
||||
status: gemini.StatusCGIError,
|
||||
meta: "some cgi error msg",
|
||||
},
|
||||
{
|
||||
name: "proxy error",
|
||||
response: gemini.ProxyError("upstream's full"),
|
||||
status: gemini.StatusProxyError,
|
||||
meta: "upstream's full",
|
||||
},
|
||||
{
|
||||
name: "rate limiting",
|
||||
response: gemini.SlowDown(15),
|
||||
status: gemini.StatusSlowDown,
|
||||
meta: "15",
|
||||
},
|
||||
{
|
||||
name: "permanent failure",
|
||||
response: gemini.PermanentFailure(errors.New("wut r u doin")),
|
||||
status: gemini.StatusPermanentFailure,
|
||||
meta: "wut r u doin",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
response: gemini.NotFound("nope"),
|
||||
status: gemini.StatusNotFound,
|
||||
meta: "nope",
|
||||
},
|
||||
{
|
||||
name: "gone",
|
||||
response: gemini.Gone("all out of that"),
|
||||
status: gemini.StatusGone,
|
||||
meta: "all out of that",
|
||||
},
|
||||
{
|
||||
name: "refuse proxy",
|
||||
response: gemini.RefuseProxy("no I don't think I will"),
|
||||
status: gemini.StatusProxyRequestRefused,
|
||||
meta: "no I don't think I will",
|
||||
},
|
||||
{
|
||||
name: "bad request",
|
||||
response: gemini.BadRequest("that don't make no sense"),
|
||||
status: gemini.StatusBadRequest,
|
||||
meta: "that don't make no sense",
|
||||
},
|
||||
{
|
||||
name: "require cert",
|
||||
response: gemini.RequireCert("cert required"),
|
||||
status: gemini.StatusClientCertificateRequired,
|
||||
meta: "cert required",
|
||||
},
|
||||
{
|
||||
name: "cert auth failure",
|
||||
response: gemini.CertAuthFailure("you can't see that"),
|
||||
status: gemini.StatusCertificateNotAuthorized,
|
||||
meta: "you can't see that",
|
||||
},
|
||||
{
|
||||
name: "invalid cert",
|
||||
response: gemini.CertInvalid("bad cert dude"),
|
||||
status: gemini.StatusCertificateNotValid,
|
||||
meta: "bad cert dude",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range table {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if test.response.Status != test.status {
|
||||
t.Errorf("expected status %d, got %d", test.status, test.response.Status)
|
||||
}
|
||||
if test.response.Meta != test.meta {
|
||||
t.Errorf("expected meta %q, got %q", test.meta, test.response.Meta)
|
||||
}
|
||||
|
||||
responseBytes, err := io.ReadAll(test.response)
|
||||
if err != nil {
|
||||
t.Fatalf("error reading response body: %q", err.Error())
|
||||
}
|
||||
|
||||
body := string(bytes.SplitN(responseBytes, []byte("\r\n"), 2)[1])
|
||||
if body != test.body {
|
||||
t.Errorf("expected body %q, got %q", test.body, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
wg *sync.WaitGroup
|
||||
listener net.Listener
|
||||
handler Handler
|
||||
}
|
||||
|
||||
func NewServer(ctx context.Context, tlsConfig *tls.Config, listener net.Listener, handler Handler) *Server {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
s := &Server{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
wg: &sync.WaitGroup{},
|
||||
listener: tls.NewListener(listener, tlsConfig),
|
||||
handler: handler,
|
||||
}
|
||||
go s.propagateCancel()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) Close() {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
func (s *Server) Serve() {
|
||||
s.wg.Add(1)
|
||||
defer s.wg.Done()
|
||||
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.wg.Add(1)
|
||||
go s.handleConn(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleConn(conn net.Conn) {
|
||||
defer s.wg.Done()
|
||||
defer conn.Close()
|
||||
|
||||
req, err := ParseRequest(conn)
|
||||
if tlsconn, ok := conn.(*tls.Conn); req != nil && ok {
|
||||
state := tlsconn.ConnectionState()
|
||||
req.TLSState = &state
|
||||
}
|
||||
|
||||
var resp *Response
|
||||
if err == nil {
|
||||
resp = s.handler(s.ctx, req)
|
||||
} else {
|
||||
resp = BadRequest(err.Error())
|
||||
}
|
||||
defer resp.Close()
|
||||
|
||||
_, _ = io.Copy(conn, resp)
|
||||
}
|
||||
|
||||
func (s *Server) propagateCancel() {
|
||||
go func() {
|
||||
<-s.ctx.Done()
|
||||
_ = s.listener.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) closed() bool {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
package gemini
|
||||
|
||||
import "crypto/tls"
|
||||
|
||||
func FileTLS(certfile string, keyfile string) (*tls.Config, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certfile, keyfile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{cert},
|
||||
MinVersion: tls.VersionTLS12,
|
||||
ClientAuth: tls.RequestClientCert,
|
||||
}, nil
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
module tildegit.org/tjp/gus
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/go-kit/log v0.2.1
|
||||
|
||||
require github.com/go-logfmt/logfmt v0.5.1 // indirect
|
|
@ -0,0 +1,4 @@
|
|||
github.com/go-kit/log v0.2.1 h1:MRVx0/zhvdseW+Gza6N9rVzU/IVzaeE1SFI4raAhmBU=
|
||||
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-logfmt/logfmt v0.5.1 h1:otpy5pqBCBZ1ng9RQ0dPu4PN7ba75Y/aA+UpowDyNVA=
|
||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
Reference in New Issue