Initial working version of go XMPP library

This commit is contained in:
Mickael Remond 2016-01-06 16:51:12 +01:00
parent f237b861bb
commit c5732bbf1a
16 changed files with 727 additions and 1 deletions

View file

@ -1,4 +1,4 @@
Copyright (c) 2015, Mickaël Rémond Copyright (c) 2016, ProcessOne
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

79
xmpp/auth.go Normal file
View file

@ -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, "<auth xmlns='%s' mechanism='PLAIN'>%s</auth>", 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
}

116
xmpp/client.go Normal file
View file

@ -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, "<presence xml:lang='en'><show>%s</show><status>%s</status></presence>", "chat", "Online")
fmt.Fprintf(c.Session.socketProxy, "<presence/>")
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()
}

11
xmpp/iq.go Normal file
View file

@ -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
}

34
xmpp/jid.go Normal file
View file

@ -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
}

24
xmpp/message.go Normal file
View file

@ -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("<message to='%s' type='chat' xml:lang='en'>"+
"<body>%s</body></message>",
message.To,
xmlEscape(message.Body))
}

10
xmpp/ns.go Normal file
View file

@ -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"
)

12
xmpp/options.go Normal file
View file

@ -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'
}

13
xmpp/packet.go Normal file
View file

@ -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
}

96
xmpp/parser.go Normal file
View file

@ -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 <stream> 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
}

13
xmpp/presence.go Normal file
View file

@ -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
}

178
xmpp/session.go Normal file
View file

@ -0,0 +1,178 @@
package xmpp
import (
"crypto/tls"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
)
const xmppStreamOpen = "<?xml version='1.0'?><stream:stream to='%s' xmlns='%s' xmlns:stream='%s' version='1.0'>"
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, "<starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/>")
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, "<iq type='set' id='%s'><bind xmlns='%s'><resource>%s</resource></bind></iq>",
s.PacketId(), nsBind, resource)
} else {
fmt.Fprintf(s.socketProxy, "<iq type='set' id='%s'><bind xmlns='%s'/></iq>", 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, "<iq type='set' id='%s'><session xmlns='%s'/></iq>", 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
}
}

49
xmpp/socket_proxy.go Normal file
View file

@ -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
}

22
xmpp/starttls.go Normal file
View file

@ -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"`
}

22
xmpp/stream.go Normal file
View file

@ -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"`
}

47
xmpp_client.go Normal file
View file

@ -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