481 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			481 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
| 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"
 | |
| 	"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), nil, 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
 | |
| 
 | |
| // 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 {
 | |
| 		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[bufferedConn]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)
 | |
| 			telnetConns[u.bufConn] = 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.bufConn)
 | |
| 		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
 | |
| 				}
 | |
| 			}
 | |
| 		}
 | |
| 	}
 | |
| }
 |