149 lines
4.4 KiB
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)
|
|
}
|