From dd69e1b4329067b9c91a1b038c888adb450b243e Mon Sep 17 00:00:00 2001 From: Christine Dodrill Date: Sat, 30 Nov 2019 16:07:20 -0500 Subject: [PATCH] it works --- agent/agent.go | 77 ++++++++++++++++ cmd/testserver/main.go | 32 +++++++ go.mod | 2 + go.sum | 2 + iconia.confyg | 4 +- main.go | 91 ++++++++++++++++++- server.go | 200 +++++++++++++++++++++++++++++++++++++++-- 7 files changed, 398 insertions(+), 10 deletions(-) create mode 100644 agent/agent.go create mode 100644 cmd/testserver/main.go diff --git a/agent/agent.go b/agent/agent.go new file mode 100644 index 0000000..dd2fcdd --- /dev/null +++ b/agent/agent.go @@ -0,0 +1,77 @@ +package agent + +import ( + "context" + "crypto/tls" + "fmt" + "log" + "net/http" + "net/url" + "os" + "time" + + "github.com/hashicorp/yamux" +) + +func Handle(ctx context.Context, agentURL, agentToken string, h http.Handler) error { + u, err := url.Parse(agentURL) + if err != nil { + return fmt.Errorf("error parsing URL: %w", err) + } + + if u.Scheme != "iconia" { + return fmt.Errorf("wanted scheme %s, got: %s", "iconia", u.Scheme) + } + + if u.Path == "" { + return fmt.Errorf("put the domain you want to forward in the path") + } + + tc := &tls.Config{ + ServerName: u.Path[1:], + InsecureSkipVerify: true, // TODO(Cadey): FIX THIS OMG + NextProtos: []string{agentToken}, + } + + log.Println("dialing", u.Host) + + conn, err := tls.Dial("tcp", u.Host, tc) + if err != nil { + return fmt.Errorf("error dialing remote host %s: %w", u.Host, err) + } + defer conn.Close() + + log.Println("connection established") + + sesh, err := yamux.Client(conn, &yamux.Config{ + AcceptBacklog: 1, + EnableKeepAlive: true, + KeepAliveInterval: time.Minute, + ConnectionWriteTimeout: 100 * time.Millisecond, + MaxStreamWindowSize: 262144 * 16, + Logger: log.New(os.Stderr, u.Path+"(yamux): ", log.LstdFlags), + }) + if err != nil { + return fmt.Errorf("error connecting to iconia: %w", err) + } + defer sesh.Close() + + s := &http.Server{ + Handler: h, + } + + log.Println("listening for traffic from iconia") + + err = s.Serve(sesh) + if err != nil { + return fmt.Errorf("error serving http: %w", err) + } + + go func() { + <-ctx.Done() + s.Shutdown(context.Background()) + sesh.GoAway() + }() + + return nil +} diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 0000000..ddc39d4 --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,32 @@ +package main + +import ( + "context" + "expvar" + "flag" + "log" + "net/http" + + "tulpa.dev/cadey/iconia/agent" +) + +var ( + agentURL = flag.String("agent-url", "iconia://127.0.0.1:3045/test.local.cetacean.club", "url of iconia server") + agentToken = flag.String("agent-token", "95FD1C09-47E0-438E-B1AA-40CF41E1CD01", "token to use for iconia") +) + +func main() { + flag.Parse() + + hitCounter := expvar.NewInt("hits") + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Hello world!", http.StatusOK) + hitCounter.Add(1) + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + log.Fatal(agent.Handle(ctx, *agentURL, *agentToken, http.DefaultServeMux)) +} diff --git a/go.mod b/go.mod index f7e4f16..0644a8f 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,12 @@ require ( github.com/facebookgo/flagenv v0.0.0-20160425205200-fcd59fca7456 // indirect github.com/golang/protobuf v1.3.2 github.com/hashicorp/yamux v0.0.0-20190923154419-df201c70410d + github.com/kr/pretty v0.1.0 github.com/smartystreets/goconvey v1.6.4 // indirect github.com/twitchtv/twirp v5.8.0+incompatible go.chromium.org/luci v0.0.0-20191128012655-94aa8d72b78e golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392 within.website/confyg v0.4.0 + within.website/ln v0.7.0 within.website/x v1.2.0 ) diff --git a/go.sum b/go.sum index b18f3c1..243bbea 100644 --- a/go.sum +++ b/go.sum @@ -57,8 +57,10 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/reedsolomon v1.9.2/go.mod h1:CwCi+NUr9pqSVktrkN+Ondf06rkhYZ/pcNv7fu+8Un4= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= diff --git a/iconia.confyg b/iconia.confyg index 7608673..d936656 100644 --- a/iconia.confyg +++ b/iconia.confyg @@ -1,3 +1,3 @@ host-token ( - test=95FD1C09-47E0-438E-B1AA-40CF41E1CD01 -) \ No newline at end of file + test.local.cetacean.club=95FD1C09-47E0-438E-B1AA-40CF41E1CD01 +) diff --git a/main.go b/main.go index c6002f8..38f1e92 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,23 @@ package main import ( + "context" + "crypto/tls" "flag" "log" "net/http" + "os" + "os/signal" "sync" + "syscall" + "time" "github.com/facebookarchive/flagenv" "github.com/hashicorp/yamux" "go.chromium.org/luci/common/flag/stringmapflag" "golang.org/x/crypto/acme/autocert" "within.website/confyg/flagconfyg" + "within.website/ln" "within.website/x/localca" ) @@ -51,16 +58,96 @@ func main() { DomainSuffix: *domainSuffix, } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ctx = ln.WithF(ctx, cfg.F()) + + certManager, err := localca.New(*certFile, *keyFile, *domainSuffix, autocert.DirCache(*certFolder)) + if err != nil { + ln.FatalErr(ctx, err) + } + + httpsTc := &tls.Config{ + GetCertificate: certManager.GetCertificate, + } + + httpsListener, err := tls.Listen("tcp", ":"+*httpsPort, httpsTc) + if err != nil { + ln.FatalErr(ctx, err) + } + s := &Server{ Config: cfg, clients: map[string][]*yamux.Session{}, clientsLock: &sync.RWMutex{}, - certManager: localca.New(*certFile, *keyFile, *domainSuffix, autocert.DirCache(*certFolder)), + tokenInfo: map[string]string(*hostsToTokens), + tokensLock: &sync.Mutex{}, + + certManager: certManager, + + tlsListener: httpsListener, plainServer: &http.Server{ - Addr: ":" + *httpPort, + Addr: ":" + *httpPort, + Handler: http.HandlerFunc(insecureRedirect), + }, + + statusServer: &http.Server{ + Addr: ":" + *statusPort, + Handler: http.DefaultServeMux, }, } + _ = s + + yamuxTc := &tls.Config{ + GetCertificate: certManager.GetCertificate, + GetConfigForClient: s.handleYamuxClientHello, + } + + yamuxListener, err := tls.Listen("tcp", ":"+*yamuxPort, yamuxTc) + if err != nil { + ln.FatalErr(ctx, err) + } + s.yamuxListener = yamuxListener + + ln.Log(ctx, ln.Info("now listening for traffic")) + + go func() { ln.FatalErr(ctx, s.plainServer.ListenAndServe()) }() + go func() { ln.FatalErr(ctx, s.statusServer.ListenAndServe()) }() + go func() { ln.FatalErr(ctx, s.tlsForward(httpsListener)) }() + go func() { ln.FatalErr(ctx, s.yamuxHandler(yamuxListener)) }() + + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + <-sigs + ln.Log(ctx, ln.Info("got SIGINT/SIGTERM, dying")) + defer cancel() + + s.yamuxListener.Close() + s.tlsListener.Close() + s.plainServer.Shutdown(ctx) + s.statusServer.Shutdown(ctx) + s.goAwayClients() + + time.Sleep(4 * time.Minute) +} + +// insecureRedirect redirects a client to https if they connect over plain HTTP. +func insecureRedirect(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPatch, http.MethodPut, http.MethodPost: + http.Error(w, "use https", http.StatusNotAcceptable) + ln.Log(r.Context(), ln.Action("cannot redirect (wrong method)"), ln.F{"remote": r.RemoteAddr, "host": r.Host, "path": r.URL.Path}) + return + } + + r.URL.Host = r.Host + r.URL.Scheme = "https" + + ln.Log(r.Context(), ln.Action("redirecting insecure HTTP to HTTPS"), ln.F{"remote": r.RemoteAddr, "host": r.Host, "path": r.URL.Path}) + + http.Redirect(w, r, r.URL.String(), http.StatusPermanentRedirect) } diff --git a/server.go b/server.go index 2610e3b..114d938 100644 --- a/server.go +++ b/server.go @@ -1,23 +1,42 @@ package main import ( + "bytes" + "context" "crypto/tls" "fmt" + "io" + "io/ioutil" + "log" + "math/rand" "net" "net/http" - "strings" + "os" "sync" + "time" "github.com/hashicorp/yamux" + "within.website/ln" "within.website/x/localca" ) -// Config uration for the server +// Config uration for the server. type Config struct { HTTPPort, HTTPSPort, YamuxPort, StatusPort, DomainSuffix string } -// Server is the iconia gateway server +// F ields for logging. +func (c Config) F() ln.F { + return ln.F{ + "http-port": c.HTTPPort, + "https-port": c.HTTPSPort, + "yamux-port": c.YamuxPort, + "status-port": c.StatusPort, + "domain-suffix": c.DomainSuffix, + } +} + +// Server is the iconia gateway server. type Server struct { Config @@ -33,13 +52,24 @@ type Server struct { tokensLock *sync.Mutex } +func (s *Server) goAwayClients() { + s.clientsLock.Lock() + defer s.clientsLock.Unlock() + + for _, set := range s.clients { + for _, sesh := range set { + sesh.GoAway() + } + } +} + func (s *Server) handleYamuxClientHello(chi *tls.ClientHelloInfo) (*tls.Config, error) { var found bool s.tokensLock.Lock() - var token = s.tokenInfo[strings.Split(chi.ServerName, s.Config.DomainSuffix)[0]] + var token = s.tokenInfo[chi.ServerName] s.tokensLock.Unlock() - for _, proto := range chi.NextProtos { + for _, proto := range chi.SupportedProtos { if proto == token { found = true break @@ -50,5 +80,163 @@ func (s *Server) handleYamuxClientHello(chi *tls.ClientHelloInfo) (*tls.Config, return nil, fmt.Errorf("unknown token for domain %s", chi.ServerName) } - return nil, nil + tc := &tls.Config{ + GetCertificate: s.certManager.GetCertificate, + NextProtos: []string{token}, + ServerName: chi.ServerName, + } + + return tc, nil +} + +func gen502Page(host, why string) *http.Response { + template := `${WHY}

${WHY}

Please ensure a backend is running for ${HOST}.

` + + resbody := []byte(os.Expand(template, func(in string) string { + switch in { + case "HOST": + return host + case "WHY": + return why + } + + return "" + })) + reshdr := http.Header{} + reshdr.Set("Content-Type", "text/html; charset=utf-8") + + resp := &http.Response{ + Status: fmt.Sprintf("%d Bad Gateway", http.StatusBadGateway), + StatusCode: http.StatusBadGateway, + Body: ioutil.NopCloser(bytes.NewBuffer(resbody)), + + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: reshdr, + ContentLength: int64(len(resbody)), + Close: true, + Request: nil, + } + + return resp +} + +func (s *Server) yamuxHandler(l net.Listener) error { + for { + c, err := l.Accept() + if err != nil { + return err + } + + go s.handleYamuxClient(c) + } + + panic("unexpected state") +} + +func (s *Server) handleYamuxClient(c net.Conn) { + tlsConn, ok := c.(*tls.Conn) + if !ok { + panic("no, this should really be impossible") + } + tlsConn.Handshake() + + sName := tlsConn.ConnectionState().ServerName + + ctx := context.Background() + ctx = ln.WithF(ctx, ln.F{ + "domain-name": sName, + "remote-host": c.RemoteAddr().String(), + }) + + sesh, err := yamux.Server(c, &yamux.Config{ + AcceptBacklog: 1, + EnableKeepAlive: true, + KeepAliveInterval: time.Minute, + ConnectionWriteTimeout: 100 * time.Millisecond, + MaxStreamWindowSize: 262144 * 16, + Logger: log.New(os.Stderr, sName+": ", log.LstdFlags), + }) + if err != nil { + ln.Error(ctx, err) + c.Close() + return + } + + s.clientsLock.Lock() + s.clients[sName] = append(s.clients[sName], sesh) + i := len(s.clients[sName]) - 1 + s.clientsLock.Unlock() + + ln.Log(ctx, ln.Info("agent registered")) + + go func() { + <-sesh.CloseChan() + ln.Log(ctx, ln.Info("client closed")) + + s.clientsLock.Lock() + s.clients[sName] = append(s.clients[sName][:i], s.clients[sName][i+1:]...) + s.clientsLock.Unlock() + }() +} + +func (s *Server) tlsForward(l net.Listener) error { + for { + c, err := l.Accept() + if err != nil { + return fmt.Errorf("error accepting connection: %w", err) + } + + go s.handleTLSClient(c) + } + + panic("unexpected state") +} + +func (s *Server) handleTLSClient(c net.Conn) { + tlsConn, ok := c.(*tls.Conn) + if !ok { + gen502Page("unknown", "this should be impossible").Write(c) + c.Close() + return + } + tlsConn.Handshake() + + sName := tlsConn.ConnectionState().ServerName + + s.clientsLock.RLock() + set, ok := s.clients[sName] + s.clientsLock.RUnlock() + + if !ok || len(set) == 0 { + gen502Page(sName, "no backends connected").Write(c) + c.Close() + return + } + + var ( + sesh *yamux.Session + stream *yamux.Stream + count int + err error + ) + +retry: + sesh = set[rand.Intn(len(set))] + + stream, err = sesh.OpenStream() + if err != nil { + if count > 3 { + gen502Page(sName, "no working session").Write(c) + c.Close() + return + } + + count++ + goto retry + } + + go io.Copy(c, stream) + io.Copy(stream, c) }