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
57 changes: 57 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Build

on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]

jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true

- name: Build yatund
run: |
GOOS=linux GOARCH=${{ matrix.arch }} CGO_ENABLED=0 go build -o build/yatund-${{ matrix.arch }} ./cmd/yatund/

- name: Build yatun
run: |
GOOS=linux GOARCH=${{ matrix.arch }} CGO_ENABLED=0 go build -o build/yatun-${{ matrix.arch }} ./cmd/yatun/

- uses: actions/upload-artifact@v4
with:
name: binaries-${{ matrix.arch }}
path: build/

release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with:
path: artifacts

- name: Collect binaries
run: |
mkdir release
find artifacts -type f -exec cp {} release/ \;

- uses: softprops/action-gh-release@v2
with:
draft: true
files: release/*
generate_release_notes: true
118 changes: 110 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,117 @@
# yatun
# yatun | Yet another tunel

Project structure:
**yatun** is an open-source TCP reverse tunneling tool (like ngrok/cloudflared) that exposes local services to the internet. It consists of a public relay server and a lightweight agent.

```
┌──────────────┐ TCP ┌──────────────┐ yamux ┌──────────────┐ TCP ┌─────────┐
│ Internet │──────────▶│ yatund │◀══════════▶│ yatun │────────▶│ local │
│ Client │ :ephemeral│ (server) │ session │ (agent) │ :port │ service │
└──────────────┘ └──────────────┘ └──────────────┘ └─────────┘
```

## Architecture

- **`yatund`** — The public relay server. Agents connect to it on port `5678`. For each agent, it opens an ephemeral TCP port on the server's public interface and forwards all incoming connections through a yamux session back to the agent.

- **`yatun`** — The agent (client) that runs alongside your local service. It connects to the yatund server, authenticates, and starts forwarding traffic. Features a real-time Bubble Tea TUI showing tunnel status, connection counts, and ping latency.

### How it works

1. The agent opens a TCP connection to `yatund:5678` and upgrades it to a yamux multiplexed session.
2. The agent sends connection details (subdomain name) over the first yamux stream.
3. The server opens an ephemeral TCP port, sends the public address back to the agent, and starts a heartbeat ping loop.
4. When an internet client connects to `<server>:<ephemeral-port>`, the server opens a new yamux stream to the agent.
5. The agent connects to `localhost:<port>` and bidirectionally copies data between the yamux stream and the local service.

## Security / TLS

By default all connections are **plain TCP with no encryption** — the link between the internet client and yatund, and the link between yatund and the agent, are both unencrypted.

There were too many ways to implement TLS (terminate at yatund, pass through, wrap the agent session, etc.) and none felt like an obvious clear choice, so it's left unimplemented for now. The practical workaround: put a **reverse proxy that handles TLS in front of yatund**. If you already run something like **Traefik**, **Caddy**, **nginx**, or **Cloudflare** (orange cloud), the TLS termination happens before traffic reaches yatund — and your setup is secure without any changes to yatun itself.

## Getting Started

### Prerequisites

- Go 1.26+
- A publicly accessible server for `yatund`

### Running the server

```bash
# Set your public domain (optional — used in address responses)
export DOMAIN=https://tunnel.example.com

go run ./cmd/yatund/
```

The server listens on port `5678` for agent connections.

### Running an agent

```bash
# Expose local port 8080 through the tunnel
go run ./cmd/yatun/ --port 8080 --server yatund.example.com
```

The agent opens a TUI showing connection status, the public address, active connections, and ping latency. Press `q` or `Ctrl+C` to quit, `c` to copy the public address to your clipboard.

### Deploying with Docker

```bash
docker compose -f deploy/docker-compose.yml up --build
```

## CLI Flags

### Agent (`yatun`)

| Flag | Default | Description |
|------------|-------------------------|------------------------------------|
| `--port` | (required) | Local port to expose |
| `--server` | `yatun.snowdev.one` | yatund server address |

### Server (`yatund`)

| Environment Variable | Description |
|----------------------|------------------------------------------|
| `DOMAIN` | Public-facing domain (used in responses) |

## Wire Protocol

Messages between agent and server use a simple binary format:

```
[1 byte: type][2 bytes: payload length (BE uint16)][N bytes: JSON payload]
```

| Type Byte | Message | Description |
|-----------|--------------------|------------------------------------|
| `A` | TCPConnection | Signal: agent wants TCP tunnel |
| `B` | ConnectionDetails | Handshake: subdomain info (JSON) |
| `C` | ResponseMessage | Server response with address (JSON)|
| `D` | PingMessage | Heartbeat from server (JSON) |

## Development

```
yatun/
├── go.mod
├── go.sum
├── README.md
├── Makefile
├── cmd/
│ ├── yatund/ # the server binary
│ ├── yatun/ # Agent/client binary
│ │ └── main.go
│ └── yatun/ # the agent/client binary
│ └── yatund/ # Server binary
│ └── main.go
├── deploy/
│ ├── Dockerfile
│ └── docker-compose.yml
├── internal/
│ ├── config/ # Server configuration (env vars)
│ ├── message/ # Wire protocol encoding/decoding
│ ├── server/ # Server tunneling logic
│ └── tui/ # Bubble Tea TUI for agent
└── web/ # (reserved)
```

## License

MIT
29 changes: 17 additions & 12 deletions cmd/yatun/main.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package main

import (
"errors"
"flag"
"fmt"
"io"
"log"

"os"
"sync"
"time"
Expand All @@ -26,7 +27,8 @@ func sendMsg(con *yamux.Stream, m message.TransportMessage) {
func clientServerComms(ses *yamux.Session, tuiP *tea.Program) {
con, err := ses.OpenStream()
if err != nil {
panic(err)
tuiP.Kill()
panic(errors.New("failed to accept new stream, is the server running?"))
}

dat := message.ConnectionDetailsMessageData{
Expand All @@ -42,7 +44,8 @@ func clientServerComms(ses *yamux.Session, tuiP *tea.Program) {
for {
msg, err := message.Decode(con)
if err != nil {
panic(err)
tuiP.Kill()
panic(errors.New("the server closed unexpectedly"))
}

switch msg.Type {
Expand All @@ -59,13 +62,14 @@ func clientServerComms(ses *yamux.Session, tuiP *tea.Program) {
}()
}

func initializeServerConnection(tuiP *tea.Program) (sess *yamux.Session, err error) {
conn, err := net.Dial("tcp", "0.0.0.0:5678")
func initializeServerConnection(tuiP *tea.Program, server string) (sess *yamux.Session, err error) {
conn, err := net.Dial("tcp", fmt.Sprintf("%v:5678", server))
if err != nil {
return
}

sess, err = yamux.Client(conn, nil)
c := yamux.DefaultConfig()
c.LogOutput = io.Discard
sess, err = yamux.Client(conn, c)
if err != nil {
return
}
Expand All @@ -79,9 +83,8 @@ func serverConnectionLoop(sess *yamux.Session, port *string, tuiP *tea.Program)
// TODO: After initial handshake is done, io.Copy from server (yatun) to internal target server
for {

stream, err := sess.Accept()
stream, err := sess.AcceptStream()
if err != nil {
log.Printf("Error accepting new stream \n%v", err)
// TODO: Maybe? send a message to the TUI so that the user knows it is having trouble getting new sessions from server
tuiP.Send(tui.SetState{
Err: err,
Expand Down Expand Up @@ -128,16 +131,19 @@ func serverConnectionLoop(sess *yamux.Session, port *string, tuiP *tea.Program)

func main() {
port := flag.String("port", "", "--port")
server := flag.String("server", "yatun.snowdev.one", "--server")

flag.Parse()
if port == nil || len(*port) == 0 {
panic("no port")
fmt.Printf("The --port parameter is missing\n")
flag.Usage()
return
}

tuiP := tui.BuildTUI()

//TODO: More info needs to be passed to the server so that it knows to which subdomain to match
sess, err := initializeServerConnection(tuiP)
sess, err := initializeServerConnection(tuiP, *server)
if err != nil {
go tuiP.Send(tui.SetState{
Err: err,
Expand All @@ -153,7 +159,6 @@ func main() {
}

if _, err := tuiP.Run(); err != nil {
fmt.Printf("Cya %v", err)
sess.Close()
os.Exit(1)
}
Expand Down
7 changes: 6 additions & 1 deletion cmd/yatund/main.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package main

import (
"log"
"net"

"github.com/KatIsCoding/yatun/internal/config"
"github.com/KatIsCoding/yatun/internal/server"
)

func main() {
conf := config.ReadFromEnv()

// So, we need to open a tcp server for external connections and another one for the agent.
// But the external should only open if an agent requests it, so the very first server is the agent one.
Expand All @@ -17,14 +20,16 @@ func main() {
panic(err)
}

log.Printf("Listening on %v", listener.Addr().String())

for {

conn, err := listener.Accept()
if err != nil {
panic(err)
}

sconn, err := server.NewServerConnection(conn)
sconn, err := server.NewServerConnection(conn, conf)
if err != nil {
panic(err)
}
Expand Down
32 changes: 32 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package config

import (
"log"

"net/url"
"strings"

"os"
)

type ServerConfig struct {
Domain *string
}

func ReadFromEnv() ServerConfig {
c := ServerConfig{}
domain, ok := os.LookupEnv("Domain")
if ok {
if !strings.Contains(domain, "//") {
domain = "//" + domain
}
u, err := url.Parse(domain)
if err != nil {
panic(err)
}
log.Printf("Host: %v", u.Hostname())
c.Domain = new(u.Hostname())
}

return c
}
Loading
Loading