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