Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions cmd/pair.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
package cmd

import (
"crypto/ed25519"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"net"
"net/url"
"os"
"path/filepath"

qrcode "github.com/skip2/go-qrcode"
"github.com/spf13/cobra"

"github.com/quantum-bytes/oclaw/internal/config"
)

var pairHost string

var pairCmd = &cobra.Command{
Use: "pair",
Short: "Display a QR code for pairing the oclaw mobile app",
Long: `Generates a QR code containing the gateway connection details.
Scan this code with the oclaw mobile app to connect instantly.

The QR encodes an oclaw:// URI with the gateway's LAN IP, port, auth token,
and device credentials for scope authorization.
Use --host to override the auto-detected LAN IP if the wrong interface is selected.`,
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := config.Load(flagURL, flagToken, flagAgent)
if err != nil {
return fmt.Errorf("load config: %w", err)
}

// Parse gateway URL to extract port
gwURL, err := url.Parse(cfg.GatewayURL)
if err != nil {
return fmt.Errorf("parse gateway URL %q: %w", cfg.GatewayURL, err)
}

port := gwURL.Port()
if port == "" {
port = "39421"
}

// Determine host: explicit flag > auto-detect
host := pairHost
if host == "" {
host, err = detectLANIP()
if err != nil {
return fmt.Errorf("detect LAN IP (use --host to specify manually): %w", err)
}
}

// Build query params
params := url.Values{"token": {cfg.Token}}

// Load device identity for scope authorization
deviceID, privKeyB64, err := loadDeviceKey()
if err != nil {
fmt.Fprintf(os.Stderr, " Warning: no device identity found (%v)\n", err)
fmt.Fprintf(os.Stderr, " Mobile app will connect without operator scopes\n\n")
} else {
params.Set("did", deviceID)
params.Set("dkey", privKeyB64)
}

// Build oclaw:// URI
pairURI := &url.URL{
Scheme: "oclaw",
Host: net.JoinHostPort(host, port),
RawQuery: params.Encode(),
}
uri := pairURI.String()

// Generate QR code as terminal string
qr, err := qrcode.New(uri, qrcode.Medium)
if err != nil {
return fmt.Errorf("generate QR code: %w", err)
}

// Mask token for display
maskedToken := cfg.Token
if len(maskedToken) > 4 {
maskedToken = maskedToken[:4] + "****"
}

fmt.Println()
fmt.Println(" Scan this QR code with the oclaw mobile app:")
fmt.Println()
fmt.Print(qr.ToSmallString(false))
fmt.Println()
fmt.Printf(" Host: %s:%s\n", host, port)
fmt.Printf(" Token: %s\n", maskedToken)
if deviceID != "" {
fmt.Printf(" Device: %s...%s\n", deviceID[:8], deviceID[len(deviceID)-8:])
}
fmt.Println()

return nil
},
}

func init() {
pairCmd.Flags().StringVar(&pairHost, "host", "", "Override auto-detected LAN IP (e.g., 192.168.1.100)")
rootCmd.AddCommand(pairCmd)
}

// deviceJSON is the structure of ~/.openclaw/identity/device.json.
type deviceJSON struct {
DeviceID string `json:"deviceId"`
PrivateKeyPem string `json:"privateKeyPem"`
}

// loadDeviceKey reads the device identity and returns the device ID and
// base64-encoded raw Ed25519 private key seed (32 bytes).
func loadDeviceKey() (deviceID, privKeyB64 string, err error) {
home, err := os.UserHomeDir()
if err != nil {
return "", "", err
}

path := filepath.Join(home, ".openclaw", "identity", "device.json")
data, err := os.ReadFile(path)
if err != nil {
return "", "", err
}

var dev deviceJSON
if err := json.Unmarshal(data, &dev); err != nil {
return "", "", fmt.Errorf("parse device.json: %w", err)
}

// Parse PEM-encoded private key
block, _ := pem.Decode([]byte(dev.PrivateKeyPem))
if block == nil {
return "", "", fmt.Errorf("no PEM block found in privateKeyPem")
}

key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", "", fmt.Errorf("parse PKCS8 key: %w", err)
}

edKey, ok := key.(ed25519.PrivateKey)
if !ok {
return "", "", fmt.Errorf("not an Ed25519 key")
}

// Ed25519 private key is 64 bytes; seed is first 32
seed := edKey.Seed()
return dev.DeviceID, base64.StdEncoding.EncodeToString(seed), nil
}

// detectLANIP returns the first non-loopback, non-virtual IPv4 address.
// It prefers physical interfaces (en*, eth*) over virtual ones.
func detectLANIP() (string, error) {
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}

var fallback string

for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}

addrs, err := iface.Addrs()
if err != nil {
continue
}

for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}

if ip == nil || ip.IsLoopback() {
continue
}

ip4 := ip.To4()
if ip4 == nil {
continue
}

if ip4[0] == 169 && ip4[1] == 254 {
continue
}

name := iface.Name
if (len(name) >= 2 && name[:2] == "en") || (len(name) >= 3 && name[:3] == "eth") {
return ip4.String(), nil
}

if fallback == "" {
fallback = ip4.String()
}
}
}

if fallback != "" {
return fallback, nil
}

return "", fmt.Errorf("no LAN IPv4 address found")
}
6 changes: 3 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ bypassing the buggy built-in TUI.`,
}

func init() {
rootCmd.Flags().StringVar(&flagURL, "url", "", "Gateway WebSocket URL (default: from config or ws://127.0.0.1:39421)")
rootCmd.Flags().StringVar(&flagToken, "token", "", "Gateway auth token (default: from config)")
rootCmd.Flags().StringVar(&flagAgent, "agent", "", "Default agent ID to connect to")
rootCmd.PersistentFlags().StringVar(&flagURL, "url", "", "Gateway WebSocket URL (default: from config or ws://127.0.0.1:39421)")
rootCmd.PersistentFlags().StringVar(&flagToken, "token", "", "Gateway auth token (default: from config)")
rootCmd.PersistentFlags().StringVar(&flagAgent, "agent", "", "Default agent ID to connect to")
}

func Execute() {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ require (
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.13 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
Expand Down
Loading