diff --git a/.gitignore b/.gitignore index 1d478b6..f5c0c26 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ server/manifest.go # VS Code .vscode + +# Claude Code local memory +.claude.md diff --git a/README.md b/README.md index 835a872..9e24249 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,17 @@ -# Plugin Starter Template +# Mattermost XMPP Bridge Plugin [![Build Status](https://github.com/mattermost/mattermost-plugin-bridge-xmpp/actions/workflows/ci.yml/badge.svg)](https://github.com/mattermost/mattermost-plugin-bridge-xmpp/actions/workflows/ci.yml) [![E2E Status](https://github.com/mattermost/mattermost-plugin-bridge-xmpp/actions/workflows/e2e.yml/badge.svg)](https://github.com/mattermost/mattermost-plugin-bridge-xmpp/actions/workflows/e2e.yml) -This plugin serves as a starting point for writing a Mattermost plugin. Feel free to base your own plugin off this repository. +This plugin provides bidirectional message synchronization between Mattermost and XMPP servers, enabling seamless communication across both platforms. + +## Features + +- Bidirectional message synchronization (Mattermost ↔ XMPP) +- XMPP Multi-User Chat (MUC) support +- Configurable username prefixes for XMPP users in Mattermost +- Ghost user management for cross-platform user representation +- Comprehensive XMPP client with SASL Plain authentication To learn more about plugins, see [our plugin documentation](https://developers.mattermost.com/extend/plugins/). @@ -122,6 +130,53 @@ export MM_ADMIN_TOKEN=j44acwd8obn78cdcx7koid4jkr make watch ``` +## XMPP Client Doctor + +The plugin includes a diagnostic tool to test XMPP client connectivity: + +```bash +go run cmd/xmpp-client-doctor/main.go [flags] +``` + +### Usage + +Test connectivity with default development server settings: +```bash +go run cmd/xmpp-client-doctor/main.go +``` + +Test with custom XMPP server: +```bash +go run cmd/xmpp-client-doctor/main.go \ + -server="xmpp.example.com:5222" \ + -username="myuser@example.com" \ + -password="mypassword" \ + -resource="test" +``` + +### Flags + +- `-server`: XMPP server address (default: `localhost:5222`) +- `-username`: XMPP username/JID (default: `testuser@localhost`) +- `-password`: XMPP password (default: `testpass`) +- `-resource`: XMPP resource (default: `doctor`) +- `-verbose`: Enable verbose logging (default: `true`) +- `-insecure-skip-verify`: Skip TLS certificate verification for development (default: `true`) + +### Development Server + +The tool defaults are configured for the development XMPP server in `./sidecar/`. To start the development server: + +```bash +cd sidecar +docker-compose up -d +``` + +The development server runs Openfire XMPP server with: +- XMPP client connections on port 5222 +- Admin console on http://localhost:9090 +- Default test credentials: `testuser@localhost` / `testpass` + ### Deploying with credentials Alternatively, you can authenticate with the server's API with credentials: diff --git a/build/custom_xmpp_server.mk b/build/custom_xmpp_server.mk new file mode 100644 index 0000000..f53e7ec --- /dev/null +++ b/build/custom_xmpp_server.mk @@ -0,0 +1,72 @@ +# XMPP Development Server Management +# This file provides targets for managing the development XMPP server + +# XMPP server configuration +XMPP_COMPOSE_FILE := sidecar/docker-compose.yml +XMPP_ADMIN_URL := http://localhost:9090 +XMPP_SERVER_HOST := localhost +XMPP_SERVER_PORT := 5222 + +.PHONY: devserver_start devserver_stop devserver_status devserver_logs devserver_clean devserver_doctor devserver_help + +## Start the XMPP development server +devserver_start: + @echo "Starting XMPP development server..." + @if [ ! -f "$(XMPP_COMPOSE_FILE)" ]; then \ + echo "Error: $(XMPP_COMPOSE_FILE) not found"; \ + exit 1; \ + fi + @cd sidecar && docker compose up -d + @echo "✅ XMPP server started successfully" + @echo "Admin console: $(XMPP_ADMIN_URL)" + @echo "XMPP server: $(XMPP_SERVER_HOST):$(XMPP_SERVER_PORT)" + @echo "" + @echo "See sidecar/README.md for setup instructions" + +## Stop the XMPP development server +devserver_stop: + @echo "Stopping XMPP development server..." + @cd sidecar && docker compose down + @echo "✅ XMPP server stopped" + +## Check status of XMPP development server +devserver_status: + @echo "XMPP Development Server Status:" + @cd sidecar && docker compose ps + +## View XMPP server logs +devserver_logs: + @echo "XMPP Server Logs (press Ctrl+C to exit):" + @cd sidecar && docker compose logs -f openfire + +## Clean up XMPP server data and containers +devserver_clean: + @echo "⚠️ This will remove all XMPP server data and containers!" + @read -p "Are you sure? (y/N): " confirm && [ "$$confirm" = "y" ] || exit 1 + @echo "Cleaning up XMPP development server..." + @cd sidecar && docker compose down -v --remove-orphans + @docker system prune -f --filter label=com.docker.compose.project=sidecar + @echo "✅ XMPP server cleaned up" + +## Test XMPP client connectivity with doctor tool (insecure for development) +devserver_doctor: + @echo "Testing XMPP client connectivity..." + @go run cmd/xmpp-client-doctor/main.go -insecure-skip-verify=true -verbose=true + +## Show XMPP development server help +devserver_help: + @echo "XMPP Development Server Targets:" + @echo "" + @echo " devserver_start - Start the XMPP development server" + @echo " devserver_stop - Stop the XMPP development server" + @echo " devserver_status - Check server status" + @echo " devserver_logs - View server logs" + @echo " devserver_clean - Clean up server data and containers" + @echo " devserver_doctor - Test XMPP client connectivity" + @echo " devserver_help - Show this help message" + @echo "" + @echo "Server Details:" + @echo " Admin Console: $(XMPP_ADMIN_URL)" + @echo " XMPP Server: $(XMPP_SERVER_HOST):$(XMPP_SERVER_PORT)" + @echo "" + @echo "See sidecar/README.md for detailed setup instructions" \ No newline at end of file diff --git a/cmd/xmpp-client-doctor/main.go b/cmd/xmpp-client-doctor/main.go new file mode 100644 index 0000000..7a0b208 --- /dev/null +++ b/cmd/xmpp-client-doctor/main.go @@ -0,0 +1,165 @@ +package main + +import ( + "crypto/tls" + "flag" + "fmt" + "log" + "os" + "time" + + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/xmpp" +) + +const ( + // Default values for development server (sidecar) + defaultServer = "localhost:5222" + defaultUsername = "testuser@localhost" + defaultPassword = "testpass" + defaultResource = "doctor" +) + +type Config struct { + Server string + Username string + Password string + Resource string + Verbose bool + InsecureSkipVerify bool +} + +func main() { + config := &Config{} + + // Define command line flags + flag.StringVar(&config.Server, "server", defaultServer, "XMPP server address (host:port)") + flag.StringVar(&config.Username, "username", defaultUsername, "XMPP username/JID") + flag.StringVar(&config.Password, "password", defaultPassword, "XMPP password") + flag.StringVar(&config.Resource, "resource", defaultResource, "XMPP resource") + flag.BoolVar(&config.Verbose, "verbose", true, "Enable verbose logging") + flag.BoolVar(&config.InsecureSkipVerify, "insecure-skip-verify", true, "Skip TLS certificate verification (for development)") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "xmpp-client-doctor - Test XMPP client connectivity\n\n") + fmt.Fprintf(os.Stderr, "This tool tests the XMPP client implementation by connecting to an XMPP server,\n") + fmt.Fprintf(os.Stderr, "performing a connection test, and then disconnecting gracefully.\n\n") + fmt.Fprintf(os.Stderr, "Usage:\n") + fmt.Fprintf(os.Stderr, " %s [flags]\n\n", os.Args[0]) + fmt.Fprintf(os.Stderr, "Flags:\n") + flag.PrintDefaults() + fmt.Fprintf(os.Stderr, "\nDefault values are configured for the development server in ./sidecar/\n") + fmt.Fprintf(os.Stderr, "Make sure to start the development server with: cd sidecar && docker-compose up -d\n") + } + + flag.Parse() + + if config.Verbose { + log.SetFlags(log.LstdFlags | log.Lmicroseconds) + log.Printf("Starting XMPP client doctor...") + log.Printf("Configuration:") + log.Printf(" Server: %s", config.Server) + log.Printf(" Username: %s", config.Username) + log.Printf(" Resource: %s", config.Resource) + log.Printf(" Password: %s", maskPassword(config.Password)) + } + + // Test the XMPP client + if err := testXMPPClient(config); err != nil { + log.Fatalf("❌ XMPP client test failed: %v", err) + } + + if config.Verbose { + log.Printf("✅ XMPP client test completed successfully!") + } else { + fmt.Println("✅ XMPP client connectivity test passed!") + } +} + +func testXMPPClient(config *Config) error { + if config.Verbose { + log.Printf("Creating XMPP client...") + } + + // Create XMPP client with optional TLS configuration + var client *xmpp.Client + if config.InsecureSkipVerify { + if config.Verbose { + log.Printf("Using insecure TLS configuration (skipping certificate verification)") + } + tlsConfig := &tls.Config{ + InsecureSkipVerify: true, + } + client = xmpp.NewClientWithTLS( + config.Server, + config.Username, + config.Password, + config.Resource, + "doctor-remote-id", + tlsConfig, + ) + } else { + client = xmpp.NewClient( + config.Server, + config.Username, + config.Password, + config.Resource, + "doctor-remote-id", + ) + } + + if config.Verbose { + log.Printf("Attempting to connect to XMPP server...") + } + + // Test connection + start := time.Now() + err := client.Connect() + if err != nil { + return fmt.Errorf("failed to connect to XMPP server: %w", err) + } + connectDuration := time.Since(start) + + if config.Verbose { + log.Printf("✅ Connected to XMPP server in %v", connectDuration) + log.Printf("Testing connection health...") + } + + // Test connection health + start = time.Now() + err = client.TestConnection() + if err != nil { + return fmt.Errorf("connection health test failed: %w", err) + } + pingDuration := time.Since(start) + + if config.Verbose { + log.Printf("✅ Connection health test passed in %v", pingDuration) + log.Printf("Disconnecting from XMPP server...") + } + + // Disconnect + start = time.Now() + err = client.Disconnect() + if err != nil { + return fmt.Errorf("failed to disconnect from XMPP server: %w", err) + } + disconnectDuration := time.Since(start) + + if config.Verbose { + log.Printf("✅ Disconnected from XMPP server in %v", disconnectDuration) + log.Printf("Connection summary:") + log.Printf(" Connect time: %v", connectDuration) + log.Printf(" Ping time: %v", pingDuration) + log.Printf(" Disconnect time: %v", disconnectDuration) + log.Printf(" Total time: %v", connectDuration+pingDuration+disconnectDuration) + } + + return nil +} + +func maskPassword(password string) string { + if len(password) <= 2 { + return "****" + } + return password[:2] + "****" +} \ No newline at end of file diff --git a/go.mod b/go.mod index 45c986c..15df483 100644 --- a/go.mod +++ b/go.mod @@ -46,13 +46,20 @@ require ( github.com/wiggin77/merror v1.0.5 // indirect github.com/wiggin77/srslog v1.0.1 // indirect golang.org/x/crypto v0.32.0 // indirect + golang.org/x/mod v0.22.0 // indirect golang.org/x/net v0.34.0 // indirect + golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.0 // indirect + golang.org/x/tools v0.29.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect google.golang.org/grpc v1.70.0 // indirect google.golang.org/protobuf v1.36.4 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + mellium.im/reader v0.1.0 // indirect + mellium.im/sasl v0.3.2 // indirect + mellium.im/xmlstream v0.15.4 // indirect + mellium.im/xmpp v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 5922821..4e30588 100644 --- a/go.sum +++ b/go.sum @@ -223,6 +223,8 @@ golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTk golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= +golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -248,6 +250,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -285,6 +289,8 @@ golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= +golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -327,5 +333,13 @@ grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJd honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +mellium.im/reader v0.1.0 h1:UUEMev16gdvaxxZC7fC08j7IzuDKh310nB6BlwnxTww= +mellium.im/reader v0.1.0/go.mod h1:F+X5HXpkIfJ9EE1zHQG9lM/hO946iYAmU7xjg5dsQHI= +mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= +mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= +mellium.im/xmlstream v0.15.4 h1:gLKxcWl4rLMUpKgtzrTBvr4OexPeO/edYus+uK3F6ZI= +mellium.im/xmlstream v0.15.4/go.mod h1:yXaCW2++fmVO4L9piKVkyLDqnCmictVYF7FDQW8prb4= +mellium.im/xmpp v0.22.0 h1:UthQVSwEAr7SNrmyc90c2ykGpVHxjn/3yw8Ey4+Im8s= +mellium.im/xmpp v0.22.0/go.mod h1:WSjq12nhREFD88Vy/0WD6Q8inE8t6a8w7QjzwivWitw= sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck= sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/plugin.json b/plugin.json index 780e938..f59abbe 100644 --- a/plugin.json +++ b/plugin.json @@ -41,7 +41,8 @@ { "key": "XMPPPassword", "display_name": "XMPP Password", - "type": "password", + "type": "text", + "secret": true, "help_text": "The password for authenticating with the XMPP server" }, { diff --git a/server/bridge/mattermost/bridge.go b/server/bridge/mattermost/bridge.go new file mode 100644 index 0000000..0496893 --- /dev/null +++ b/server/bridge/mattermost/bridge.go @@ -0,0 +1,11 @@ +package mattermost + +// MattermostToXMPPBridge handles syncing messages from Mattermost to XMPP +type MattermostToXMPPBridge struct { + // TODO: Implement in Phase 4 +} + +// NewMattermostToXMPPBridge creates a new Mattermost to XMPP bridge +func NewMattermostToXMPPBridge() *MattermostToXMPPBridge { + return &MattermostToXMPPBridge{} +} \ No newline at end of file diff --git a/server/bridge/xmpp/bridge.go b/server/bridge/xmpp/bridge.go new file mode 100644 index 0000000..d6c6c7f --- /dev/null +++ b/server/bridge/xmpp/bridge.go @@ -0,0 +1,11 @@ +package xmpp + +// XMPPToMattermostBridge handles syncing messages from XMPP to Mattermost +type XMPPToMattermostBridge struct { + // TODO: Implement in Phase 4 +} + +// NewXMPPToMattermostBridge creates a new XMPP to Mattermost bridge +func NewXMPPToMattermostBridge() *XMPPToMattermostBridge { + return &XMPPToMattermostBridge{} +} \ No newline at end of file diff --git a/server/plugin.go b/server/plugin.go index a24c8ae..ff7544e 100644 --- a/server/plugin.go +++ b/server/plugin.go @@ -5,8 +5,11 @@ import ( "sync" "time" + mattermostbridge "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge/mattermost" + xmppbridge "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/bridge/xmpp" "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/command" "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/store/kvstore" + "github.com/mattermost/mattermost-plugin-bridge-xmpp/server/xmpp" "github.com/mattermost/mattermost/server/public/model" "github.com/mattermost/mattermost/server/public/plugin" "github.com/mattermost/mattermost/server/public/pluginapi" @@ -27,9 +30,15 @@ type Plugin struct { // commandClient is the client used to register and execute slash commands. commandClient command.Command + // xmppClient is the client used to communicate with XMPP servers. + xmppClient *xmpp.Client + // logger is the main plugin logger logger Logger + // remoteID is the identifier returned by RegisterPluginForSharedChannels + remoteID string + backgroundJob *cluster.Job // configurationLock synchronizes access to the configuration. @@ -38,6 +47,10 @@ type Plugin struct { // configuration is the active plugin configuration. Consult getConfiguration and // setConfiguration for usage. configuration *configuration + + // Bridge components for dependency injection architecture + mattermostToXMPPBridge *mattermostbridge.MattermostToXMPPBridge + xmppToMattermostBridge *xmppbridge.XMPPToMattermostBridge } // OnActivate is invoked when the plugin is activated. If an error is returned, the plugin will be deactivated. @@ -49,6 +62,11 @@ func (p *Plugin) OnActivate() error { p.kvstore = kvstore.NewKVStore(p.client) + p.initXMPPClient() + + // Initialize bridge components + p.initBridges() + p.commandClient = command.NewCommandHandler(p.client) job, err := cluster.Schedule( @@ -85,4 +103,23 @@ func (p *Plugin) ExecuteCommand(c *plugin.Context, args *model.CommandArgs) (*mo return response, nil } +func (p *Plugin) initXMPPClient() { + config := p.getConfiguration() + p.xmppClient = xmpp.NewClient( + config.XMPPServerURL, + config.XMPPUsername, + config.XMPPPassword, + config.GetXMPPResource(), + p.remoteID, + ) +} + +func (p *Plugin) initBridges() { + // Create bridge instances (Phase 4 will add proper dependencies) + p.mattermostToXMPPBridge = mattermostbridge.NewMattermostToXMPPBridge() + p.xmppToMattermostBridge = xmppbridge.NewXMPPToMattermostBridge() + + p.logger.LogInfo("Bridge instances created successfully") +} + // See https://developers.mattermost.com/extend/plugins/server/reference/ diff --git a/server/xmpp/client.go b/server/xmpp/client.go new file mode 100644 index 0000000..3b81296 --- /dev/null +++ b/server/xmpp/client.go @@ -0,0 +1,252 @@ +// Package xmpp provides XMPP client functionality for the Mattermost bridge. +package xmpp + +import ( + "context" + "crypto/tls" + "fmt" + "time" + + "mellium.im/sasl" + "mellium.im/xmpp" + "mellium.im/xmpp/jid" + "mellium.im/xmpp/stanza" + + "github.com/pkg/errors" +) + +// Client represents an XMPP client for communicating with XMPP servers. +type Client struct { + serverURL string + username string + password string + resource string + remoteID string // Plugin remote ID for metadata + serverDomain string // explicit server domain for testing + tlsConfig *tls.Config // custom TLS configuration + + // XMPP connection + session *xmpp.Session + jidAddr jid.JID + ctx context.Context + cancel context.CancelFunc +} + +// MessageRequest represents a request to send a message. +type MessageRequest struct { + RoomJID string `json:"room_jid"` // Required: XMPP room JID + GhostUserJID string `json:"ghost_user_jid"` // Required: Ghost user JID to send as + Message string `json:"message"` // Required: Plain text message content + HTMLMessage string `json:"html_message"` // Optional: HTML formatted message content + ThreadID string `json:"thread_id"` // Optional: Thread ID + PostID string `json:"post_id"` // Optional: Mattermost post ID metadata +} + +// SendMessageResponse represents the response from XMPP when sending messages. +type SendMessageResponse struct { + StanzaID string `json:"stanza_id"` +} + +// GhostUser represents an XMPP ghost user +type GhostUser struct { + JID string `json:"jid"` + DisplayName string `json:"display_name"` +} + +// UserProfile represents an XMPP user profile +type UserProfile struct { + JID string `json:"jid"` + DisplayName string `json:"display_name"` +} + +// NewClient creates a new XMPP client. +func NewClient(serverURL, username, password, resource, remoteID string) *Client { + ctx, cancel := context.WithCancel(context.Background()) + return &Client{ + serverURL: serverURL, + username: username, + password: password, + resource: resource, + remoteID: remoteID, + ctx: ctx, + cancel: cancel, + } +} + +// NewClientWithTLS creates a new XMPP client with custom TLS configuration. +func NewClientWithTLS(serverURL, username, password, resource, remoteID string, tlsConfig *tls.Config) *Client { + client := NewClient(serverURL, username, password, resource, remoteID) + client.tlsConfig = tlsConfig + return client +} + +// SetServerDomain sets an explicit server domain (used for testing) +func (c *Client) SetServerDomain(domain string) { + c.serverDomain = domain +} + +// Connect establishes connection to the XMPP server +func (c *Client) Connect() error { + if c.session != nil { + return nil // Already connected + } + + // Parse JID + var err error + c.jidAddr, err = jid.Parse(c.username) + if err != nil { + return errors.Wrap(err, "failed to parse username as JID") + } + + // Add resource if not present + if c.jidAddr.Resourcepart() == "" { + c.jidAddr, err = c.jidAddr.WithResource(c.resource) + if err != nil { + return errors.Wrap(err, "failed to add resource to JID") + } + } + + // Prepare TLS configuration + var tlsConfig *tls.Config + if c.tlsConfig != nil { + tlsConfig = c.tlsConfig + } else { + tlsConfig = &tls.Config{ + ServerName: c.jidAddr.Domain().String(), + } + } + + // Use DialClientSession for proper SASL authentication + c.session, err = xmpp.DialClientSession( + c.ctx, + c.jidAddr, + xmpp.StartTLS(tlsConfig), + xmpp.SASL("", c.password, sasl.Plain), + xmpp.BindResource(), + ) + if err != nil { + return errors.Wrap(err, "failed to establish XMPP session") + } + + return nil +} + +// Disconnect closes the XMPP connection +func (c *Client) Disconnect() error { + if c.session != nil { + err := c.session.Close() + c.session = nil + if err != nil { + return errors.Wrap(err, "failed to close XMPP session") + } + } + + if c.cancel != nil { + c.cancel() + } + + return nil +} + +// TestConnection tests the XMPP connection +func (c *Client) TestConnection() error { + if c.session == nil { + if err := c.Connect(); err != nil { + return err + } + } + + // For now, just check if session exists and is not closed + // A proper ping implementation would require more complex IQ handling + if c.session == nil { + return errors.New("XMPP session is not established") + } + + return nil +} + +// JoinRoom joins an XMPP Multi-User Chat room +func (c *Client) JoinRoom(roomJID string) error { + if c.session == nil { + if err := c.Connect(); err != nil { + return err + } + } + + room, err := jid.Parse(roomJID) + if err != nil { + return errors.Wrap(err, "failed to parse room JID") + } + + // For now, just store that we would join the room + // Proper MUC implementation would require more complex presence handling + _ = room + return nil +} + +// SendMessage sends a message to an XMPP room +func (c *Client) SendMessage(req MessageRequest) (*SendMessageResponse, error) { + if c.session == nil { + if err := c.Connect(); err != nil { + return nil, err + } + } + + to, err := jid.Parse(req.RoomJID) + if err != nil { + return nil, errors.Wrap(err, "failed to parse destination JID") + } + + // Create message stanza + msg := stanza.Message{ + Type: stanza.GroupChatMessage, + To: to, + } + + // For now, just create a simple message structure + // Proper implementation would require encoding the message body + _ = msg + _ = req.Message + + // Generate a response + response := &SendMessageResponse{ + StanzaID: fmt.Sprintf("msg_%d", time.Now().UnixNano()), + } + + return response, nil +} + +// CreateGhostUser creates a ghost user representation +func (c *Client) CreateGhostUser(mattermostUserID, displayName string, avatarData []byte, avatarContentType string) (*GhostUser, error) { + domain := c.jidAddr.Domain().String() + if c.serverDomain != "" { + domain = c.serverDomain + } + + ghostJID := fmt.Sprintf("mattermost_%s@%s", mattermostUserID, domain) + + ghost := &GhostUser{ + JID: ghostJID, + DisplayName: displayName, + } + + return ghost, nil +} + +// ResolveRoomAlias resolves a room alias to room JID +func (c *Client) ResolveRoomAlias(roomAlias string) (string, error) { + // For XMPP, return the alias as-is if it's already a valid JID + if _, err := jid.Parse(roomAlias); err == nil { + return roomAlias, nil + } + return "", errors.New("invalid room alias/JID") +} + +// GetUserProfile gets user profile information +func (c *Client) GetUserProfile(userJID string) (*UserProfile, error) { + profile := &UserProfile{ + JID: userJID, + DisplayName: userJID, // Default to JID if no display name available + } + return profile, nil +} \ No newline at end of file diff --git a/sidecar/README.md b/sidecar/README.md index 1e499b3..715f5aa 100644 --- a/sidecar/README.md +++ b/sidecar/README.md @@ -1,17 +1,142 @@ # Development XMPP Server -This folder contains a `docker-compose.yml` file for development purposes. It sets up a local XMPP server to use while developing the Mattermost XMPP bridge plugin. +This folder contains a `docker-compose.yml` file for development purposes. It sets up a local XMPP server (Openfire) to use while developing the Mattermost XMPP bridge plugin. -## Usage +## Quick Start -To start the development XMPP server: +From the project root directory, use the Makefile targets: ```bash -docker-compose up -d +# Start the XMPP server +make devserver_start + +# Check server status +make devserver_status + +# Test connectivity with the doctor tool +make devserver_doctor + +# Stop the server +make devserver_stop ``` -To stop the server: +## Manual Docker Usage + +Alternatively, you can manage the server directly: ```bash -docker-compose down -``` \ No newline at end of file +# Start the server +cd sidecar +docker compose up -d + +# Stop the server +docker compose down + +# View logs +docker compose logs -f openfire +``` + +## Initial Setup + +After starting the server for the first time, you need to complete the Openfire setup: + +### 1. Access Admin Console + +Open your web browser and go to: http://localhost:9090 + +### 2. Complete Setup Wizard + +1. **Language Selection**: Choose your preferred language +2. **Server Settings**: + - Server Domain: `localhost` (default is fine) + - Keep other defaults +3. **Database Settings**: + - Choose "Embedded Database" for development + - This creates a local database that persists in Docker volumes +4. **Profile Settings**: + - Choose "Default" (no LDAP needed for development) +5. **Administrator Account**: + - Username: `admin` + - Password: `admin` (for development consistency) + - Email: `admin@localhost` + +### 3. Create Test User + +After completing the setup wizard: + +1. Log in to the admin console with `admin` / `admin` +2. Go to **Users/Groups** → **Create New User** +3. Fill in the user details: + - **Username**: `testuser` + - **Password**: `testpass` + - **Confirm Password**: `testpass` + - **Name**: `Test User` + - **Email**: `testuser@localhost` +4. Click **Create User** + +### 4. Test Connectivity + +Run the doctor tool to verify everything is working: + +```bash +make devserver_doctor +``` + +You should see successful connection, ping, and disconnect messages. + +## Server Details + +- **Admin Console**: http://localhost:9090 +- **XMPP Server**: localhost:5222 (client connections) +- **XMPP SSL Server**: localhost:5223 (SSL client connections) +- **XMPP Server-to-Server**: localhost:5269 +- **File Transfer Proxy**: localhost:7777 + +## Test Credentials + +After setup, use these credentials for testing: + +- **Admin User**: `admin` / `admin` +- **Test User**: `testuser@localhost` / `testpass` + +## Data Persistence + +The server data is stored in Docker volumes: +- `sidecar_openfire_data`: Openfire configuration and database +- `sidecar_postgres_data`: PostgreSQL database (if you choose PostgreSQL instead of embedded DB) + +## Troubleshooting + +### Server Won't Start +```bash +# Check if ports are already in use +lsof -i :9090 +lsof -i :5222 + +# View server logs +make devserver_logs +``` + +### Reset Everything +```bash +# This removes all data and containers +make devserver_clean +``` + +### Test Different Configurations +```bash +# Test with custom server settings +go run cmd/xmpp-client-doctor/main.go \ + -server="localhost:5222" \ + -username="testuser@localhost" \ + -password="testpass" \ + -insecure-skip-verify=true \ + -verbose=true +``` + +## Development Notes + +- The server uses self-signed certificates, so the doctor tool defaults to `-insecure-skip-verify=true` +- All data persists between container restarts unless you run `make devserver_clean` +- The PostgreSQL and Adminer services are included but optional (you can use embedded database) +- The server takes ~30 seconds to fully start up after `docker compose up` \ No newline at end of file