Skip to content

Commit

Permalink
feat: Add SSH tunneling functionality for remote dependencies
Browse files Browse the repository at this point in the history
  • Loading branch information
yarlson committed Dec 4, 2024
1 parent 305a4d9 commit 19956d7
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 32 deletions.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ FTL is a deployment tool that reduces complexity for projects that don't require
- Integrated Nginx reverse proxy
- Multi-provider support (Hetzner, DigitalOcean, Linode, custom servers)
- Fetch and stream logs from deployed services
- Establish SSH tunnels to remote dependencies

## Installation

Expand Down Expand Up @@ -156,6 +157,48 @@ ftl logs [service] [flags]
ftl logs -n 50
```

### 5. Create SSH Tunnels

Establish SSH tunnels for your dependencies, allowing local access to services running on your server:

```bash
ftl tunnels [flags]
```

This command will:

- Connect to your server via SSH
- Forward local ports to remote ports for all dependencies defined in your configuration
- Allow you to interact with your dependencies locally as if they were running on your machine

#### Flags

- `-s`, `--server`: (Optional) Specify the server name or index to connect to, if multiple servers are defined.

#### Examples

- Establish tunnels to all dependency ports:

```bash
ftl tunnels
```

- Specify a server to connect to (if multiple servers are configured):

```bash
ftl tunnels --server my-project.example.com
```

Press `Ctrl+C` to terminate the tunnels when you're done.

#### Purpose

The `ftl tunnels` command is useful for:

- Accessing dependency services (e.g., databases) running on your server from your local machine
- Simplifying local development by connecting to remote services without modifying your code
- Testing and debugging your application against live dependencies

## How It Works

FTL manages deployments and log retrieval through these main components:
Expand Down Expand Up @@ -183,6 +226,13 @@ FTL manages deployments and log retrieval through these main components:
- Supports real-time streaming with the `-f` flag
- Allows limiting the number of log lines with the `-n` flag

### SSH Tunnels (`ftl tunnels`)

- Connects to your server via SSH
- Establishes port forwarding from local ports to remote ports for all defined dependencies
- Maintains active tunnels with keep-alive packets
- Allows for graceful shutdown upon user interruption (Ctrl+C)

## Use Cases

### Suitable For
Expand Down Expand Up @@ -241,6 +291,7 @@ dependencies:
volumes: [string] # Volume mappings (format: "volume:path")
env: # Environment variables
- KEY=value
ports: [int] # Ports to expose for SSH tunneling
volumes: [string] # Named volumes list
```
Expand All @@ -266,6 +317,7 @@ FTL supports two forms of environment variable substitution in the configuration
- **Environment Variables**: Set environment variables for services and dependencies, with support for environment variable substitution.
- **Service Dependencies**: Specify dependent services and their configurations.
- **Routing Rules**: Define custom routing paths and whether to strip prefixes.
- **SSH Tunnels**: Specify ports in dependencies to enable SSH tunneling for local access.

## Example Projects

Expand Down
139 changes: 139 additions & 0 deletions cmd/tunnels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package cmd

import (
"context"
"fmt"
"github.com/yarlson/ftl/pkg/config"
"os"
"os/signal"
"sync"
"syscall"
"time"

"github.com/spf13/cobra"

"github.com/yarlson/ftl/pkg/console"
"github.com/yarlson/ftl/pkg/ssh"
)

var tunnelsCmd = &cobra.Command{
Use: "tunnels",
Short: "Create SSH tunnels for dependencies",
Long: `Create SSH tunnels for all dependencies defined in ftl.yaml,
forwarding local ports to remote ports.`,
Run: runTunnels,
}

func init() {
rootCmd.AddCommand(tunnelsCmd)

tunnelsCmd.Flags().StringP("server", "s", "", "Server name or index to connect to")
}

func runTunnels(cmd *cobra.Command, args []string) {
sm := console.NewSpinnerManager()
sm.Start()
defer sm.Stop()

spinner := sm.AddSpinner("tunnels", "Establishing SSH tunnels")

cfg, err := parseConfig("ftl.yaml")
if err != nil {
spinner.ErrorWithMessagef("Failed to parse config file: %v", err)
return
}

serverName, _ := cmd.Flags().GetString("server")
serverConfig, err := selectServer(cfg, serverName)
if err != nil {
spinner.ErrorWithMessagef("Server selection failed: %v", err)
return
}

user := serverConfig.User

tunnels, err := collectDependencyTunnels(cfg)
if err != nil {
spinner.ErrorWithMessagef("Failed to collect dependencies: %v", err)
return
}
if len(tunnels) == 0 {
spinner.ErrorWithMessage("No dependencies with ports found in the configuration.")
return
}

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

var wg sync.WaitGroup
errorChan := make(chan error, len(tunnels))

for _, tunnel := range tunnels {
wg.Add(1)
go func(t TunnelConfig) {
defer wg.Done()
err := ssh.CreateSSHTunnel(ctx, serverConfig.Host, serverConfig.Port, user, serverConfig.SSHKey, t.LocalPort, t.RemoteAddr)
if err != nil {
errorChan <- fmt.Errorf("Tunnel %s -> %s failed: %v", t.LocalPort, t.RemoteAddr, err)
}
}(tunnel)
}

go func() {
wg.Wait()
close(errorChan)
}()

select {
case err := <-errorChan:
spinner.ErrorWithMessagef("Failed to establish tunnels: %v", err)
return
case <-time.After(2 * time.Second):
spinner.Complete()
}

sm.Stop()

console.Success("SSH tunnels established. Press Ctrl+C to exit.")

sigs := make(chan os.Signal, 1)
signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
<-sigs

console.Info("Shutting down tunnels...")
cancel()
time.Sleep(1 * time.Second)
}

func selectServer(cfg *config.Config, serverName string) (config.Server, error) {
if serverName != "" {
for _, srv := range cfg.Servers {
if srv.Host == serverName || srv.User == serverName {
return srv, nil
}
}
return config.Server{}, fmt.Errorf("server not found in configuration: %s", serverName)
} else if len(cfg.Servers) == 1 {
return cfg.Servers[0], nil
} else {
return config.Server{}, fmt.Errorf("multiple servers defined. Please specify a server using the --server flag")
}
}

type TunnelConfig struct {
LocalPort string
RemoteAddr string
}

func collectDependencyTunnels(cfg *config.Config) ([]TunnelConfig, error) {
var tunnels []TunnelConfig
for _, dep := range cfg.Dependencies {
for _, port := range dep.Ports {
tunnels = append(tunnels, TunnelConfig{
LocalPort: fmt.Sprintf("%d", port),
RemoteAddr: fmt.Sprintf("localhost:%d", port),
})
}
}
return tunnels, nil
}
2 changes: 2 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Service struct {
Env []string `yaml:"env"`
Forwards []string `yaml:"forwards"`
Recreate bool `yaml:"recreate"`
LocalPorts []int
}

type HealthCheck struct {
Expand All @@ -73,6 +74,7 @@ type Dependency struct {
Image string `yaml:"image" validate:"required"`
Volumes []string `yaml:"volumes" validate:"dive,volume_reference"`
Env []string `yaml:"env" validate:"dive"`
Ports []int `yaml:"ports" validate:"dive,min=1,max=65535"`
}

type Volume struct {
Expand Down
13 changes: 9 additions & 4 deletions pkg/deployment/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,10 +240,11 @@ func (d *Deployment) startProxy(ctx context.Context, project string, cfg *config

func (d *Deployment) startDependency(project string, dependency *config.Dependency) error {
service := &config.Service{
Name: dependency.Name,
Image: dependency.Image,
Volumes: dependency.Volumes,
Env: dependency.Env,
Name: dependency.Name,
Image: dependency.Image,
Volumes: dependency.Volumes,
Env: dependency.Env,
LocalPorts: dependency.Ports,
}
if err := d.deployService(project, service); err != nil {
return fmt.Errorf("failed to start container for %s: %v", dependency.Image, err)
Expand Down Expand Up @@ -408,6 +409,10 @@ func (d *Deployment) createContainer(project string, service *config.Service, su
args = append(args, "--health-timeout", fmt.Sprintf("%ds", int(service.HealthCheck.Timeout.Seconds())))
}

for _, port := range service.LocalPorts {
args = append(args, "-p", fmt.Sprintf("127.0.0.1:%d:%d", port, port))
}

if len(service.Forwards) > 0 {
for _, forward := range service.Forwards {
args = append(args, "-p", forward)
Expand Down
Loading

0 comments on commit 19956d7

Please sign in to comment.