diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 82b8bf4..8e7a349 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,8 +15,11 @@ jobs: strategy: matrix: include: - - os: windows - runs-on: warp-windows-latest-2204-4x + - os: darwin + runs-on: macos-latest + arch: arm64 + - os: linux + runs-on: ubuntu-latest arch: amd64 steps: diff --git a/.gitignore b/.gitignore index 85c7d0f..4292076 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ .claude/ **/.claude/ + +CLAUDE.md +**/CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index dccfc50..8261877 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,67 @@ cd ../client; make build Pre-compiled binaries for various platforms are available in the [releases](https://github.com/sqlrsync/client/releases) section of the GitHub repository. -* Mac (since 2020): sqlrsync-darwin-arm64 -* Mac (before 2020): sqlrsync-darwin-amd64 -* Linux: sqlrsync-linux-amd64 -* Windows: sqlrsync-windows-amd64.exe +- Mac (since 2020): sqlrsync-darwin-arm64 +- Mac (before 2020): sqlrsync-darwin-amd64 +- Linux: sqlrsync-linux-amd64 +- Windows: sqlrsync-windows-amd64.exe ## Running Run ./bin/sqlrsync + +## Application Logic and Settings + +By default, REMOTE is SQLRsync.com Version Controlled Storage + +### Authentication and Authorization + +There are 3 types of keys used by SQLRsync: +- Account Create: Allows the creation of a new Database on REMOTE +- Account Pull: Allows creating a local copy of any version of any existing Database on REMOTE that is owned by that account +- Replica Pull: Allows creating a local copy of any version of a specific existing Database on REMOTE to a local file +- Replica Push: Allows the creation of a new version of a specific existing Database on REMOTE + +Account level Pull and Create keys are never stored locally, and are always interactively prompted or provided as a command line argument. + +If you use an Account level key, the server will reply with a new replica-specific Pull key (and Push token if you use an Account Create key). The pull key is stored adjacent to the replicated database in a file ending with the suffix `-sqlrsync`, along with other data. (For a database at /tmp/my-data.sqlite, the pull key would be stored in /tmp/my-data.sqlite-sqlrsync) + +The more sensitive Replica Push key is stored in ~/.config/sqlrsync/local-secrets.toml and subsequent pushes will use that key. + +### Stored Settings + +Settings and defaults are stored in your user directory at ~/.config/sqlrsync. Within that directory, there are two files: + +1. defaults.toml + Contains default settings for all sqlrsync databases, like server URL, public/private, to generate a new unique clientSideEncryptionKey +```toml +# An example ~/.config/defaults.toml +[defaults] +server = "wss://sqlrsync.com" +``` +cd +2. local-secrets.toml + Contains this-machine-specific settings, including the path to the local SQLite files, push keys, and encryption keys. + +```toml +# An example ~/.config/local-secrets.toml +[local] +# When a new SQLRsync Replica is created on the server, we can use this prefix to identify this machine +hostname = "homelab3" +defaultClientSideEncryptionKey = "riot-camel-pass-flash-cereal-journey" + +[[sqlrsync-databases]] +path = "/home/matt/webapps/hedgedoc/data/data.db" +replicaID = "AJK928AK02jidsJA1" +private-push-key = "abcd1234abcd1234" +clientSideEncryptionKey = "riot-camel-pass-flash-cereal-journey" +lastUpdated = "2023-01-01T00:00:00Z" +server = "wss://s9.sqlrsync.com" + +[[sqlrsync-databases]] +path = "/home/matt/webapps/wikijs/data/another.db" +private-push-key = "efgh5678efgh5678" +lastUpdated = "2023-01-01T00:00:00Z" +clientSideEncryptionKey = "riot-camel-pass-flash-cereal-journey" +server = "wss://sqlrsync.com" +``` diff --git a/bridge/cgo_bridge.go b/bridge/cgo_bridge.go index fe2518c..e40d383 100644 --- a/bridge/cgo_bridge.go +++ b/bridge/cgo_bridge.go @@ -172,7 +172,7 @@ func go_local_read_callback(userData unsafe.Pointer, buffer *C.uint8_t, size C.i bytesRead, err := client.ReadFunc(goBuffer) if err != nil { if err.Error() != "connection lost" && err.Error() != "sync completed" { - client.Logger.Error("Read callback error", zap.Error(err)) + client.Logger.Error("Connection to server had a failure. Are you online? Read callback error", zap.Error(err)) } return -1 } diff --git a/client/config.go b/client/config.go index 41a499d..cae4dd6 100644 --- a/client/config.go +++ b/client/config.go @@ -1,50 +1,103 @@ package main import ( + "bufio" "fmt" "os" "path/filepath" + "strings" + "time" - "gopkg.in/yaml.v3" + "github.com/BurntSushi/toml" ) -type Config struct { - DefaultPrefix string `yaml:"prefix"` - PrivateToken string `yaml:"privateToken"` +type DefaultsConfig struct { + Defaults struct { + Server string `toml:"server"` + } `toml:"defaults"` } -type DatabaseConfig struct { - ServerID string `yaml:"serverID"` - PullToken string `yaml:"pullToken,omitempty"` - PushToken string `yaml:"pushToken,omitempty"` - ServerStateID int `yaml:"serverStateID"` - LocalStateHash string `yaml:"localStateHash"` - Aliases []string `yaml:"aliases,omitempty"` +type LocalSecretsConfig struct { + Local struct { + Hostname string `toml:"hostname"` + DefaultClientSideEncryptionKey string `toml:"defaultClientSideEncryptionKey"` + } `toml:"local"` + SQLRsyncDatabases []SQLRsyncDatabase `toml:"sqlrsync-databases"` } -type SecretsConfig struct { - Config Config `yaml:"config"` - Dbs map[string]DatabaseConfig `yaml:"dbs"` +type SQLRsyncDatabase struct { + Path string `toml:"path"` + ReplicaID string `toml:"replicaID,omitempty"` + ClientSideEncryptionKey string `toml:"clientSideEncryptionKey,omitempty"` + LastUpdated time.Time `toml:"lastUpdated,omitempty"` + Server string `toml:"server,omitempty"` } -func LoadSecretsConfig(path string) (*SecretsConfig, error) { +// DashSQLRsync manages the -sqlrsync file for a database +type DashSQLRsync struct { + DatabasePath string + RemotePath string + PullKey string +} + +func GetConfigDir() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + return filepath.Join(homeDir, ".config", "sqlrsync"), nil +} + +func GetDefaultsPath() (string, error) { + configDir, err := GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "defaults.toml"), nil +} + +func GetLocalSecretsPath() (string, error) { + configDir, err := GetConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "local-secrets.toml"), nil +} + +func LoadDefaultsConfig() (*DefaultsConfig, error) { + path, err := GetDefaultsPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("failed to read config file %s: %w", path, err) + if os.IsNotExist(err) { + // Return default config if file doesn't exist + config := &DefaultsConfig{} + config.Defaults.Server = "wss://sqlrsync.com" + return config, nil + } + return nil, fmt.Errorf("failed to read defaults config file %s: %w", path, err) } - var config SecretsConfig - if err := yaml.Unmarshal(data, &config); err != nil { - return nil, fmt.Errorf("failed to parse YAML config: %w", err) + var config DefaultsConfig + if _, err := toml.Decode(string(data), &config); err != nil { + return nil, fmt.Errorf("failed to parse TOML defaults config: %w", err) + } + + // Set default server if not specified + if config.Defaults.Server == "" { + config.Defaults.Server = "wss://sqlrsync.com" } return &config, nil } -func SaveSecretsConfig(config *SecretsConfig, path string) error { - data, err := yaml.Marshal(config) +func SaveDefaultsConfig(config *DefaultsConfig) error { + path, err := GetDefaultsPath() if err != nil { - return fmt.Errorf("failed to marshal config to YAML: %w", err) + return err } dir := filepath.Dir(path) @@ -52,59 +105,191 @@ func SaveSecretsConfig(config *SecretsConfig, path string) error { return fmt.Errorf("failed to create directory %s: %w", dir, err) } - if err := os.WriteFile(path, data, 0600); err != nil { - return fmt.Errorf("failed to write config file %s: %w", path, err) + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create defaults config file %s: %w", path, err) + } + defer file.Close() + + encoder := toml.NewEncoder(file) + if err := encoder.Encode(config); err != nil { + return fmt.Errorf("failed to write defaults config: %w", err) } return nil } -func GetDefaultSecretsPath() (string, error) { - homeDir, err := os.UserHomeDir() +func LoadLocalSecretsConfig() (*LocalSecretsConfig, error) { + path, err := GetLocalSecretsPath() if err != nil { - return "", fmt.Errorf("failed to get user home directory: %w", err) + return nil, err } - return filepath.Join(homeDir, ".config", "sqlrsync", "secrets.yml"), nil -} -func LoadDefaultSecretsConfig() (*SecretsConfig, error) { - path, err := GetDefaultSecretsPath() + data, err := os.ReadFile(path) if err != nil { - return nil, err + if os.IsNotExist(err) { + // Return empty config if file doesn't exist + return &LocalSecretsConfig{ + SQLRsyncDatabases: []SQLRsyncDatabase{}, + }, nil + } + return nil, fmt.Errorf("failed to read local-secrets config file %s: %w", path, err) } - return LoadSecretsConfig(path) + + var config LocalSecretsConfig + if _, err := toml.Decode(string(data), &config); err != nil { + return nil, fmt.Errorf("failed to parse TOML local-secrets config: %w", err) + } + + return &config, nil } -func SaveDefaultSecretsConfig(config *SecretsConfig) error { - path, err := GetDefaultSecretsPath() +func SaveLocalSecretsConfig(config *LocalSecretsConfig) error { + path, err := GetLocalSecretsPath() if err != nil { return err } - return SaveSecretsConfig(config, path) + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory %s: %w", dir, err) + } + + file, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create local-secrets config file %s: %w", path, err) + } + defer file.Close() + + // Set file permissions to 0600 (read/write for owner only) + if err := file.Chmod(0600); err != nil { + return fmt.Errorf("failed to set permissions on local-secrets config file: %w", err) + } + + encoder := toml.NewEncoder(file) + if err := encoder.Encode(config); err != nil { + return fmt.Errorf("failed to write local-secrets config: %w", err) + } + + return nil } -func (sc *SecretsConfig) GetDatabaseConfig(localPath string) (DatabaseConfig, bool) { - config, exists := sc.Dbs[localPath] - return config, exists +func (c *LocalSecretsConfig) FindDatabaseByPath(path string) *SQLRsyncDatabase { + for i := range c.SQLRsyncDatabases { + if c.SQLRsyncDatabases[i].Path == path { + return &c.SQLRsyncDatabases[i] + } + } + return nil } -func (sc *SecretsConfig) SetDatabaseConfig(localPath string, config DatabaseConfig) { - if sc.Dbs == nil { - sc.Dbs = make(map[string]DatabaseConfig) +func (c *LocalSecretsConfig) UpdateOrAddDatabase(db SQLRsyncDatabase) { + for i := range c.SQLRsyncDatabases { + if c.SQLRsyncDatabases[i].Path == db.Path { + // Update existing database + c.SQLRsyncDatabases[i] = db + return + } } - sc.Dbs[localPath] = config + // Add new database + c.SQLRsyncDatabases = append(c.SQLRsyncDatabases, db) +} + +func (c *LocalSecretsConfig) RemoveDatabase(path string) { + for i, db := range c.SQLRsyncDatabases { + if db.Path == path { + // Remove database from slice + c.SQLRsyncDatabases = append(c.SQLRsyncDatabases[:i], c.SQLRsyncDatabases[i+1:]...) + return + } + } +} + +func (c *LocalSecretsConfig) SetHostname(hostname string) { + c.Local.Hostname = hostname +} + +func (c *LocalSecretsConfig) SetDefaultEncryptionKey(key string) { + c.Local.DefaultClientSideEncryptionKey = key } -func (sc *SecretsConfig) RemoveDatabaseConfig(localPath string) { - if sc.Dbs != nil { - delete(sc.Dbs, localPath) +// NewDashSQLRsync creates a new DashSQLRsync instance for the given database path +func NewDashSQLRsync(databasePath string) *DashSQLRsync { + return &DashSQLRsync{ + DatabasePath: databasePath, } } -func (sc *SecretsConfig) GetPrivateToken() string { - return sc.Config.PrivateToken +// FilePath returns the path to the -sqlrsync file +func (d *DashSQLRsync) FilePath() string { + return d.DatabasePath + "-sqlrsync" +} + +// Exists checks if the -sqlrsync file exists +func (d *DashSQLRsync) Exists() bool { + _, err := os.Stat(d.FilePath()) + return err == nil } -func (sc *SecretsConfig) SetPrivateToken(token string) { - sc.Config.PrivateToken = token +// Read reads the -sqlrsync file and populates the struct fields +func (d *DashSQLRsync) Read() error { + if !d.Exists() { + return fmt.Errorf("file does not exist: %s", d.FilePath()) + } + + file, err := os.Open(d.FilePath()) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "#") || line == "" { + continue + } + + if strings.HasPrefix(line, "sqlrsync ") { + parts := strings.Fields(line) + if len(parts) >= 2 { + d.RemotePath = parts[1] + } + + for _, part := range parts { + if strings.HasPrefix(part, "--pullKey=") { + d.PullKey = strings.TrimPrefix(part, "--pullKey=") + } + } + break + } + } + + return scanner.Err() } + +// Write writes the -sqlrsync file with the given remote path and pull key +func (d *DashSQLRsync) Write(remotePath string, pullKey string) error { + d.RemotePath = remotePath + d.PullKey = pullKey + + content := fmt.Sprintf(`#!/bin/bash +# https://sqlrsync.com/docs/pullfile +sqlrsync %s --pullKey=%s +`, remotePath, pullKey) + + if err := os.WriteFile(d.FilePath(), []byte(content), 0755); err != nil { + return fmt.Errorf("failed to write -sqlrsync file: %w", err) + } + + return nil +} + +// Remove removes the -sqlrsync file if it exists +func (d *DashSQLRsync) Remove() error { + if !d.Exists() { + return nil + } + return os.Remove(d.FilePath()) +} \ No newline at end of file diff --git a/client/go.mod b/client/go.mod index 21913d8..300e31e 100644 --- a/client/go.mod +++ b/client/go.mod @@ -3,6 +3,8 @@ module github.com/sqlrsync/sqlrsync.com go 1.24.5 require ( + github.com/BurntSushi/toml v1.5.0 + github.com/fatih/color v1.18.0 github.com/gorilla/websocket v1.5.0 github.com/spf13/cobra v1.8.0 github.com/sqlrsync/sqlrsync.com/bridge v0.0.0-00010101000000-000000000000 @@ -10,14 +12,12 @@ require ( ) require ( - github.com/fatih/color v1.18.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/spf13/pflag v1.0.5 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/sys v0.25.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) replace github.com/sqlrsync/sqlrsync.com/bridge => ../bridge diff --git a/client/go.sum b/client/go.sum index 4fd44d4..51cb4ae 100644 --- a/client/go.sum +++ b/client/go.sum @@ -1,3 +1,5 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/client/main.go b/client/main.go index e3a0ae8..ae7ce4c 100644 --- a/client/main.go +++ b/client/main.go @@ -111,15 +111,6 @@ Usage modes: Eternal gratitude to the authors of the SQLite Project for their contributions to the world of data storage. - -Following their lead, the author of sqlrsync disclaims copyright to the source -code where he is able. This project does not exclusively contain code -eligible for his classification of the public domain. In place of a legal -notice, here is a blessing: - - May you do good and not evil. - May you find forgiveness for yourself and forgive others. - May you share freely, never taking more than you give. `, Version: "1.0.0", @@ -159,7 +150,7 @@ func runSync(cmd *cobra.Command, args []string) error { // This cannot happen isLocal := func(path string) bool { - return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") || !strings.Contains(path, "/") + return strings.HasPrefix(path, "/") || strings.HasPrefix(path, "./") || strings.HasPrefix(path, "../") || strings.HasPrefix(path, "~/") || !strings.Contains(path, "/") } if len(args) == 0 { @@ -183,24 +174,37 @@ func runSync(cmd *cobra.Command, args []string) error { return fmt.Errorf("remote to remote sync not supported") } } else if len(args) == 1 { - // One argument: either ORIGIN (for push) or REPLICA (for pull) - path := args[0] + // One argument: either ORIGIN (push/pull depends on ~.config & -sqlrsync) or REPLICA (for pull) + path := args[0] if isLocal(path) { - // IF ORIGIN:LOCAL (no REPLICA) - push to default remote path - config, err := LoadDefaultSecretsConfig() - if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to load config: %w", err) + // IF ORIGIN:LOCAL (no REPLICA) - varies + localSecretsConfig, err := LoadLocalSecretsConfig() + if err != nil { + return fmt.Errorf("failed to load local secrets config: %w", err) } - dbname := filepath.Base(path) - var remotePath string - if config != nil && config.Config.DefaultPrefix != "" { - remotePath = config.Config.DefaultPrefix + "/" + dbname - } else { - remotePath = dbname + // If we have a push key for this database, use it to push + pushedDBInfo := localSecretsConfig.FindDatabaseByPath(path) + if pushedDBInfo != nil && pushedDBInfo.ReplicaID != "" { + + return runPushSync(path, pushedDBInfo.ReplicaID) + } + + + // else if there is a -sqlrsync file, do a pull instead + dashSQLRsync := NewDashSQLRsync(path) + if dashSQLRsync.Exists() { + if err := dashSQLRsync.Read(); err != nil { + return fmt.Errorf("failed to read -sqlrsync file: %w", err) + } + if dashSQLRsync.RemotePath == "" { + return fmt.Errorf("invalid -sqlrsync file: missing remote path") + } + return runPullSync(dashSQLRsync.RemotePath, path) } - return runPushSync(path, remotePath) + // else push this file up + return runPushSync(path, "") } else { // IF REPLICA:REMOTE (no ORIGIN) - pull to default local name dbname := filepath.Base(path) @@ -251,47 +255,85 @@ func runDirectSync(originPath, replicaPath string) error { } logger.Info("Direct synchronization completed successfully") + fmt.Println("✅ Locally replicated", originPath, "to", replicaPath+".") return nil } func runPushSync(localPath string, remotePath string) error { + logger.Info("Running a PUSH sync", + zap.String("local", localPath), + zap.String("remote", remotePath)) // Validate that database file exists if _, err := os.Stat(localPath); os.IsNotExist(err) { return fmt.Errorf("database file does not exist: %s", localPath) } - // Load or create secrets config - config, err := LoadDefaultSecretsConfig() + // Load defaults config + defaultsConfig, err := LoadDefaultsConfig() + if err != nil { + return fmt.Errorf("failed to load defaults config: %w", err) + } + + // Load local secrets config + localSecretsConfig, err := LoadLocalSecretsConfig() + if err != nil { + return fmt.Errorf("failed to load local secrets config: %w", err) + } + + // Get absolute path for the local database + absLocalPath, err := filepath.Abs(localPath) if err != nil { - // If config doesn't exist or parent directories don't exist, create a new one - config = &SecretsConfig{ - Config: Config{}, - Dbs: make(map[string]DatabaseConfig), + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Find or create database entry + dbConfig := localSecretsConfig.FindDatabaseByPath(absLocalPath) + if dbConfig == nil { + // Create new database entry + dbConfig = &SQLRsyncDatabase{ + Path: absLocalPath, + } + } + + if remotePath == "" { + // Check for -sqlrsync file + dashSQLRsync := NewDashSQLRsync(absLocalPath) + if !dashSQLRsync.Exists() { + fmt.Println("No -sqlrsync file found. This database hasn't been pushed to SQLRsync Server before.") + fmt.Println("No REMOTE name provided. Will use Account Admin Key's default Replica name.") + } else { + logger.Info("Found -sqlrsync file.") } } - // Check if we have a namespace push token - if config.GetPrivateToken() == "" { - fmt.Print("No namespace push token found. Please enter your namespace push token: ") + // Check if we have a push key for this database + if os.Getenv("SQLRSYNC_TOKEN") == "" && authToken == "" { + fmt.Println("No Key provided. Creating a new Replica? Get a key at https://sqlrsync.com/namespaces") + fmt.Print("Enter an Account Admin Key to create a new Replica: ") reader := bufio.NewReader(os.Stdin) token, err := reader.ReadString('\n') if err != nil { - return fmt.Errorf("failed to read namespace push token: %w", err) + return fmt.Errorf("failed to read push key: %w", err) } token = strings.TrimSpace(token) if token == "" { - return fmt.Errorf("namespace push token cannot be empty") + return fmt.Errorf("push key cannot be empty") } + authToken = token - config.SetPrivateToken(token) - - // Save the updated config - if err := SaveDefaultSecretsConfig(config); err != nil { - return fmt.Errorf("failed to save secrets config: %w", err) + // account admin tokens are 24 and are stashed for the session + if len(token) == 24 { + os.Setenv("SQLRSYNC_TOKEN", token) } + } - fmt.Println("Namespace push token saved to ~/.config/sqlrsync/secrets.yml") + // Use server from database config, or defaults if not set + if dbConfig.Server == "" { + dbConfig.Server = defaultsConfig.Defaults.Server + } + if serverURL == "" { + serverURL = dbConfig.Server } logger.Info("Starting push synchronization to sqlrsync.com", @@ -311,18 +353,19 @@ func runPushSync(localPath string, remotePath string) error { } defer localClient.Close() - if len(authToken) < 20 { - authToken = config.GetPrivateToken() - } + localHostname, _ := os.Hostname() + fmt.Println("Using hostname", localHostname, "and abs path", absLocalPath) // Create remote client for WebSocket transport remoteClient, err := remote.New(&remote.Config{ - ServerURL: serverURL + "/push/" + remotePath, + ServerURL: serverURL + "/sapi/push/" + remotePath, PingPong: false, Timeout: timeout, AuthToken: authToken, Logger: logger.Named("remote"), EnableTrafficInspection: inspectTraffic, + LocalHostname: localHostname, + LocalAbsolutePath: absLocalPath, InspectionDepth: inspectionDepth, RequestReadToken: needsReadToken(localPath), }) @@ -353,20 +396,24 @@ func runPushSync(localPath string, remotePath string) error { return fmt.Errorf("push synchronization failed: %w", err) } + // Update database config with latest info + dbConfig.LastUpdated = time.Now() + localSecretsConfig.UpdateOrAddDatabase(*dbConfig) + + // Save the updated config + if err := SaveLocalSecretsConfig(localSecretsConfig); err != nil { + logger.Warn("Failed to save local secrets config", zap.Error(err)) + } + logger.Info("Push synchronization completed successfully") if needsReadToken(localPath) { token := remoteClient.GetNewReadToken() - shareableConfigFile := localPath + "-sqlrsync" - shareableConfigContent := fmt.Sprintf(`#!/bin/bash -# https://sqlrsync.com/docs/pullfile -sqlrsync %s --pullKey=%s -`, remotePath, token) - - if err := os.WriteFile(shareableConfigFile, []byte(shareableConfigContent), 0755); err != nil { + dashSQLRsync := NewDashSQLRsync(localPath) + if err := dashSQLRsync.Write(remotePath, token); err != nil { return fmt.Errorf("failed to create shareable config file: %w", err) } - fmt.Println("🔑 Shareable config file created:", shareableConfigFile) + fmt.Println("🔑 Shareable config file created:", dashSQLRsync.FilePath()) fmt.Println(" Anyone with this file will be able to PULL any version of this database from sqlrsync.com") } @@ -399,9 +446,8 @@ func needsReadToken(path string) bool { return false } // check if the {path}-sqlrsync file exists - _, err := os.Stat(path + "-sqlrsync") - result := os.IsNotExist(err) - return result + dashSQLRsync := NewDashSQLRsync(path) + return !dashSQLRsync.Exists() } func runPullSync(remotePath string, localPath string) error { @@ -423,9 +469,44 @@ func runPullSync(remotePath string, localPath string) error { } } + // Load defaults config + defaultsConfig, err := LoadDefaultsConfig() + if err != nil { + return fmt.Errorf("failed to load defaults config: %w", err) + } + + // Load local secrets config + localSecretsConfig, err := LoadLocalSecretsConfig() + if err != nil { + return fmt.Errorf("failed to load local secrets config: %w", err) + } + + // Get absolute path for the local database + absLocalPath, err := filepath.Abs(localPath) + if err != nil { + return fmt.Errorf("failed to get absolute path: %w", err) + } + + // Find or create database entry + dbConfig := localSecretsConfig.FindDatabaseByPath(absLocalPath) + if dbConfig == nil { + // Create new database entry + dbConfig = &SQLRsyncDatabase{ + Path: absLocalPath, + } + } + + // Use server from database config, or defaults if not set + if dbConfig.Server == "" { + dbConfig.Server = defaultsConfig.Defaults.Server + } + if serverURL == "" { + serverURL = dbConfig.Server + } + // Create remote client for WebSocket transport remoteClient, err := remote.New(&remote.Config{ - ServerURL: serverURL + "/pull/" + remotePath, + ServerURL: serverURL + "/sapi/pull/" + remotePath, Timeout: timeout, PingPong: false, Logger: logger.Named("remote"), @@ -463,17 +544,21 @@ func runPullSync(remotePath string, localPath string) error { if needsReadToken(localPath) { token := remoteClient.GetNewReadToken() - shareableConfigFile := localPath + "-sqlrsync" - shareableConfigContent := fmt.Sprintf(`#!/bin/bash -# https://sqlrsync.com/docs/pullfile -sqlrsync %s --pullKey=%s -`, remotePath, token) - - if err := os.WriteFile(shareableConfigFile, []byte(shareableConfigContent), 0755); err != nil { + dashSQLRsync := NewDashSQLRsync(localPath) + if err := dashSQLRsync.Write(remotePath, token); err != nil { return fmt.Errorf("failed to create shareable config file: %w", err) } } + // Update database config with latest info + dbConfig.LastUpdated = time.Now() + localSecretsConfig.UpdateOrAddDatabase(*dbConfig) + + // Save the updated config + if err := SaveLocalSecretsConfig(localSecretsConfig); err != nil { + logger.Warn("Failed to save local secrets config", zap.Error(err)) + } + logger.Info("Pull synchronization completed successfully") return nil } @@ -523,8 +608,8 @@ func Execute() error { } func init() { - rootCmd.Flags().StringVar(&authToken, "pullKey", "", "Read-only access key to retrieve a database from sqlrsync.com") - rootCmd.Flags().StringVarP(&serverURL, "server", "s", "wss://sqlrsync-workers.matt4659.workers.dev", "Server URL for push/pull operations") + rootCmd.Flags().StringVar(&authToken, "authKey", "", "Authentication key for push/pull operations") + rootCmd.Flags().StringVarP(&serverURL, "server", "s", "", "Server URL for push/pull operations (defaults to value in config)") rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose logging") rootCmd.Flags().BoolVar(&newReadToken, "storeNewReadToken", true, "After syncing, the server creates a new read-only token that is stored in the -sqlrsync file adjacent to the local database") rootCmd.Flags().BoolVar(&dryRun, "dry", false, "Perform a dry run without making changes") diff --git a/client/remote/client.go b/client/remote/client.go index 185cbd1..1671e81 100644 --- a/client/remote/client.go +++ b/client/remote/client.go @@ -164,6 +164,8 @@ type Config struct { PingPong bool AuthToken string RequestReadToken bool // the -sqlrsync file doesn't exist, so make a token + LocalHostname string + LocalAbsolutePath string } // Client handles WebSocket communication with the remote server @@ -238,7 +240,8 @@ func (c *Client) Connect() error { c.logger.Info("Connecting to remote server", zap.String("url", c.config.ServerURL)) u, err := url.Parse(c.config.ServerURL) - if err != nil { + if err != nil || !strings.HasPrefix(u.Scheme, "ws") { + fmt.Println("Server should be in the format: wss://server.com") return fmt.Errorf("invalid server URL: %w", err) } @@ -252,14 +255,26 @@ func (c *Client) Connect() error { defer connectCancel() headers := http.Header{} - headers.Set("Authorization", c.config.AuthToken) + if c.config.AuthToken == "" || len(c.config.AuthToken) <= 20 { + return fmt.Errorf("invalid authtoken: %s", c.config.AuthToken) + } else { + headers.Set("Authorization", c.config.AuthToken) + } + + if c.config.LocalHostname != "" { + headers.Set("X-LocalHostname", c.config.LocalHostname) + } + if c.config.LocalAbsolutePath != "" { + headers.Set("X-LocalAbsolutePath", c.config.LocalAbsolutePath) + } conn, response, err := dialer.DialContext(connectCtx, u.String(), headers) - defer response.Body.Close() if err != nil { + fmt.Println("Failed to connect:", err) respStr, _ := io.ReadAll(response.Body) return fmt.Errorf("%s", respStr) } + defer response.Body.Close() c.mu.Lock() c.conn = conn