Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ tmp/
client/sqlrsync
client/sqlrsync
client/sqlrsync_simple
asciinema/
10 changes: 9 additions & 1 deletion client/Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# SQLite Rsync Go Client Makefile
.PHONY: all build clean test deps check-deps install-deps run help
.PHONY: all build clean test deps check-deps install-deps install run help

# Build configuration
BINARY_NAME := sqlrsync
Expand Down Expand Up @@ -55,6 +55,12 @@ build: $(SQLITE_RSYNC_LIB)
CGO_ENABLED=$(CGO_ENABLED) CGO_LDFLAGS="-L$(BRIDGE_LIB_DIR) -lsqlite_rsync" go build $(GOFLAGS) -ldflags="$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME) $(MAIN_FILE)
@echo "✓ Build complete: $(BUILD_DIR)/$(BINARY_NAME)"

# Install the binary to system path
install: build
@echo "Installing $(BINARY_NAME) to /usr/local/bin/..."
cp $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/$(BINARY_NAME)
@echo "✓ Install complete: /usr/local/bin/$(BINARY_NAME)"

# Build with debug symbols
build-debug: check-deps
@echo "Building $(BINARY_NAME) with debug symbols..."
Expand Down Expand Up @@ -104,6 +110,7 @@ help:
@echo " all - Check dependencies and build (default)"
@echo " build - Build the binary"
@echo " build-debug - Build with debug symbols"
@echo " install - Build and install binary to /usr/local/bin"
@echo " clean - Remove build artifacts"
@echo " deps - Download Go dependencies"
@echo " check-deps - Check system dependencies"
Expand All @@ -118,5 +125,6 @@ help:
@echo "Usage examples:"
@echo " make build"
@echo " make run"
@echo " make install"
@echo " make run-dry"
@echo " make test"
7 changes: 1 addition & 6 deletions client/auth/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,12 @@ func SaveLocalSecretsConfig(config *LocalSecretsConfig) error {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}

file, err := os.Create(path)
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
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)
Expand Down
65 changes: 33 additions & 32 deletions client/auth/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

// ResolveResult contains the resolved authentication information
type ResolveResult struct {
AccessToken string
AccessKey string
ReplicaID string
ServerURL string
RemotePath string
Expand All @@ -22,14 +22,14 @@ type ResolveResult struct {

// ResolveRequest contains the parameters for authentication resolution
type ResolveRequest struct {
LocalPath string
RemotePath string
ServerURL string
ProvidedPullKey string
ProvidedPushKey string
LocalPath string
RemotePath string
ServerURL string
ProvidedPullKey string
ProvidedPushKey string
ProvidedReplicaID string
Operation string // "pull", "push", "subscribe"
Logger *zap.Logger
Operation string // "pull", "push", "subscribe"
Logger *zap.Logger
}

// Resolver handles authentication and configuration resolution
Expand All @@ -53,24 +53,24 @@ func (r *Resolver) Resolve(req *ResolveRequest) (*ResolveResult, error) {
}

// 1. Try environment variable first
if token := os.Getenv("SQLRSYNC_AUTH_TOKEN"); token != "" {
r.logger.Debug("Using SQLRSYNC_AUTH_TOKEN from environment")
result.AccessToken = token
if key := os.Getenv("SQLRSYNC_AUTH_KEY"); key != "" {
r.logger.Debug("Using SQLRSYNC_AUTH_KEY from environment")
result.AccessKey = key
result.ReplicaID = req.ProvidedReplicaID
return result, nil
}

// 2. Try explicitly provided keys
if req.ProvidedPullKey != "" {
r.logger.Debug("Using provided pull key")
result.AccessToken = req.ProvidedPullKey
result.AccessKey = req.ProvidedPullKey
result.ReplicaID = req.ProvidedReplicaID
return result, nil
}

if req.ProvidedPushKey != "" {
r.logger.Debug("Using provided push key")
result.AccessToken = req.ProvidedPushKey
result.AccessKey = req.ProvidedPushKey
result.ReplicaID = req.ProvidedReplicaID
return result, nil
}
Expand All @@ -87,7 +87,7 @@ func (r *Resolver) Resolve(req *ResolveRequest) (*ResolveResult, error) {
if req.ServerURL == "wss://sqlrsync.com" {
if localSecretsConfig, err := LoadLocalSecretsConfig(); err == nil {
if dbConfig := localSecretsConfig.FindDatabaseByPath(absLocalPath); dbConfig != nil {
r.logger.Debug("Using server URL from local secrets config",
r.logger.Debug("Using server URL from local secrets config",
zap.String("configuredServer", dbConfig.Server),
zap.String("defaultServer", req.ServerURL))
result.ServerURL = dbConfig.Server
Expand All @@ -114,7 +114,7 @@ func (r *Resolver) Resolve(req *ResolveRequest) (*ResolveResult, error) {
if req.Operation == "push" {
if os.Getenv("SQLRSYNC_ADMIN_KEY") != "" {
r.logger.Debug("Using SQLRSYNC_ADMIN_KEY from environment")
result.AccessToken = os.Getenv("SQLRSYNC_ADMIN_KEY")
result.AccessKey = os.Getenv("SQLRSYNC_ADMIN_KEY")
result.ShouldPrompt = false
return result, nil
}
Expand All @@ -126,7 +126,7 @@ func (r *Resolver) Resolve(req *ResolveRequest) (*ResolveResult, error) {

// 5. If it's a pull, maybe no key needed
if req.Operation == "pull" || req.Operation == "subscribe" {
result.AccessToken = ""
result.AccessKey = ""
result.ShouldPrompt = false
return result, nil
}
Expand All @@ -138,7 +138,7 @@ func (r *Resolver) Resolve(req *ResolveRequest) (*ResolveResult, error) {
// resolveFromLocalSecrets attempts to resolve auth from local-secrets.toml
func (r *Resolver) resolveFromLocalSecrets(absLocalPath, serverURL string, result *ResolveResult) (*ResolveResult, error) {
r.logger.Debug("Attempting to resolve from local secrets", zap.String("absLocalPath", absLocalPath), zap.String("serverURL", serverURL))

localSecretsConfig, err := LoadLocalSecretsConfig()
if err != nil {
r.logger.Debug("Failed to load local secrets config", zap.Error(err))
Expand All @@ -162,14 +162,14 @@ func (r *Resolver) resolveFromLocalSecrets(absLocalPath, serverURL string, resul
}

if dbConfig.Server != serverURL {
r.logger.Debug("Server URL mismatch",
zap.String("configured", dbConfig.Server),
r.logger.Debug("Server URL mismatch",
zap.String("configured", dbConfig.Server),
zap.String("requested", serverURL))
return nil, fmt.Errorf("server URL mismatch: configured=%s, requested=%s", dbConfig.Server, serverURL)
}

r.logger.Debug("Found authentication in local secrets config")
result.AccessToken = dbConfig.PushKey
result.AccessKey = dbConfig.PushKey
result.ReplicaID = dbConfig.ReplicaID
result.RemotePath = dbConfig.RemotePath
result.ServerURL = dbConfig.Server
Expand All @@ -193,32 +193,33 @@ func (r *Resolver) resolveFromDashFile(localPath string, result *ResolveResult)
}

r.logger.Debug("Found authentication in -sqlrsync file")
result.AccessToken = dashSQLRsync.PullKey
result.AccessKey = dashSQLRsync.PullKey
result.ReplicaID = dashSQLRsync.ReplicaID
result.RemotePath = dashSQLRsync.RemotePath
result.ServerURL = dashSQLRsync.Server

return result, nil
}

// PromptForAdminKey prompts the user for an admin key
func (r *Resolver) PromptForAdminKey(serverURL string) (string, error) {
// PromptForKey prompts the user for an key
func (r *Resolver) PromptForKey(serverURL string, remotePath string, keyType string) (string, error) {
httpServer := strings.Replace(serverURL, "ws", "http", 1)
fmt.Println("No Key provided. Creating a new Replica? Get a key at " + httpServer + "/namespaces")
fmt.Print(" Enter an Account Admin Key to create a new Replica: ")
fmt.Println("Replica not found when using unauthenticated access. Try again using a key or check your spelling.")
fmt.Println(" Get a key at " + httpServer + "/namespaces or " + httpServer + "/" + remotePath)
fmt.Print(" Provide a key to " + keyType + ": ")

reader := bufio.NewReader(os.Stdin)
token, err := reader.ReadString('\n')
key, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read admin key: %w", err)
return "", fmt.Errorf("failed to read key: %w", err)
}

token = strings.TrimSpace(token)
if token == "" {
return "", fmt.Errorf("admin key cannot be empty")
key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("key cannot be empty")
}

return token, nil
return key, nil
}

// SavePushResult saves the result of a successful push operation
Expand Down Expand Up @@ -279,4 +280,4 @@ func (r *Resolver) CheckNeedsDashFile(localPath, remotePath string) bool {
}

return dashSQLRsync.RemotePath != remotePath
}
}
8 changes: 4 additions & 4 deletions client/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ func runSync(cmd *cobra.Command, args []string) error {
// Create sync coordinator
coordinator := sync.NewCoordinator(&sync.CoordinatorConfig{
ServerURL: serverURL,
ProvidedAuthToken: getAuthToken(),
ProvidedAuthKey: getAuthKey(),
ProvidedPullKey: pullKey,
ProvidedPushKey: pushKey,
ProvidedReplicaID: replicaID,
Expand Down Expand Up @@ -224,10 +224,10 @@ func determineOperation(args []string) (sync.Operation, string, string, error) {
return sync.Operation(0), "", "", fmt.Errorf("invalid arguments")
}

func getAuthToken() string {
func getAuthKey() string {
// Try environment variable first
if token := os.Getenv("SQLRSYNC_AUTH_TOKEN"); token != "" {
return token
if key := os.Getenv("SQLRSYNC_AUTH_KEY"); key != "" {
return key
}

// Try pull/push keys
Expand Down
14 changes: 7 additions & 7 deletions client/remote/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -401,9 +401,9 @@ type Config struct {
EnableTrafficInspection bool // Enable detailed traffic logging
InspectionDepth int // How many bytes to inspect (default: 32)
PingPong bool
AuthToken string
AuthKey string
ClientVersion string // version of the client software
SendKeyRequest bool // the -sqlrsync file doesn't exist, so make a token
SendKeyRequest bool // the -sqlrsync file doesn't exist, so make a key

SendConfigCmd bool // we don't have the version number or remote path
LocalHostname string
Expand Down Expand Up @@ -685,9 +685,9 @@ func (c *Client) Connect() error {

headers := http.Header{}

headers.Set("Authorization", c.config.AuthToken)
headers.Set("Authorization", c.config.AuthKey)

headers.Set("X-ClientVersion", c.config.ClientVersion);
headers.Set("X-ClientVersion", c.config.ClientVersion)

if c.config.WsID != "" {
headers.Set("X-ClientID", c.config.WsID)
Expand Down Expand Up @@ -882,9 +882,9 @@ func (c *Client) Read(buffer []byte) (int, error) {
if c.config.Subscribe {
return 1 * time.Hour
}
// Use a longer timeout if sync is completed to allow final transaction processing
// Use a shorter timeout if sync is completed to allow final transaction processing
if c.isSyncCompleted() {
return 2 * time.Second
return 1 * time.Second
}
return 30 * time.Second
}()):
Expand Down Expand Up @@ -1012,7 +1012,7 @@ func (c *Client) Close() {
if c.conn != nil {
// Send close message
closeMessage := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")
err := c.conn.WriteControl(websocket.CloseMessage, closeMessage, time.Now().Add(5*time.Second))
err := c.conn.WriteControl(websocket.CloseMessage, closeMessage, time.Now().Add(3*time.Second))
if err != nil {
c.logger.Debug("Error sending close message", zap.Error(err))
} else {
Expand Down
41 changes: 39 additions & 2 deletions client/subscription/manager.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package subscription

import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -38,7 +40,7 @@ type Message struct {
type ManagerConfig struct {
ServerURL string
ReplicaPath string
AuthToken string
AccessKey string
ReplicaID string
WsID string // websocket ID for client identification
ClientVersion string // version of the client software
Expand Down Expand Up @@ -199,7 +201,7 @@ func (m *Manager) doConnect() error {
u.Path = strings.TrimSuffix(u.Path, "/") + "/sapi/subscribe/" + m.config.ReplicaPath

headers := http.Header{}
headers.Set("Authorization", m.config.AuthToken)
headers.Set("Authorization", m.config.AccessKey)
if m.config.ReplicaID != "" {
headers.Set("X-ReplicaID", m.config.ReplicaID)
}
Expand Down Expand Up @@ -229,6 +231,20 @@ func (m *Manager) doConnect() error {
}
}

// Connect to remote server
if strings.Contains(err.Error(), "key is not authorized") || strings.Contains(err.Error(), "404 Path not found") {
if m.config.AccessKey == "" {
key, err := PromptForKey(m.config.ServerURL, m.config.ReplicaPath, "PULL")
if err != nil {
return fmt.Errorf("manager failed to get key interactively: %w", err)
}
m.config.AccessKey = key
return m.doConnect()
} else {
return fmt.Errorf("manager failed to connect to server: %w", err)
}
}

// Create a clean error message
var errorMsg strings.Builder
errorMsg.WriteString(fmt.Sprintf("HTTP %d (%s)", statusCode, statusText))
Expand Down Expand Up @@ -574,3 +590,24 @@ func (m *Manager) pingLoop() {
}
}
}

// PromptForKey prompts the user for an admin key
func PromptForKey(serverURL string, remotePath string, keyType string) (string, error) {
httpServer := strings.Replace(serverURL, "ws", "http", 1)
fmt.Println("Replica not found when using unauthenticated access. Try again using a key or check your spelling.")
fmt.Println(" Get a key at " + httpServer + "/namespaces or " + httpServer + "/" + remotePath)
fmt.Print(" Provide a key to " + keyType + ": ")

reader := bufio.NewReader(os.Stdin)
key, err := reader.ReadString('\n')
if err != nil {
return "", fmt.Errorf("failed to read admin key: %w", err)
}

key = strings.TrimSpace(key)
if key == "" {
return "", fmt.Errorf("admin key cannot be empty")
}

return key, nil
}
Loading