benzcloud-client/internal/pairing/pairing.go

149 lines
4.4 KiB
Go

package pairing
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// ClientProfile stores the pairing status and credentials.
type ClientProfile struct {
ServerLANURL string `json:"server_lan_url"`
Username string `json:"username"`
BaseDomain string `json:"base_domain"`
OverlayIP string `json:"overlay_ip"`
ServerVPNIP string `json:"server_vpn_ip"`
SessionToken string `json:"session_token"`
Paired bool `json:"paired"`
PairedAt time.Time `json:"paired_at"`
Permissions map[string]bool `json:"permissions"`
ConfigDir string `json:"config_dir"`
mu sync.RWMutex
}
var ErrNotPaired = errors.New("client is not paired with a BenzCloud server")
// Pair connects to the BenzCloud server via local IP, exchanges credentials, and saves the mesh VPN config.
func Pair(serverURL, username, password, configDir string) (*ClientProfile, error) {
if err := os.MkdirAll(configDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create config dir: %w", err)
}
cleanURL := strings.TrimRight(serverURL, "/")
pairEndpoint := cleanURL + "/api/pair"
reqBody, _ := json.Marshal(map[string]string{
"username": username,
"password": password,
})
client := http.Client{Timeout: 10 * time.Second}
resp, err := client.Post(pairEndpoint, "application/json", bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("pairing request to %s failed: %w", pairEndpoint, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var errResp map[string]string
_ = json.NewDecoder(resp.Body).Decode(&errResp)
return nil, fmt.Errorf("server rejected pairing (HTTP %d): %s", resp.StatusCode, errResp["error"])
}
var pairData struct {
Status string `json:"status"`
Username string `json:"username"`
BaseDomain string `json:"base_domain"`
OverlayIP string `json:"overlay_ip"`
ServerVPNIP string `json:"server_vpn_ip"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
ClientConfigYAML string `json:"client_config_yaml"`
SessionToken string `json:"session_token"`
Permissions map[string]bool `json:"permissions"`
}
if err := json.NewDecoder(resp.Body).Decode(&pairData); err != nil {
return nil, fmt.Errorf("failed to parse pairing response: %w", err)
}
// Write mesh VPN files to disk
yamlPath := filepath.Join(configDir, "client_nebula.yaml")
if err := os.WriteFile(yamlPath, []byte(pairData.ClientConfigYAML), 0600); err != nil {
return nil, fmt.Errorf("failed to write nebula config: %w", err)
}
profile := &ClientProfile{
ServerLANURL: cleanURL,
Username: pairData.Username,
BaseDomain: pairData.BaseDomain,
OverlayIP: pairData.OverlayIP,
ServerVPNIP: pairData.ServerVPNIP,
SessionToken: pairData.SessionToken,
Paired: true,
PairedAt: time.Now().UTC(),
Permissions: pairData.Permissions,
ConfigDir: configDir,
}
if err := profile.Save(); err != nil {
return nil, err
}
return profile, nil
}
// ProfilePath returns the location of pairing.json.
func (p *ClientProfile) ProfilePath() string {
return filepath.Join(p.ConfigDir, "pairing.json")
}
// Save persists the profile to disk with 0600 permissions.
func (p *ClientProfile) Save() error {
p.mu.Lock()
defer p.mu.Unlock()
data, err := json.MarshalIndent(p, "", " ")
if err != nil {
return err
}
tmpFile := p.ProfilePath() + ".tmp"
if err := os.WriteFile(tmpFile, data, 0600); err != nil {
return err
}
return os.Rename(tmpFile, p.ProfilePath())
}
// Load loads the profile from configDir.
func Load(configDir string) (*ClientProfile, error) {
pPath := filepath.Join(configDir, "pairing.json")
data, err := os.ReadFile(pPath)
if err != nil {
if os.IsNotExist(err) {
return &ClientProfile{ConfigDir: configDir, Paired: false}, ErrNotPaired
}
return nil, err
}
var p ClientProfile
if err := json.Unmarshal(data, &p); err != nil {
return nil, err
}
p.ConfigDir = configDir
return &p, nil
}
// Unpair removes pairing data from disk.
func Unpair(configDir string) error {
pPath := filepath.Join(configDir, "pairing.json")
yamlPath := filepath.Join(configDir, "client_nebula.yaml")
_ = os.Remove(yamlPath)
return os.Remove(pPath)
}