diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..f9a1421 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 3278c0e..43db4aa 100644 --- a/README.md +++ b/README.md @@ -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 `:`, the server opens a new yamux stream to the agent. +5. The agent connects to `localhost:` 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 diff --git a/cmd/yatun/main.go b/cmd/yatun/main.go index 2d991a9..74e04c0 100644 --- a/cmd/yatun/main.go +++ b/cmd/yatun/main.go @@ -1,10 +1,11 @@ package main import ( + "errors" "flag" "fmt" "io" - "log" + "os" "sync" "time" @@ -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{ @@ -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 { @@ -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 } @@ -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, @@ -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, @@ -153,7 +159,6 @@ func main() { } if _, err := tuiP.Run(); err != nil { - fmt.Printf("Cya %v", err) sess.Close() os.Exit(1) } diff --git a/cmd/yatund/main.go b/cmd/yatund/main.go index cbcc0da..d8ce9fb 100644 --- a/cmd/yatund/main.go +++ b/cmd/yatund/main.go @@ -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. @@ -17,6 +20,8 @@ func main() { panic(err) } + log.Printf("Listening on %v", listener.Addr().String()) + for { conn, err := listener.Accept() @@ -24,7 +29,7 @@ func main() { panic(err) } - sconn, err := server.NewServerConnection(conn) + sconn, err := server.NewServerConnection(conn, conf) if err != nil { panic(err) } diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4a3149b --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/server/server.go b/internal/server/server.go index 5080983..9fe22b0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -2,12 +2,14 @@ package server import ( "errors" + "fmt" "io" "log" "net" "sync" "time" + "github.com/KatIsCoding/yatun/internal/config" "github.com/KatIsCoding/yatun/internal/message" "github.com/hashicorp/yamux" ) @@ -16,6 +18,7 @@ type ServerConnection struct { agentSession *yamux.Session connectionType *message.MessageType connectionDetails *message.ConnectionDetailsMessageData + config config.ServerConfig } func (s *ServerConnection) handleInitialConfig(stream *yamux.Stream) error { @@ -118,11 +121,21 @@ func (s *ServerConnection) handleExternalConnections(serv net.Listener) error { } func (s *ServerConnection) sendAddressInfo(stream *yamux.Stream, server net.Listener) error { + addr := server.Addr().String() + if s.config.Domain != nil { + _, port, err := net.SplitHostPort(server.Addr().String()) + if err != nil { + panic(err) + } + + addr = fmt.Sprintf("%v:%v", *s.config.Domain, port) + } + m := message.TransportMessage{ Type: message.ResponseMessage, Data: &message.Response{ Ok: true, - Address: server.Addr().String(), + Address: addr, }, } @@ -164,8 +177,8 @@ func (s *ServerConnection) StartListeningAgents() error { // This means the agent stream disconnected, it is fine to just break here return errors.New("sess disconnected") } - - panic(err) + log.Printf("Unrecognized err: %v", err) + return err } // When we receive an agent stream, listen and parse for the first handshake and then we can start copying over / creating new sessions @@ -205,7 +218,7 @@ func (s *ServerConnection) StartListeningAgents() error { return nil } -func NewServerConnection(conn net.Conn) (*ServerConnection, error) { +func NewServerConnection(conn net.Conn, conf config.ServerConfig) (*ServerConnection, error) { sess, err := yamux.Server(conn, yamux.DefaultConfig()) if err != nil { return nil, err @@ -213,5 +226,6 @@ func NewServerConnection(conn net.Conn) (*ServerConnection, error) { return &ServerConnection{ agentSession: sess, connectionType: nil, + config: conf, }, nil }