An example chat server in golang.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

489 rivejä
14 KiB

package main
// TODO learn more about chan chan's
// http://marcio.io/2015/07/handling-1-million-requests-per-minute-with-golang/
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/base64"
"flag"
"fmt"
"io/ioutil"
"net"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/emicklei/go-restful"
"gopkg.in/yaml.v2"
)
// I'm not sure how to pass nested structs, so I de-nested this.
// TODO Learn if passing nested structs is desirable?
type Conf struct {
Addr string `yaml:"addr,omitempty"`
Port uint `yaml:"port,omitempty"`
Mailer ConfMailer
RootPath string `yaml:"root_path,omitempty"`
}
type ConfMailer struct {
Url string `yaml:"url,omitempty"`
ApiKey string `yaml:"api_key,omitempty"`
From string `yaml:"from,omitempty"`
}
// So we can peek at net.Conn, which we can't do natively
// https://stackoverflow.com/questions/51472020/how-to-get-the-size-of-available-tcp-data
type bufferedConn struct {
r *bufio.Reader
//rout *io.Reader // See https://github.com/polvi/sni/blob/master/sni.go#L135
net.Conn
}
func newBufferedConn(c net.Conn) bufferedConn {
return bufferedConn{bufio.NewReader(c), c}
}
func (b bufferedConn) Peek(n int) ([]byte, error) {
return b.r.Peek(n)
}
func (b bufferedConn) Buffered() int {
return b.r.Buffered()
}
func (b bufferedConn) Read(p []byte) (int, error) {
/*
if b.rout != nil {
return b.rout.Read(p)
}
*/
return b.r.Read(p)
}
type chatMsg struct {
sender net.Conn
Message string `json:"message"`
ReceivedAt time.Time `json:"received_at"`
Channel string `json:"channel"`
User string `json:"user"`
}
// Poor-Man's container/ring (circular buffer)
type chatHist struct {
msgs []*chatMsg
i int // current index
c int // current count (number of elements)
}
// Multi-use
var config Conf
var virginConns chan net.Conn
var gotClientHello chan bufferedConn
var myChatHist chatHist
var broadcastMsg chan chatMsg
// Telnet
var wantsServerHello chan bufferedConn
var authTelnet chan telnetUser
var cleanTelnet chan telnetUser // intentionally blocking
// HTTP
var demuxHttpClient chan bufferedConn
var authReqs chan authReq
var valAuthReqs chan authReq
var delAuthReqs chan authReq
func usage() {
fmt.Fprintf(os.Stderr, "\nusage: go run chatserver*.go\n")
flag.PrintDefaults()
fmt.Println()
os.Exit(1)
}
// https://blog.questionable.services/article/generating-secure-random-numbers-crypto-rand/
func genAuthCode() (string, error) {
n := 12
b := make([]byte, n)
_, err := rand.Read(b)
// Note that err == nil only if we read len(b) bytes.
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func muxTcp(conn bufferedConn) {
// Wish List for protocol detection
// * PROXY protocol (and loop)
// * HTTP CONNECT (proxy) (and loop)
// * tls (and loop) https://github.com/polvi/sni
// * http/ws
// * irc
// * fallback to telnet
// At this piont we've already at least one byte via Peek()
// so the first packet is available in the buffer
// Note: Realistically no tls/http/irc client is going to send so few bytes
// (and no router is going to chunk so small)
// that it cannot reasonably detect the protocol in the first packet
// However, typical MTU is 1,500 and HTTP can have a 2k URL
// so expecting to find the "HTTP/1.1" in the Peek is not always reasonable
n := conn.Buffered()
firstMsg, err := conn.Peek(n)
if nil != err {
conn.Close()
return
}
var protocol string
// between A and z
if firstMsg[0] >= 65 && firstMsg[0] <= 122 {
i := bytes.Index(firstMsg, []byte(" /"))
if -1 != i {
protocol = "HTTP"
// very likely HTTP
j := bytes.IndexAny(firstMsg, "\r\n")
if -1 != j {
k := bytes.Index(bytes.ToLower(firstMsg[:j]), []byte("HTTP/1"))
if -1 != k {
// positively HTTP
}
}
}
} else if 0x16 /*22*/ == firstMsg[0] {
// Because I don't always remember off the top of my head what the first byte is
// http://blog.fourthbit.com/2014/12/23/traffic-analysis-of-an-ssl-slash-tls-session
// https://tlseminar.github.io/first-few-milliseconds/
// TODO I want to learn about ALPN
protocol = "TLS"
}
if "" == protocol {
// Throw away the first bytes
b := make([]byte, 4096)
conn.Read(b)
fmt.Fprintf(conn, "\n\nWelcome to Sample Chat! You're not an HTTP client, assuming Telnet.\nYou must authenticate via email to participate\n\nEmail: ")
wantsServerHello <- conn
return
} else if "HTTP" != protocol {
defer conn.Close()
fmt.Fprintf(conn, "\n\nNot yet supported. Try HTTP or Telnet\n\n")
return
}
demuxHttpClient <- conn
}
func testForHello(netConn net.Conn) {
ts := time.Now()
fmt.Fprintf(os.Stdout, "[New Connection] (%s) welcome %s\n", ts, netConn.RemoteAddr().String())
m := sync.Mutex{}
virgin := true
bufConn := newBufferedConn(netConn)
go func() {
// Cause first packet to be loaded into buffer
_, err := bufConn.Peek(1)
if nil != err {
panic(err)
}
m.Lock()
if virgin {
virgin = false
gotClientHello <- bufConn
} else {
wantsServerHello <- bufConn
}
m.Unlock()
}()
// Wait for a hello packet of some sort from the client
// (obviously this wouldn't work in extremely high latency situations)
time.Sleep(250 * 1000000)
// If we still haven't received data from the client
// assume that the client must be expecting a welcome from us
m.Lock()
if virgin {
virgin = false
// Defer as to not block and prolonging the mutex
// (not that those few cycles much matter...)
defer fmt.Fprintf(netConn,
"\n\nWelcome to Sample Chat! You appear to be using Telnet (http is also available on this port)."+
"\nYou must authenticate via email to participate\n\nEmail: ")
}
m.Unlock()
}
func sendAuthCode(cnf ConfMailer, to string) (string, error) {
code, err := genAuthCode()
if nil != err {
return "", err
}
// TODO use go text templates with HTML escaping
text := "Your authorization code:\n\n" + code
html := "Your authorization code:<br><br>" + code
// https://stackoverflow.com/questions/24493116/how-to-send-a-post-request-in-go
// https://stackoverflow.com/questions/16673766/basic-http-auth-in-go
client := http.Client{}
form := url.Values{}
form.Add("from", cnf.From)
form.Add("to", to)
form.Add("subject", "Sample Chat Auth Code: "+code)
form.Add("text", text)
form.Add("html", html)
req, err := http.NewRequest("POST", cnf.Url, strings.NewReader(form.Encode()))
if nil != err {
return "", err
}
//req.PostForm = form ??
req.Header.Add("User-Agent", "golang http.Client - Sample Chat App Authenticator")
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.SetBasicAuth("api", cnf.ApiKey)
resp, err := client.Do(req)
if nil != err {
return "", err
}
defer resp.Body.Close()
// Security XXX
// we trust mailgun implicitly and this is just a demo
// hence no DoS check on body size for now
body, err := ioutil.ReadAll(resp.Body)
if nil != err {
return "", err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 || "{" != string(body[0]) {
fmt.Fprintf(os.Stdout, "[Mailgun] Uh-oh...\n[Maigun] Baby Brent says: %s\n", body)
}
return code, nil
}
func main() {
flag.Usage = usage
port := flag.Uint("port", 0, "tcp telnet chat port")
confname := flag.String("conf", "./config.yml", "yaml config file")
flag.Parse()
confstr, err := ioutil.ReadFile(*confname)
fmt.Fprintf(os.Stdout, "-conf=%s\n", *confname)
if nil != err {
fmt.Fprintf(os.Stderr, "%s\nUsing defaults instead\n", err)
confstr = []byte("{\"port\":" + strconv.Itoa(int(*port)) + "}")
}
err = yaml.Unmarshal(confstr, &config)
if nil != err {
config = Conf{}
}
if "" == config.RootPath {
// TODO Maybe embed the public dir into the binary
// (and provide a flag with path for override - like gitea)
config.RootPath = "./public"
}
// The magical sorting hat
virginConns = make(chan net.Conn, 128)
// TCP & Authentication
telnetConns := make(map[string]telnetUser)
wantsServerHello = make(chan bufferedConn, 128)
authTelnet = make(chan telnetUser, 128)
// HTTP & Authentication
myAuthReqs := make(map[string]authReq)
authReqs = make(chan authReq, 128)
valAuthReqs = make(chan authReq, 128)
delAuthReqs = make(chan authReq, 128)
gotClientHello = make(chan bufferedConn, 128)
demuxHttpClient = make(chan bufferedConn, 128)
//myRooms["general"] = make(chan chatMsg, 128)
// Note: I had considered dynamically select on channels for rooms.
// https://stackoverflow.com/questions/19992334/how-to-listen-to-n-channels-dynamic-select-statement
// I don't think that's actually the best approach, but I just wanted to save the link
broadcastMsg = make(chan chatMsg, 128)
myChatHist.msgs = make([]*chatMsg, 128)
var addr string
if 0 != int(*port) {
addr = config.Addr + ":" + strconv.Itoa(int(*port))
} else {
addr = config.Addr + ":" + strconv.Itoa(int(config.Port))
}
// https://golang.org/pkg/net/#Conn
sock, err := net.Listen("tcp", addr)
if nil != err {
fmt.Fprintf(os.Stderr, "Couldn't bind to TCP socket %q: %s\n", addr, err)
os.Exit(2)
}
fmt.Println("Listening on", addr)
go func() {
for {
conn, err := sock.Accept()
if err != nil {
// Not sure what kind of error this could be or how it could happen.
// Could a connection abort or end before it's handled?
fmt.Fprintf(os.Stderr, "Error accepting connection:\n%s\n", err)
}
virginConns <- conn
}
}()
// Learning by Example
// https://github.com/emicklei/go-restful/blob/master/examples/restful-multi-containers.go
// https://github.com/emicklei/go-restful/blob/master/examples/restful-basic-authentication.go
// https://github.com/emicklei/go-restful/blob/master/examples/restful-serve-static.go
// https://github.com/emicklei/go-restful/blob/master/examples/restful-pre-post-filters.go
container := restful.NewContainer()
wsStatic := new(restful.WebService)
wsStatic.Path("/")
wsStatic.Route(wsStatic.GET("/").To(serveStatic))
wsStatic.Route(wsStatic.GET("/{subpath:*}").To(serveStatic))
container.Add(wsStatic)
cors := restful.CrossOriginResourceSharing{ExposeHeaders: []string{"Authorization"}, CookiesAllowed: false, Container: container}
wsApi := new(restful.WebService)
wsApi.Path("/api").Consumes(restful.MIME_JSON).Produces(restful.MIME_JSON).Filter(cors.Filter)
wsApi.Route(wsApi.GET("/hello").To(serveHello))
wsApi.Route(wsApi.POST("/sessions").To(requestAuth))
wsApi.Route(wsApi.POST("/sessions/{cid}").To(issueToken))
wsApi.Route(wsApi.GET("/rooms/{room}").Filter(requireToken).To(listMsgs))
wsApi.Route(wsApi.POST("/rooms/{room}").Filter(requireToken).To(postMsg))
container.Add(wsApi)
server := &http.Server{
Addr: addr,
Handler: container,
}
myHttpServer := newHttpServer(sock)
go func() {
server.Serve(myHttpServer)
}()
// Main event loop handling most access to shared data
for {
select {
case conn := <-virginConns:
// This is short lived
go testForHello(conn)
case u := <-authTelnet:
// allow to receive messages
// (and be counted among the users)
_, ok := telnetConns[u.email]
if ok {
// this is a blocking channel, and that's important
cleanTelnet <- telnetConns[u.email]
}
telnetConns[u.email] = u
// is chan chan the right way to handle this?
u.userCount <- len(telnetConns)
broadcastMsg <- chatMsg{
sender: nil,
Message: fmt.Sprintf("<%s> joined #general\r\n", u.email),
ReceivedAt: time.Now(),
Channel: "general",
User: "system",
}
case ar := <-authReqs:
myAuthReqs[ar.Cid] = ar
case ar := <-valAuthReqs:
// TODO In this case it's probably more conventional (and efficient) to
// use struct with a mutex and the authReqs map than a chan chan
av, ok := myAuthReqs[ar.Cid]
//ar.Chan <- nil // TODO
if ok {
ar.Chan <- av
} else {
// sending empty object so that I can still send a copy
// rather than a pointer above. Maybe not the right way
// to do this, but it works for now.
ar.Chan <- authReq{}
}
case ar := <-delAuthReqs:
delete(myAuthReqs, ar.Cid)
case bufConn := <-wantsServerHello:
go handleTelnetConn(bufConn)
case u := <-cleanTelnet:
close(u.newMsg)
// we can safely ignore this error, if any
u.bufConn.Close()
delete(telnetConns, u.email)
case bufConn := <-gotClientHello:
go muxTcp(bufConn)
case bufConn := <-demuxHttpClient:
// this will be Accept()ed immediately by the go-restful container
// NOTE: we don't store these HTTP connections for broadcast
// since we manage the session by HTTP Auth Bearer rather than TCP
myHttpServer.chans <- bufConn
case msg := <-broadcastMsg:
// copy comes in, pointer gets saved (and not GC'd, I hope)
myChatHist.msgs[myChatHist.i] = &msg
myChatHist.i += 1
if myChatHist.c < cap(myChatHist.msgs) {
myChatHist.c += 1
}
myChatHist.i %= len(myChatHist.msgs)
// print the system message (the "log")
t := msg.ReceivedAt
tf := "%d-%02d-%02d %02d:%02d:%02d (%s)"
var sender string
if nil != msg.sender {
sender = msg.sender.RemoteAddr().String()
} else {
sender = "system"
}
// Tangential thought:
// I wonder if we could use IP detection to get a Telnet client's tz
// ... could probably make time for this in the authentication loop
zone, _ := msg.ReceivedAt.Zone()
fmt.Fprintf(os.Stdout, tf+" [%s] (%s): %s\r\n",
t.Year(), t.Month(), t.Day(),
t.Hour(), t.Minute(), t.Second(), zone,
sender,
msg.User, msg.Message)
for _, u := range telnetConns {
// Don't echo back to the original client
if msg.sender == u.bufConn {
continue
}
msg := fmt.Sprintf(tf+" [%s]: %s", t.Year(), t.Month(), t.Day(), t.Hour(),
t.Minute(), t.Second(), zone, msg.User, msg.Message)
select {
case u.newMsg <- msg:
// all is well, client was ready to receive
default:
// Rate Limit: Reasonable poor man's DoS prevention (Part 2)
// This client's send channel buffer is full.
// It is consuming data too slowly. It may be malicious.
// In the case that it's experiencing network issues,
// well, these things happen when you're having network issues.
// It can reconnect.
cleanTelnet <- u
}
}
}
}
}