Skip to content

Commit

Permalink
Merge pull request #8 from Contextualist/feat-tailscale
Browse files Browse the repository at this point in the history
Tailscale integration
  • Loading branch information
Contextualist committed Sep 23, 2023
2 parents faceaa9 + 984fbcb commit 6b0d92d
Show file tree
Hide file tree
Showing 23 changed files with 926 additions and 196 deletions.
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
Highlights (aka "Why making another file-transfer tool?"):

- Designed for personal use; no need to copy-paste a token / code for each transfer
- Rendezvous service runs distributively on [serverless edge function](https://deno.com/deploy/docs),
- Rendezvous service runs distributively on [serverless edge function](https://stackoverflow.blog/2023/02/23/how-edge-functions-move-your-back-end-close-to-your-front-end/),
a robust solution with low latency worldwide. ([How does this work?](docs/mechanism.md))

Other features:
Expand All @@ -15,6 +15,7 @@ Other features:
- Compression (gzip)
- Cross platform: Linux, macOS, Windows
- Support transfering multiple files and directories
- Optional [Tailscale integration](docs/advanced.md#tailscale-integration)

See also [comparison table with similar tools](#similar-projects).

Expand Down Expand Up @@ -57,7 +58,7 @@ You can run the sender and receiver in arbitrary order.
Whenever both sides are up and running, they will attempt to establish a P2P connection.
If you see messages such as `rendezvous timeout`, at least one side is behind a firewall or a strict NAT that prohibits P2P connection.

For advanced configuration and self-hosting (it's free & takes only 5 minutes!), check out [the docs here](docs/advanced.md).
For advanced configuration and self-hosting, check out [the docs here](docs/advanced.md).


## Similar projects
Expand All @@ -68,7 +69,7 @@ For advanced configuration and self-hosting (it's free & takes only 5 minutes!),
| LAN | O | O | O | O | O |
| WAN (local ↔︎ remote) | O | O | O | P | O |
| WAN (remote ↔︎ remote) | | P | O | P | O |
| relay | | | | P | O |
| relay | | | P | P | O |
| p2p | | | O | O | O |
| distributive | | | O | O | |

Expand All @@ -86,3 +87,8 @@ Apart from the dependencies listed in [`go.mod`](go.mod), this project is also b
- [**mholt/archiver**](https://github.com/mholt/archiver): tar/untar implementation
- [**libp2p/go-reuseport**](https://github.com/libp2p/go-reuseport): address reuse for TCP hole-punching
- [**egoist/bina**](https://github.com/egoist/bina): installation script
- [**Tailscale**](https://tailscale.com), as one of the connection option, provides a painstaking implementation of NAT traversal and a distributive relay service

## Disclaimer

This project is not associated with Deno Land Inc. or Tailscale Inc.
94 changes: 60 additions & 34 deletions cmd/acp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,16 @@ package main

import (
"context"
"encoding/base64"
"errors"
"flag"
"fmt"
"io"
"os"

tea "github.com/charmbracelet/bubbletea"
"github.com/contextualist/acp/pkg/config"
"github.com/contextualist/acp/pkg/pnet"
"github.com/contextualist/acp/pkg/stream"
"github.com/contextualist/acp/pkg/tui"
)

Expand Down Expand Up @@ -62,13 +63,13 @@ func main() {
return
}
if *doSetup || *doSetupWith != "" {
checkErr(setup(*doSetupWith))
checkErr(config.Setup(*doSetupWith))
return
}

filenames := flag.Args()
conf := mustGetConfig()
conf.applyDefault()
conf := config.MustGetConfig()
conf.ApplyDefault()

ctx, userCancel := context.WithCancel(context.Background())
logger = tui.NewLoggerControl(*debug)
Expand All @@ -77,48 +78,61 @@ func main() {
tui.RunProgram(loggerModel, userCancel, *destination == "-")
}

func transfer(ctx context.Context, conf *Config, filenames []string, loggerModel tea.Model) {
func transfer(ctx context.Context, conf *config.Config, filenames []string, loggerModel tea.Model) {
pnet.SetLogger(logger)
stream.SetLogger(logger)
defer logger.End()

conn, err := pnet.HolePunching(
ctx,
conf.Server+"/v2/exchange",
conf.ID,
len(filenames) > 0,
pnet.HolePunchingOptions{
UseIPv6: conf.UseIPv6,
Ports: conf.Ports,
UPnP: conf.UPnP,
},
logger,
)
if errors.Is(err, context.Canceled) || !checkErr(err) {
sinfo := pnet.SelfInfo{ChanName: conf.ID}
strategy, errs := tryEach(conf.Strategy, func(name string) (s string, err error) {
var d stream.Dialer
if d, err = stream.GetDialer(name); err != nil {
return
}
if err = d.Init(*conf); err != nil {
return "", fmt.Errorf("failed to init dialer %s: %w", name, err)
}
d.SetInfo(&sinfo)
return name, nil
})
sinfo.Strategy = strategy
if len(strategy) == 0 {
checkErr(fmt.Errorf("none of the dialers from the strategy is available: %w", errors.Join(errs...)))
return
}

psk, err := base64.StdEncoding.DecodeString(conf.PSK)
if !checkErr(err) {
return
}
conn, err = encrypted(conn, psk)
info, err := pnet.ExchangeConnInfo(
ctx,
conf.Server+"/v2/exchange",
&sinfo,
conf.Ports[0],
conf.UseIPv6,
)
if !checkErr(err) {
return
}

stream, _ := conn.(io.ReadWriteCloser)
var status *tui.StatusControl
if !*debug {
status = tui.NewStatusControl()
stream = status.Monitor(stream)
logger.Next(tui.NewStatusModel(status))
}

var status interface{ Next(tea.Model) }
if len(filenames) > 0 {
var s io.WriteCloser
strategyFinal := strategyConsensus(strategy, info.Strategy)
s, err = tryUntil(strategyFinal, func(dn string) (io.WriteCloser, error) { return must(stream.GetDialer(dn)).IntoSender(ctx, *info) })
if !checkErr(err) {
return
}
s, status = monitor(s)
logger.Debugf("sending...")
err = sendFiles(filenames, stream)
err = sendFiles(filenames, s)
} else {
var s io.ReadCloser
strategyFinal := strategyConsensus(info.Strategy, strategy)
s, err = tryUntil(strategyFinal, func(dn string) (io.ReadCloser, error) { return must(stream.GetDialer(dn)).IntoReceiver(ctx, *info) })
if !checkErr(err) {
return
}
s, status = monitor(s)
logger.Debugf("receiving...")
err = receiveFiles(stream)
err = receiveFiles(s)
}

if !*debug {
Expand All @@ -127,10 +141,22 @@ func transfer(ctx context.Context, conf *Config, filenames []string, loggerModel
checkErr(err)
}

func monitor[T io.Closer](s T) (T, *tui.StatusControl[T]) {
var status *tui.StatusControl[T]
if !*debug {
status = tui.NewStatusControl[T]()
s = status.Monitor(s)
logger.Next(tui.NewStatusModel(status))
}
return s, status
}

func checkErr(err error) bool {
if err == nil {
return true
}
exitStatement = err.Error()
if !errors.Is(err, context.Canceled) {
exitStatement = err.Error()
}
return false
}
60 changes: 60 additions & 0 deletions cmd/acp/strategy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package main

import (
"errors"
"fmt"
)

// Merge strategy lists from two parties into a common one,
// following the precedency set by the first party.
func strategyConsensus(pa, pb []string) (c []string) {
pbSet := make(map[string]struct{}, len(pb))
for _, x := range pb {
pbSet[x] = struct{}{}
}

for _, x := range pa {
if _, ok := pbSet[x]; ok {
c = append(c, x)
}
}
logger.Debugf("strategy: a=%v, b=%v, consensus=%v", pa, pb, c)
return
}

// Map a func onto a slice, for each element returning a result or an error
func tryEach[U, V any](a []U, fn func(U) (V, error)) (r []V, errs []error) {
for _, x := range a {
y, err := fn(x)
if err != nil {
logger.Debugf("attempt failed: %v", err)
errs = append(errs, err)
continue
}
r = append(r, y)
}
return
}

// Map a func onto a slice, until returning the first successful result
func tryUntil[U, V any](a []U, fn func(U) (V, error)) (r V, err error) {
var errs []error
for _, x := range a {
r, err = fn(x)
if err != nil {
logger.Debugf("attempt failed: %v", err)
errs = append(errs, err)
continue
}
return
}
err = fmt.Errorf("all attempts failed: %w", errors.Join(errs...))
return
}

func must[T any](t T, err error) T {
if err != nil {
panic(err)
}
return t
}
8 changes: 0 additions & 8 deletions cmd/acp/stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,13 @@ import (
"archive/tar"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"

"github.com/klauspost/pgzip"
aead "github.com/shadowsocks/go-shadowsocks2/shadowaead"
)

func encrypted(conn net.Conn, psk []byte) (net.Conn, error) {
cipher, err := aead.Chacha20Poly1305(psk)
conn = aead.NewConn(conn, cipher)
return conn, err
}

func sendFiles(filenames []string, to io.WriteCloser) (err error) {
defer to.Close()
z := pgzip.NewWriter(to)
Expand Down
1 change: 1 addition & 0 deletions cmd/acp/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func tryUpdate(exe string, repo string, currTag string) error {
if err != nil {
return fmt.Errorf("failed to update: %w", err)
}
fmt.Println("acp has been updated")
return nil
}

Expand Down
11 changes: 11 additions & 0 deletions docs/advanced.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ List of configurable options:
- `[0]`: bind to a random port;
- `[9527]`: bind to port 9527;
- `[0,9527]`: bind to a random port and port 9527.
- `strategy` (default: `["tcp_punch"]`): List of dialers for connection attempts, ordered by preference.
Available dialers:
- `tcp_punch`: TCP hole-punching
- `tailscale`: TCP over Tailnet / Taildrop (requires Tailscale running)
- `upnp` (default: `false`): Request UPnP port mapping from supported router.
This may not work for random port.

Expand Down Expand Up @@ -54,3 +58,10 @@ acp - < tmp-file
# receiver
acp -d - > tmp-file
```


## Tailscale integration

Tailscale has a more robust NAT traversal implementation and [distributed relay fallback](https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp), so it is guarenteed to make connections in all cases. Acp can use Tailscale as a transport backend if you have Tailscale running on both side.

If you have Tailscale running before installing acp, Tailscale support is automatically enabled for acp. Otherwise you can set `strategy: ["tailscale","tcp-punch"]` in config to enable Tailscale support after installing Tailscale.
3 changes: 2 additions & 1 deletion docs/mechanism.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,5 @@ The following steps take place to create such mappings and use them for P2P conn
</p>

*Still curious about the implementation?*
You can find the rendezvous service in [edge/index.ts](../edge/index.ts) and the client-size of hole-punching in [pkg/pnet/p2p.go](../pkg/pnet/p2p.go).
You can find the rendezvous service in [edge/index.ts](../edge/index.ts) and the client-side of hole-punching in [pkg/pnet/p2p.go](../pkg/pnet/p2p.go).
BTW, you would probably find it interesting to read [Tailscale's exteneded discussion on NAT traversal](https://tailscale.com/blog/how-nat-traversal-works/).
13 changes: 10 additions & 3 deletions edge/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ type ConnInfo = Deno.ServeHandlerInfo
interface ClientInfo {
priAddr: string,
chanName: string,
nPlan: number,
strategy?: string[],
nPlan?: number,
tsAddr?: string,
tsCap?: number,
}

interface AddrPair {
Expand All @@ -13,7 +16,10 @@ interface AddrPair {

interface ReplyInfo {
peerAddrs: AddrPair[],
peerNPlan: number,
strategy?: string[],
peerNPlan?: number,
tsAddr?: string,
tsCap?: number,
}


Expand All @@ -25,12 +31,13 @@ async function handleExchangeV2(req: Request, connInfo: ConnInfo): Promise<Respo

const pubAddr = joinHostPort(connInfo.remoteAddr)
const conn = req.body!.getReader({ mode: "byob" })
const { priAddr, chanName, nPlan = 1 }: ClientInfo = JSON.parse(
const { priAddr, chanName, nPlan = 1, ...otherInfo }: ClientInfo = JSON.parse(
new TextDecoder().decode(await receivePacket(conn))
)
const reply: ReplyInfo = {
peerAddrs: [{ pubAddr, priAddr }],
peerNPlan: nPlan,
...otherInfo,
}
const x0 = JSON.stringify(reply)
//console.log(`accepted from ${x0}`)
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.8.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/fsnotify/fsnotify v1.6.0
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 // indirect
github.com/klauspost/compress v1.16.7 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY=
github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8=
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0 h1:iQTw/8FWTuc7uiaSepXwyf3o52HaUYcV+Tu66S3F5GA=
Expand Down Expand Up @@ -55,6 +57,7 @@ golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
Expand Down

0 comments on commit 6b0d92d

Please sign in to comment.