diff --git a/go.mod b/go.mod index bdd4fac..3e6d1c1 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/charmbracelet/lipgloss v0.12.1 github.com/gorilla/websocket v1.5.3 github.com/wailsapp/wails/v2 v2.11.0 + golang.org/x/text v0.22.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -52,5 +53,4 @@ require ( golang.org/x/sync v0.11.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/term v0.29.0 // indirect - golang.org/x/text v0.22.0 // indirect ) diff --git a/internal/app/form.go b/internal/app/form.go index 2942324..7c3695a 100644 --- a/internal/app/form.go +++ b/internal/app/form.go @@ -55,6 +55,7 @@ func (m *Model) handleProviderNav(dir string) { config.ProviderLocalhostRun, config.ProviderServeo, config.ProviderPinggy, + config.ProviderBore, } current := config.Provider(m.FormValues.Provider) diff --git a/internal/config/tunnel.go b/internal/config/tunnel.go index db9be31..27f70ad 100644 --- a/internal/config/tunnel.go +++ b/internal/config/tunnel.go @@ -8,6 +8,7 @@ const ( ProviderLocalhostRun Provider = "localhostrun" ProviderServeo Provider = "serveo" ProviderPinggy Provider = "pinggy" + ProviderBore Provider = "bore" ) type TunnelState string diff --git a/internal/i18n/helpers.go b/internal/i18n/helpers.go index 80fca84..dd9000b 100644 --- a/internal/i18n/helpers.go +++ b/internal/i18n/helpers.go @@ -35,6 +35,8 @@ func ProviderText(provider string) string { return T("provider_tunnelmole") case "localhostrun": return T("provider_localhostrun") + case "bore": + return T("provider_bore") default: return provider } diff --git a/internal/i18n/locales/en.yaml b/internal/i18n/locales/en.yaml index 6e212e5..74e81e6 100644 --- a/internal/i18n/locales/en.yaml +++ b/internal/i18n/locales/en.yaml @@ -45,6 +45,7 @@ provider_serveo: "Serveo" provider_cloudflared: "Cloudflared" provider_tunnelmole: "Tunnelmole" provider_localhostrun: "LocalhostRun" +provider_bore: "bore" # Actions connect: "Connect" diff --git a/internal/i18n/locales/es.yaml b/internal/i18n/locales/es.yaml index 3082eab..ea0fe7a 100644 --- a/internal/i18n/locales/es.yaml +++ b/internal/i18n/locales/es.yaml @@ -45,6 +45,7 @@ provider_serveo: "Serveo" provider_cloudflared: "Cloudflared" provider_tunnelmole: "Tunnelmole" provider_localhostrun: "LocalhostRun" +provider_bore: "bore" # Actions connect: "Conectar" diff --git a/internal/process/manager.go b/internal/process/manager.go index 29c2193..0986784 100644 --- a/internal/process/manager.go +++ b/internal/process/manager.go @@ -9,6 +9,7 @@ import ( "github.com/sthbryan/ftm/internal/config" "github.com/sthbryan/ftm/internal/providers" + "github.com/sthbryan/ftm/internal/providers/bore" "github.com/sthbryan/ftm/internal/providers/cloudflared" "github.com/sthbryan/ftm/internal/providers/pinggy" "github.com/sthbryan/ftm/internal/providers/ssh" @@ -85,6 +86,7 @@ func NewManager() *Manager { config.ProviderLocalhostRun: ssh.NewLocalhostRun(), config.ProviderServeo: ssh.NewServeo(), config.ProviderPinggy: pinggy.New(), + config.ProviderBore: bore.New(), }, } } diff --git a/internal/providers/bore/bore.go b/internal/providers/bore/bore.go new file mode 100644 index 0000000..a4411aa --- /dev/null +++ b/internal/providers/bore/bore.go @@ -0,0 +1,133 @@ +package bore + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/sthbryan/ftm/internal/config" + "github.com/sthbryan/ftm/internal/providers" +) + +type BoreProvider struct { + installer *Installer +} + +func New() providers.Provider { + configDir, _ := os.UserHomeDir() + if configDir == "" { + configDir = "." + } + baseDir := filepath.Join(configDir, ".config", "foundry-tunnel", "bin") + + return &BoreProvider{ + installer: NewInstaller(baseDir), + } +} + +func (p *BoreProvider) Name() string { + return "bore" +} + +func (p *BoreProvider) BinaryName() string { + if runtime.GOOS == "windows" { + return "bore.exe" + } + return "bore" +} + +func (p *BoreProvider) IsInstalled() bool { + if _, err := exec.LookPath(p.BinaryName()); err == nil { + return true + } + return p.installer.IsInstalled() +} + +func (p *BoreProvider) Install(progress chan<- providers.DownloadProgress) error { + return p.installer.Install(progress) +} + +func (p *BoreProvider) FindBinary() string { + if path, err := exec.LookPath(p.BinaryName()); err == nil { + return path + } + + if p.installer.IsInstalled() { + return p.installer.BoreBin() + } + + home, _ := os.UserHomeDir() + candidates := []string{ + filepath.Join(home, ".config", "foundry-tunnel", "bin", p.BinaryName()), + "/usr/local/bin/" + p.BinaryName(), + "/usr/bin/" + p.BinaryName(), + } + + if runtime.GOOS == "windows" { + candidates = []string{ + p.BinaryName(), + filepath.Join(os.Getenv("ProgramFiles"), "bore", p.BinaryName()), + } + } + + for _, path := range candidates { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +func (p *BoreProvider) Start(ctx context.Context, tunnel config.TunnelConfig, logWriter io.Writer) (*providers.Process, error) { + binary := p.FindBinary() + if binary == "" { + if err := p.installer.Install(nil); err != nil { + return nil, fmt.Errorf("failed to install bore: %w", err) + } + binary = p.installer.BoreBin() + } + + ctx, cancel := context.WithCancel(ctx) + + args := []string{ + "local", + fmt.Sprintf("%d", tunnel.LocalPort), + "--to", "bore.pub", + } + + cmd := exec.CommandContext(ctx, binary, args...) + cmd.Stdout = logWriter + cmd.Stderr = logWriter + + if err := cmd.Start(); err != nil { + cancel() + return nil, fmt.Errorf("failed to start bore: %w", err) + } + + return &providers.Process{ + Cancel: cancel, + }, nil +} + +var boreURLRegex = regexp.MustCompile(`bore\.pub:\d+`) + +func (p *BoreProvider) ParseURL(line string) string { + matches := boreURLRegex.FindString(line) + if matches != "" { + return matches + } + return "" +} + +func (p *BoreProvider) IsReady(line string) bool { + lineLower := strings.ToLower(line) + return strings.Contains(lineLower, "bore.pub") && + (strings.Contains(lineLower, "listening") || strings.Contains(lineLower, "port")) +} diff --git a/internal/providers/bore/installer.go b/internal/providers/bore/installer.go new file mode 100644 index 0000000..f99b463 --- /dev/null +++ b/internal/providers/bore/installer.go @@ -0,0 +1,119 @@ +package bore + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/sthbryan/ftm/internal/providers" +) + +const boreVersion = "v0.6.0" + +type Installer struct { + BaseDir string +} + +func NewInstaller(baseDir string) *Installer { + return &Installer{BaseDir: baseDir} +} + +func (i *Installer) BoreBin() string { + if runtime.GOOS == "windows" { + return filepath.Join(i.BaseDir, "bore.exe") + } + return filepath.Join(i.BaseDir, "bore") +} + +func (i *Installer) IsInstalled() bool { + if _, err := os.Stat(i.BoreBin()); err == nil { + return true + } + return false +} + +func (i *Installer) Install(progress chan<- providers.DownloadProgress) error { + if err := os.MkdirAll(i.BaseDir, 0755); err != nil { + return fmt.Errorf("failed to create base dir: %w", err) + } + + if i.IsInstalled() { + return nil + } + + platform := runtime.GOOS + arch := runtime.GOARCH + + var url string + var filename string + + switch platform { + case "darwin": + switch arch { + case "arm64": + filename = "bore-" + boreVersion + "-aarch64-apple-darwin.tar.gz" + url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename + case "amd64": + filename = "bore-" + boreVersion + "-x86_64-apple-darwin.tar.gz" + url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename + default: + return fmt.Errorf("unsupported architecture for macOS: %s (supported: arm64, amd64)", arch) + } + case "linux": + switch arch { + case "arm64": + filename = "bore-" + boreVersion + "-aarch64-unknown-linux-musl.tar.gz" + url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename + case "amd64": + filename = "bore-" + boreVersion + "-x86_64-unknown-linux-musl.tar.gz" + url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename + default: + return fmt.Errorf("unsupported architecture for Linux: %s (supported: arm64, amd64)", arch) + } + case "windows": + if arch == "amd64" { + filename = "bore-" + boreVersion + "-x86_64-pc-windows-msvc.zip" + url = "https://github.com/ekzhang/bore/releases/download/" + boreVersion + "/" + filename + } else { + return fmt.Errorf("unsupported architecture for Windows: %s (supported: amd64)", arch) + } + default: + return fmt.Errorf("unsupported platform: %s", platform) + } + + destArchive := filepath.Join(i.BaseDir, filename) + + if progress != nil { + progress <- providers.DownloadProgress{ + Percent: 10, + Current: 0, + Total: 100, + Name: "bore", + } + } + + if err := providers.DownloadWithProgress(url, destArchive, progress, "bore"); err != nil { + return fmt.Errorf("failed to download bore: %w", err) + } + + defer os.Remove(destArchive) + + if err := providers.ExtractArchive(destArchive, i.BaseDir, "bore"); err != nil { + return fmt.Errorf("failed to extract bore: %w", err) + } + + if _, err := os.Stat(i.BoreBin()); err != nil { + return fmt.Errorf("binary not found after install: %w", err) + } + + if progress != nil { + progress <- providers.DownloadProgress{ + Percent: 100, + Done: true, + Name: "bore", + } + } + + return nil +} \ No newline at end of file diff --git a/internal/providers/cloudflared/cloudflared.go b/internal/providers/cloudflared/cloudflared.go index 39e33b4..45f1282 100644 --- a/internal/providers/cloudflared/cloudflared.go +++ b/internal/providers/cloudflared/cloudflared.go @@ -77,10 +77,7 @@ func (p *CloudflaredProvider) FindBinary() string { func (p *CloudflaredProvider) Start(ctx context.Context, tunnel config.TunnelConfig, logWriter io.Writer) (*providers.Process, error) { binary := p.FindBinary() if binary == "" { - if err := p.installer.Install(nil); err != nil { - return nil, fmt.Errorf("failed to install cloudflared: %w", err) - } - binary = p.installer.CloudflaredBin() + return nil, fmt.Errorf("installing") } ctx, cancel := context.WithCancel(ctx) @@ -131,3 +128,11 @@ func (p *CloudflaredProvider) IsReady(line string) bool { return strings.Contains(line, "trycloudflare.com") || strings.Contains(line, "started tunnel") } + +func (p *CloudflaredProvider) IsInstalled() bool { + return p.installer.IsInstalled() +} + +func (p *CloudflaredProvider) Install(progress chan<- providers.DownloadProgress) error { + return p.installer.Install(progress) +} diff --git a/internal/providers/cloudflared/installer.go b/internal/providers/cloudflared/installer.go index f3fdd1c..9305365 100644 --- a/internal/providers/cloudflared/installer.go +++ b/internal/providers/cloudflared/installer.go @@ -3,7 +3,6 @@ package cloudflared import ( "fmt" "os" - "os/exec" "path/filepath" "runtime" "strings" @@ -12,15 +11,11 @@ import ( ) type Installer struct { - BaseDir string - downloader *providers.BaseDownloader + BaseDir string } func NewInstaller(baseDir string) *Installer { - return &Installer{ - BaseDir: baseDir, - downloader: providers.NewBaseDownloader(), - } + return &Installer{BaseDir: baseDir} } func (i *Installer) CloudflaredBin() string { @@ -61,18 +56,17 @@ func (i *Installer) Install(progress chan<- providers.DownloadProgress) error { } if strings.HasSuffix(url, ".tgz") { - tmpFile := binPath + ".tgz" - if err := i.downloader.Download(url, tmpFile, progress, "cloudflared"); err != nil { - os.Remove(tmpFile) + destArchive := binPath + ".tgz" + if err := providers.DownloadWithProgress(url, destArchive, progress, "cloudflared"); err != nil { return fmt.Errorf("download failed: %w", err) } - defer os.Remove(tmpFile) + defer os.Remove(destArchive) - if err := i.extractTgz(tmpFile, binPath); err != nil { + if err := providers.ExtractArchive(destArchive, i.BaseDir, "cloudflared"); err != nil { return fmt.Errorf("extract failed: %w", err) } } else { - if err := i.downloader.Download(url, binPath, progress, "cloudflared"); err != nil { + if err := providers.DownloadWithProgress(url, binPath, progress, "cloudflared"); err != nil { return fmt.Errorf("download failed: %w", err) } } @@ -81,10 +75,6 @@ func (i *Installer) Install(progress chan<- providers.DownloadProgress) error { return fmt.Errorf("binary not found after install: %w", err) } - if err := os.Chmod(binPath, 0755); err != nil { - return fmt.Errorf("chmod failed: %w", err) - } - if progress != nil { progress <- providers.DownloadProgress{ Percent: 100, @@ -119,27 +109,3 @@ func (i *Installer) cloudflaredURL() (string, error) { return "", fmt.Errorf("unsupported OS: %s", os) } } - -func (i *Installer) extractTgz(src, dest string) error { - cmd := exec.Command("tar", "-xzf", src, "-C", filepath.Dir(dest)) - if err := cmd.Run(); err != nil { - return err - } - - entries, err := os.ReadDir(filepath.Dir(dest)) - if err != nil { - return err - } - - for _, entry := range entries { - if !entry.IsDir() && entry.Name() == "cloudflared" { - extractedPath := filepath.Join(filepath.Dir(dest), entry.Name()) - if extractedPath != dest { - return os.Rename(extractedPath, dest) - } - return os.Chmod(dest, 0755) - } - } - - return fmt.Errorf("cloudflared binary not found in extracted archive") -} diff --git a/internal/providers/downloader.go b/internal/providers/downloader.go index 8bac83c..406ed8a 100644 --- a/internal/providers/downloader.go +++ b/internal/providers/downloader.go @@ -1,11 +1,15 @@ package providers import ( + "archive/tar" + "archive/zip" + "compress/gzip" "fmt" "io" "net/http" "os" "path/filepath" + "strings" ) type DownloadProgress struct { @@ -92,3 +96,109 @@ func downloadWithProgress(url, dest string, progress chan<- DownloadProgress, na return nil } + +func DownloadWithProgress(url, dest string, progress chan<- DownloadProgress, name string) error { + return downloadWithProgress(url, dest, progress, name) +} + +func ExtractTarGz(src, destDir, binaryName string) error { + file, err := os.Open(src) + if err != nil { + return err + } + defer file.Close() + + gzr, err := gzip.NewReader(file) + if err != nil { + return err + } + defer gzr.Close() + + tr := tar.NewReader(gzr) + for { + header, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + + if header.Typeflag == tar.TypeReg { + name := filepath.Base(header.Name) + if name == binaryName || name == binaryName+".exe" { + return extractFileToDir(tr, destDir, name) + } + } + } + + return fmt.Errorf("executable %s not found in archive", binaryName) +} + +func ExtractZip(src, destDir, binaryName string) error { + r, err := zip.OpenReader(src) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if !f.Mode().IsRegular() { + continue + } + name := filepath.Base(f.Name) + if name == binaryName || name == binaryName+".exe" { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + return extractFileToDir(rc, destDir, name) + } + } + + return fmt.Errorf("executable %s not found in archive", binaryName) +} + +func extractFileToDir(src io.Reader, destDir, filename string) error { + destPath := filepath.Join(destDir, filename) + tmpPath := destPath + ".tmp" + + out, err := os.Create(tmpPath) + if err != nil { + return err + } + + _, err = io.Copy(out, src) + if closeErr := out.Close(); closeErr != nil && err == nil { + err = closeErr + } + + if err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to write %s: %w", filename, err) + } + + if err := os.Chmod(tmpPath, 0755); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to chmod %s: %w", filename, err) + } + + if err := os.Rename(tmpPath, destPath); err != nil { + os.Remove(tmpPath) + return fmt.Errorf("failed to rename %s: %w", filename, err) + } + + return nil +} + +func ExtractArchive(src, destDir, binaryName string) error { + lowerSrc := strings.ToLower(src) + if strings.HasSuffix(lowerSrc, ".zip") { + return ExtractZip(src, destDir, binaryName) + } + if strings.HasSuffix(lowerSrc, ".tar.gz") || strings.HasSuffix(lowerSrc, ".tgz") { + return ExtractTarGz(src, destDir, binaryName) + } + return fmt.Errorf("unsupported archive format: %s", src) +} diff --git a/internal/web/handlers_status.go b/internal/web/handlers_status.go index 1a70ff2..5376105 100644 --- a/internal/web/handlers_status.go +++ b/internal/web/handlers_status.go @@ -24,6 +24,7 @@ func (h *Handlers) handleProviders(w http.ResponseWriter) { {"id": "localhostrun", "name": "localhost.run"}, {"id": "serveo", "name": "Serveo"}, {"id": "pinggy", "name": "Pinggy"}, + {"id": "bore", "name": "bore"}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(providers)