From c5732bbf1a531aa50e532e68e8e158608b1604e2 Mon Sep 17 00:00:00 2001 From: Mickael Remond Date: Wed, 6 Jan 2016 16:51:12 +0100 Subject: [PATCH] Initial working version of go XMPP library --- LICENSE | 2 +- xmpp/auth.go | 79 +++++++++++++++++++ xmpp/client.go | 116 ++++++++++++++++++++++++++++ xmpp/iq.go | 11 +++ xmpp/jid.go | 34 +++++++++ xmpp/message.go | 24 ++++++ xmpp/ns.go | 10 +++ xmpp/options.go | 12 +++ xmpp/packet.go | 13 ++++ xmpp/parser.go | 96 +++++++++++++++++++++++ xmpp/presence.go | 13 ++++ xmpp/session.go | 178 +++++++++++++++++++++++++++++++++++++++++++ xmpp/socket_proxy.go | 49 ++++++++++++ xmpp/starttls.go | 22 ++++++ xmpp/stream.go | 22 ++++++ xmpp_client.go | 47 ++++++++++++ 16 files changed, 727 insertions(+), 1 deletion(-) create mode 100644 xmpp/auth.go create mode 100644 xmpp/client.go create mode 100644 xmpp/iq.go create mode 100644 xmpp/jid.go create mode 100644 xmpp/message.go create mode 100644 xmpp/ns.go create mode 100644 xmpp/options.go create mode 100644 xmpp/packet.go create mode 100644 xmpp/parser.go create mode 100644 xmpp/presence.go create mode 100644 xmpp/session.go create mode 100644 xmpp/socket_proxy.go create mode 100644 xmpp/starttls.go create mode 100644 xmpp/stream.go create mode 100644 xmpp_client.go diff --git a/LICENSE b/LICENSE index d35547a..5ec274c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2015, Mickaël Rémond +Copyright (c) 2016, ProcessOne All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/xmpp/auth.go b/xmpp/auth.go new file mode 100644 index 0000000..711e077 --- /dev/null +++ b/xmpp/auth.go @@ -0,0 +1,79 @@ +package xmpp + +import ( + "encoding/base64" + "encoding/xml" + "errors" + "fmt" + "io" +) + +func authSASL(socket io.ReadWriter, decoder *xml.Decoder, f streamFeatures, user string, password string) (err error) { + // TODO: Implement other type of SASL Authentication + havePlain := false + for _, m := range f.Mechanisms.Mechanism { + if m == "PLAIN" { + havePlain = true + break + } + } + if !havePlain { + return errors.New(fmt.Sprintf("PLAIN authentication is not supported by server: %v", f.Mechanisms.Mechanism)) + } + + return authPlain(socket, decoder, user, password) +} + +// Plain authentication: send base64-encoded \x00 user \x00 password +func authPlain(socket io.ReadWriter, decoder *xml.Decoder, user string, password string) error { + raw := "\x00" + user + "\x00" + password + enc := make([]byte, base64.StdEncoding.EncodedLen(len(raw))) + base64.StdEncoding.Encode(enc, []byte(raw)) + fmt.Fprintf(socket, "%s", nsSASL, enc) + + // Next message should be either success or failure. + name, val, err := next(decoder) + if err != nil { + return err + } + + switch v := val.(type) { + case *saslSuccess: + case *saslFailure: + // v.Any is type of sub-element in failure, which gives a description of what failed. + return errors.New("auth failure: " + v.Any.Local) + default: + return errors.New("expected success or failure, got " + name.Local + " in " + name.Space) + } + return err +} + +// XMPP Packet Parsing +type saslMechanisms struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl mechanisms"` + Mechanism []string `xml:"mechanism"` +} + +type saslSuccess struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl success"` +} + +type saslFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-sasl failure"` + Any xml.Name // error reason is a subelement + +} + +type bindBind struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-bind bind"` + Resource string + Jid string +} + +// Session is obsolete in RFC 6121. +// Added for compliance with RFC 3121. +// Remove when ejabberd purely conforms to RFC 6121. +type sessionSession struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-session session"` + optional xml.Name // If it does exist, it mean we are not required to open session +} diff --git a/xmpp/client.go b/xmpp/client.go new file mode 100644 index 0000000..0b8dc68 --- /dev/null +++ b/xmpp/client.go @@ -0,0 +1,116 @@ +package xmpp + +import ( + "bytes" + "encoding/xml" + "errors" + "fmt" + "net" + "strings" +) + +type Client struct { + // Store user defined options + options Options + // Session gather data that can be accessed by users of this library + Session *Session + // TCP level connection / can be replace by a TLS session after starttls + conn net.Conn +} + +/* +Setting up the client / Checking the parameters +*/ + +// TODO: better options check +func NewClient(options Options) (c *Client, err error) { + // TODO: If option address is nil, use the Jid domain to compose the address + if options.Address, err = checkAddress(options.Address); err != nil { + return + } + + if options.Password == "" { + err = errors.New("missing password") + return + } + + c = new(Client) + c.options = options + + // Parse JID + if c.options.parsedJid, err = NewJid(c.options.Jid); err != nil { + return + } + + return +} + +func checkAddress(addr string) (string, error) { + var err error + hostport := strings.Split(addr, ":") + if len(hostport) > 2 { + err = errors.New("too many colons in xmpp server address") + return addr, err + } + + // Address is composed of two parts, we are good + if len(hostport) == 2 && hostport[1] != "" { + return addr, err + } + + // Port was not passed, we append XMPP default port: + return strings.Join([]string{hostport[0], "5222"}, ":"), err +} + +// NewClient creates a new connection to a host given as "hostname" or "hostname:port". +// If host is not specified, the DNS SRV should be used to find the host from the domainpart of the JID. +// Default the port to 5222. +func (c *Client) Connect() (*Session, error) { + var tcpconn net.Conn + var err error + if tcpconn, err = net.Dial("tcp", c.options.Address); err != nil { + return nil, err + } + + c.conn = tcpconn + if c.conn, c.Session, err = NewSession(c.conn, c.options); err != nil { + return c.Session, err + } + + // We're connected and can now receive and send messages. + //fmt.Fprintf(client.conn, "%s%s", "chat", "Online") + fmt.Fprintf(c.Session.socketProxy, "") + + return c.Session, err +} + +func (c *Client) recv(receiver chan<- interface{}) (err error) { + for { + _, val, err := next(c.Session.decoder) + if err != nil { + return err + } + receiver <- val + val = nil + } + panic("unreachable") +} + +// Channel allow client to receive / dispatch packets in for range loop +func (c *Client) Recv() <-chan interface{} { + ch := make(chan interface{}) + go c.recv(ch) + return ch +} + +// Send sends message text. +func (c *Client) Send(packet string) error { + fmt.Fprintf(c.Session.socketProxy, packet) + return nil +} + +func xmlEscape(s string) string { + var b bytes.Buffer + xml.Escape(&b, []byte(s)) + return b.String() +} diff --git a/xmpp/iq.go b/xmpp/iq.go new file mode 100644 index 0000000..2e97d8f --- /dev/null +++ b/xmpp/iq.go @@ -0,0 +1,11 @@ +package xmpp + +import "encoding/xml" + +type clientIQ struct { // info/query + XMLName xml.Name `xml:"jabber:client iq"` + Packet + Bind bindBind + // TODO We need to support detecting the IQ namespace / Query packet + // Error clientError +} diff --git a/xmpp/jid.go b/xmpp/jid.go new file mode 100644 index 0000000..6ff9364 --- /dev/null +++ b/xmpp/jid.go @@ -0,0 +1,34 @@ +package xmpp + +import ( + "errors" + "strings" +) + +type Jid struct { + username string + domain string + resource string +} + +func NewJid(sjid string) (jid *Jid, err error) { + s1 := strings.Split(sjid, "@") + if len(s1) != 2 { + err = errors.New("invalid JID: " + sjid) + return + } + jid = new(Jid) + jid.username = s1[0] + + s2 := strings.Split(s1[1], "/") + if len(s2) > 2 { + err = errors.New("invalid JID: " + sjid) + return + } + jid.domain = s2[0] + if len(s2) == 2 { + jid.resource = s2[1] + } + + return +} diff --git a/xmpp/message.go b/xmpp/message.go new file mode 100644 index 0000000..813d9c0 --- /dev/null +++ b/xmpp/message.go @@ -0,0 +1,24 @@ +package xmpp + +import ( + "encoding/xml" + "fmt" +) + +// XMPP Packet Parsing +type ClientMessage struct { + XMLName xml.Name `xml:"jabber:client message"` + Packet + Subject string `xml:"subject,omitempty"` + Body string `xml:"body,omitempty"` + Thread string `xml:"thread,omitempty"` +} + +// TODO: Func new message to create an empty message structure without the XML tag matching elements + +func (message *ClientMessage) XMPPFormat() string { + return fmt.Sprintf(""+ + "%s", + message.To, + xmlEscape(message.Body)) +} diff --git a/xmpp/ns.go b/xmpp/ns.go new file mode 100644 index 0000000..cf0f5fe --- /dev/null +++ b/xmpp/ns.go @@ -0,0 +1,10 @@ +package xmpp + +const ( + nsStream = "http://etherx.jabber.org/streams" + nsTLS = "urn:ietf:params:xml:ns:xmpp-tls" + nsSASL = "urn:ietf:params:xml:ns:xmpp-sasl" + nsBind = "urn:ietf:params:xml:ns:xmpp-bind" + nsSession = "urn:ietf:params:xml:ns:xmpp-session" + nsClient = "jabber:client" +) diff --git a/xmpp/options.go b/xmpp/options.go new file mode 100644 index 0000000..96bafe6 --- /dev/null +++ b/xmpp/options.go @@ -0,0 +1,12 @@ +package xmpp + +import "os" + +type Options struct { + Address string + Jid string + parsedJid *Jid // For easier manipulation + Password string + PacketLogger *os.File // Used for debugging + Lang string // TODO: should default to 'en' +} diff --git a/xmpp/packet.go b/xmpp/packet.go new file mode 100644 index 0000000..72098c1 --- /dev/null +++ b/xmpp/packet.go @@ -0,0 +1,13 @@ +package xmpp + +type Packet struct { + Id string `xml:"id,attr,omitempty"` + From string `xml:"from,attr,omitempty"` + To string `xml:"to,attr,omitempty"` + Type string `xml:"type,attr,omitempty"` + Lang string `xml:"lang,attr,omitempty"` +} + +type packetFormatter interface { + XMPPFormat() string +} diff --git a/xmpp/parser.go b/xmpp/parser.go new file mode 100644 index 0000000..139cf22 --- /dev/null +++ b/xmpp/parser.go @@ -0,0 +1,96 @@ +package xmpp + +import ( + "encoding/xml" + "errors" + "io" + "log" +) + +// Reads and checks the opening XMPP stream element. +// It returns a stream structure containing: +// - Host: You can check the host against the host you were expecting to connect to +// - Id: the Stream ID is a temporary shared secret used for some hash calculation. It is also used by ProcessOne +// reattach features (allowing to resume an existing stream at the point the connection was interrupted, without +// getting through the authentication process. +func initDecoder(p *xml.Decoder) (sessionID string, err error) { + for { + var t xml.Token + t, err = p.Token() + if err != nil { + return + } + + switch elem := t.(type) { + case xml.StartElement: + if elem.Name.Space != nsStream || elem.Name.Local != "stream" { + err = errors.New("xmpp: expected but got <" + elem.Name.Local + "> in " + elem.Name.Space) + return + } + + // Parse Stream attributes + for _, attrs := range elem.Attr { + switch attrs.Name.Local { + case "id": + sessionID = attrs.Value + } + } + return + } + } + panic("unreachable") +} + +// Scan XML token stream to find next StartElement. +func nextStart(p *xml.Decoder) (xml.StartElement, error) { + for { + t, err := p.Token() + if err == io.EOF { + return xml.StartElement{}, nil + } + if err != nil { + log.Fatal("token:", err) + } + switch t := t.(type) { + case xml.StartElement: + return t, nil + } + } + panic("unreachable") +} + +// Scan XML token stream for next element and save into val. +// If val == nil, allocate new element based on proto map. +// Either way, return val. +func next(p *xml.Decoder) (xml.Name, interface{}, error) { + // Read start element to find out what type we want. + se, err := nextStart(p) + if err != nil { + return xml.Name{}, nil, err + } + + // Put it in an interface and allocate one. + var nv interface{} + switch se.Name.Space + " " + se.Name.Local { + // TODO: general case = Parse IQ / presence / message => split SASL case + case nsSASL + " success": + nv = &saslSuccess{} + case nsSASL + " failure": + nv = &saslFailure{} + case nsClient + " message": + nv = &ClientMessage{} + case nsClient + " presence": + nv = &clientPresence{} + case nsClient + " iq": + nv = &clientIQ{} + default: + return xml.Name{}, nil, errors.New("unexpected XMPP message " + + se.Name.Space + " <" + se.Name.Local + "/>") + } + + // Decode element into pointer storage + if err = p.DecodeElement(nv, &se); err != nil { + return xml.Name{}, nil, err + } + return se.Name, nv, err +} diff --git a/xmpp/presence.go b/xmpp/presence.go new file mode 100644 index 0000000..3b071ab --- /dev/null +++ b/xmpp/presence.go @@ -0,0 +1,13 @@ +package xmpp + +import "encoding/xml" + +// XMPP Packet Parsing +type clientPresence struct { + XMLName xml.Name `xml:"jabber:client presence"` + Packet + Show string `xml:"show,attr,omitempty"` // away, chat, dnd, xa + Status string `xml:"status,attr,omitempty"` + Priority string `xml:"priority,attr,omitempty"` + //Error *clientError +} diff --git a/xmpp/session.go b/xmpp/session.go new file mode 100644 index 0000000..a133e6c --- /dev/null +++ b/xmpp/session.go @@ -0,0 +1,178 @@ +package xmpp + +import ( + "crypto/tls" + "encoding/xml" + "errors" + "fmt" + "io" + "net" +) + +const xmppStreamOpen = "" + +type Session struct { + // Session info + BindJid string // Jabber ID as provided by XMPP server + StreamId string + Features streamFeatures + TlsEnabled bool + lastPacketId int + + // Session interface + In chan interface{} + Out chan interface{} + + // read / write + socketProxy io.ReadWriter + decoder *xml.Decoder + + // error management + err error +} + +func NewSession(conn net.Conn, o Options) (net.Conn, *Session, error) { + s := new(Session) + s.init(conn, o) + + // starttls + var tlsConn net.Conn + tlsConn = s.startTlsIfSupported(conn, o.parsedJid.domain) + s.reset(conn, tlsConn, o) + + // auth + s.auth(o) + s.reset(tlsConn, tlsConn, o) + + // bind resource and 'start' XMPP session + s.bind(o) + s.rfc3921Session(o) + + return tlsConn, s, s.err +} + +func (s *Session) PacketId() string { + s.lastPacketId++ + return fmt.Sprintf("%x", s.lastPacketId) +} + +func (s *Session) init(conn net.Conn, o Options) { + s.setProxy(nil, conn, o) + s.Features = s.open(o.parsedJid.domain) +} + +func (s *Session) reset(conn net.Conn, newConn net.Conn, o Options) { + if s.err != nil { + return + } + + s.setProxy(conn, newConn, o) + s.Features = s.open(o.parsedJid.domain) +} + +// TODO: setProxyLogger ? better name ? This is not a TCP / HTTP proxy +func (s *Session) setProxy(conn net.Conn, newConn net.Conn, o Options) { + if newConn != conn { + s.socketProxy = newSocketProxy(newConn, o.PacketLogger) + } + s.decoder = xml.NewDecoder(s.socketProxy) +} + +func (s *Session) open(domain string) (f streamFeatures) { + // Send stream open tag + if _, s.err = fmt.Fprintf(s.socketProxy, xmppStreamOpen, domain, nsClient, nsStream); s.err != nil { + return + } + + // Set xml decoder and extract streamID from reply + s.StreamId, s.err = initDecoder(s.decoder) // TODO refactor / rename + if s.err != nil { + return + } + + // extract stream features + if s.err = s.decoder.Decode(&f); s.err != nil { + s.err = errors.New("stream open decode features: " + s.err.Error()) + } + return +} + +func (s *Session) startTlsIfSupported(conn net.Conn, domain string) net.Conn { + if s.err != nil { + return conn + } + + if s.Features.StartTLS.XMLName.Space+" "+s.Features.StartTLS.XMLName.Local == nsTLS+" starttls" { + fmt.Fprintf(s.socketProxy, "") + + var k tlsProceed + if s.err = s.decoder.DecodeElement(&k, nil); s.err != nil { + s.err = errors.New("expecting starttls proceed: " + s.err.Error()) + return conn + } + s.TlsEnabled = true + + // TODO: add option to accept all TLS certificates: insecureSkipTlsVerify (DefaultTlsConfig.InsecureSkipVerify) + DefaultTlsConfig.ServerName = domain + var tlsConn *tls.Conn = tls.Client(conn, &DefaultTlsConfig) + // We convert existing connection to TLS + if s.err = tlsConn.Handshake(); s.err != nil { + return tlsConn + } + + // We check that cert matches hostname + s.err = tlsConn.VerifyHostname(domain) + return tlsConn + } + + // starttls is not supported => we do not upgrade the connection: + return conn +} + +func (s *Session) auth(o Options) { + if s.err != nil { + return + } + + s.err = authSASL(s.socketProxy, s.decoder, s.Features, o.parsedJid.username, o.Password) +} + +func (s *Session) bind(o Options) { + if s.err != nil { + return + } + + // Send IQ message asking to bind to the local user name. + var resource = o.parsedJid.resource + if resource != "" { + fmt.Fprintf(s.socketProxy, "%s", + s.PacketId(), nsBind, resource) + } else { + fmt.Fprintf(s.socketProxy, "", s.PacketId(), nsBind) + } + + var iq clientIQ + if s.err = s.decoder.Decode(&iq); s.err != nil || &iq.Bind == nil { + s.err = errors.New("iq bind result missing: " + s.err.Error()) + return + } + s.BindJid = iq.Bind.Jid // our local id (with possibly randomly generated resource + return +} + +// TODO: remove when ejabberd is fixed: https://github.com/processone/ejabberd/issues/869 +// After the bind, if the session is required (as per old RFC 3921), we send the session open iq +func (s *Session) rfc3921Session(o Options) { + if s.err != nil { + return + } + + var iq clientIQ + + // TODO: Do no send unconditionally, check if session is optional and omit it + fmt.Fprintf(s.socketProxy, "", s.PacketId(), nsSession) + if s.err = s.decoder.Decode(&iq); s.err != nil { + s.err = errors.New("expecting iq result after session open: " + s.err.Error()) + return + } +} diff --git a/xmpp/socket_proxy.go b/xmpp/socket_proxy.go new file mode 100644 index 0000000..59a3ee3 --- /dev/null +++ b/xmpp/socket_proxy.go @@ -0,0 +1,49 @@ +package xmpp + +import ( + "io" + "os" +) + +// Mediated Read / Write on socket +// Used if logFile from Options is not nil +type socketProxy struct { + socket io.ReadWriter // Actual connection + logFile *os.File +} + +func newSocketProxy(conn io.ReadWriter, logFile *os.File) io.ReadWriter { + if logFile == nil { + return conn + } else { + return &socketProxy{conn, logFile} + } +} + +func (pl *socketProxy) Read(p []byte) (n int, err error) { + n, err = pl.socket.Read(p) + if n > 0 { + pl.logFile.Write([]byte("RECV:\n")) // Prefix + if n, err := pl.logFile.Write(p[:n]); err != nil { + return n, err + } + pl.logFile.Write([]byte("\n\n")) // Separator + } + return +} + +func (pl *socketProxy) Write(p []byte) (n int, err error) { + pl.logFile.Write([]byte("SEND:\n")) // Prefix + for _, w := range []io.Writer{pl.socket, pl.logFile} { + n, err = w.Write(p) + if err != nil { + return + } + if n != len(p) { + err = io.ErrShortWrite + return + } + } + pl.logFile.Write([]byte("\n\n")) // Separator + return len(p), nil +} diff --git a/xmpp/starttls.go b/xmpp/starttls.go new file mode 100644 index 0000000..fef56ee --- /dev/null +++ b/xmpp/starttls.go @@ -0,0 +1,22 @@ +package xmpp + +import ( + "crypto/tls" + "encoding/xml" +) + +var DefaultTlsConfig tls.Config + +// XMPP Packet Parsing +type tlsStartTLS struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls starttls"` + Required bool +} + +type tlsProceed struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls proceed"` +} + +type tlsFailure struct { + XMLName xml.Name `xml:"urn:ietf:params:xml:ns:xmpp-tls failure"` +} diff --git a/xmpp/stream.go b/xmpp/stream.go new file mode 100644 index 0000000..b83ccc8 --- /dev/null +++ b/xmpp/stream.go @@ -0,0 +1,22 @@ +package xmpp + +import "encoding/xml" + +// XMPP Packet Parsing +type streamFeatures struct { + XMLName xml.Name `xml:"http://etherx.jabber.org/streams features"` + StartTLS tlsStartTLS + Caps Caps + Mechanisms saslMechanisms + Bind bindBind + Session sessionSession + Any []xml.Name `xml:",any"` +} + +type Caps struct { + XMLName xml.Name `xml:"http://jabber.org/protocol/caps c"` + Hash string `xml:"hash,attr"` + Node string `xml:"node,attr"` + Ver string `xml:"ver,attr"` + Ext string `xml:"ext,attr,omitempty"` +} diff --git a/xmpp_client.go b/xmpp_client.go new file mode 100644 index 0000000..5a10de2 --- /dev/null +++ b/xmpp_client.go @@ -0,0 +1,47 @@ +/* +xmpp_client is a demo client that connect on an XMPP server and echo message received back to original sender. +*/ + +package main + +import ( + "fmt" + "log" + "os" + + "github.com/mremond/gox/xmpp" +) + +func main() { + options := xmpp.Options{Address: "localhost:5222", Jid: "test@localhost", Password: "test", PacketLogger: os.Stdout} + + var client *xmpp.Client + var err error + if client, err = xmpp.NewClient(options); err != nil { + log.Fatal("Error: ", err) + } + + var session *xmpp.Session + if session, err = client.Connect(); err != nil { + log.Fatal("Error: ", err) + } + + fmt.Println("Stream opened, we have streamID = ", session.StreamId) + + // Iterator to receive packets coming from our XMPP connection + for packet := range client.Recv() { + switch packet := packet.(type) { + case *xmpp.ClientMessage: + fmt.Fprintf(os.Stdout, "Body = %s - from = %s\n", packet.Body, packet.From) + reply := xmpp.ClientMessage{Packet: xmpp.Packet{To: packet.From}, Body: packet.Body} + client.Send(reply.XMPPFormat()) + default: + fmt.Fprintf(os.Stdout, "Ignoring packet: %T\n", packet) + } + } +} + +// TODO create default command line client to send message or to send an arbitrary XMPP sequence from a file, +// (using templates ?) + +// TODO: autoreconnect when connection is lost