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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# telepath
![GitHub release (latest by date)](https://img.shields.io/github/v/release/tech-thinker/telepath)
![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/tech-thinker/telepath/release.yaml)
[![Tests](https://github.com/tech-thinker/telepath/actions/workflows/go-tests.yaml/badge.svg)](https://github.com/tech-thinker/telepath/actions/workflows/go-tests.yaml)
![GitHub](https://img.shields.io/github/license/tech-thinker/telepath)
![GitHub All Releases](https://img.shields.io/github/downloads/tech-thinker/telepath/total)
![GitHub last commit](https://img.shields.io/github/last-commit/tech-thinker/telepath)
Expand Down Expand Up @@ -183,7 +184,7 @@ The configuration file is a JSON array of objects. Each object defines a tunnel.
> **Note:** Jump hosts are optional and can be nested multiple times.

### Tunnel Type
- **L (Local)**: Forwards traffic from **remote → local**
- **L (Local)**: Forwards traffic from **remote → local**
- **R (Remote)**: Forwards traffic from **local → remote**

### Example Topology Diagram
Expand Down
100 changes: 100 additions & 0 deletions models/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package models

import (
"testing"
"github.com/tech-thinker/telepath/constants"
)

func TestServerValidateSuccessKey(t *testing.T) {
s := &Server{
Host: "example.com",
Port: 22,
Username: "user",
AuthType: constants.CREDIENTIAL_KEY,
Key: "/tmp/key",
}
if err := s.Validate("test"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestServerValidateSuccessPass(t *testing.T) {
s := &Server{
Host: "example.com",
Port: 22,
Username: "user",
AuthType: constants.CREDIENTIAL_PASS,
Password: "cGFzc3dvcmQ=", // "password" base64
}
if err := s.Validate("test"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestServerValidateMissingFields(t *testing.T) {
tests := []struct {
name string
server *Server
want string
}{
{"missing host", &Server{Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.host` must be present."},
{"invalid port", &Server{Host: "h", Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.port` is invalid."},
{"missing username", &Server{Host: "h", Port: 22, AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}, "`test.username` must be present."},
{"invalid auth", &Server{Host: "h", Port: 22, Username: "u", AuthType: "XYZ", Password: "cGFz"}, "`test.authType` must be present."},
{"key auth missing key", &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_KEY}, "`test.key` must be present for authType KEY."},
{"pass auth missing password", &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS}, "`test.password` must be present for authType PASS."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.server.Validate("test")
if err == nil || err.Error() != tt.want {
t.Fatalf("expected error %q, got %v", tt.want, err)
}
})
}
}

func TestServerValidateJump(t *testing.T) {
// nested jump chain with valid inner server
inner := &Server{Host: "inner", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}
top := &Server{Host: "top", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz", Jump: inner}
if err := top.Validate("test"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestConfigValidateSuccess(t *testing.T) {
cfg := Config{
Name: "cfg1",
Type: "L",
LocalPort: 8080,
RemotePort: 80,
Server: &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"},
}
if err := cfg.Validate(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}

func TestConfigValidateErrors(t *testing.T) {
baseServer := &Server{Host: "h", Port: 22, Username: "u", AuthType: constants.CREDIENTIAL_PASS, Password: "cGFz"}
tests := []struct {
name string
cfg Config
want string
}{
{"missing name", Config{Type: "L", LocalPort: 1, RemotePort: 1, Server: baseServer}, "`name` must be present."},
{"invalid type", Config{Name: "n", Type: "X", LocalPort: 1, RemotePort: 1, Server: baseServer}, "`type` must be L or R."},
{"invalid localPort", Config{Name: "n", Type: "L", LocalPort: 0, RemotePort: 1, Server: baseServer}, "`localPort` is invalid."},
{"invalid remotePort", Config{Name: "n", Type: "L", LocalPort: 1, RemotePort: 0, Server: baseServer}, "`remotePort` is invalid."},
{"missing server", Config{Name: "n", Type: "L", LocalPort: 1, RemotePort: 1}, "`server` must be present."},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.cfg.Validate()
if err == nil || err.Error() != tt.want {
t.Fatalf("expected %q, got %v", tt.want, err)
}
})
}
}
101 changes: 101 additions & 0 deletions services/config_parser_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package services

import (
"os"
"testing"
)

func TestParseConfigComments(t *testing.T) {
raw := `[
// single line comment
{
"name": "test",
"type": "L",
"localPort": 8080,
"localHost": "127.0.0.1",
"remotePort": 80,
"remoteHost": "example.com",
"server": {
"host": "ssh.example.com",
"port": 22,
"username": "user",
"authType": "PASS",
"password": "cGFzc3dvcmQ="
}
} /* multi line comment */
]`
f, err := os.CreateTemp("", "config-*.json")
if err != nil {
t.Fatalf("temp file: %v", err)
}
defer os.Remove(f.Name())
if _, err := f.Write([]byte(raw)); err != nil {
t.Fatalf("write temp: %v", err)
}
f.Close()

cfgs, err := ParseConfig(f.Name())
if err != nil {
t.Fatalf("ParseConfig error: %v", err)
}
if len(cfgs) != 1 {
t.Fatalf("expected 1 config, got %d", len(cfgs))
}
if cfgs[0].Name != "test" {
t.Fatalf("unexpected config name: %s", cfgs[0].Name)
}
if cfgs[0].Server.AuthType != "PASS" {
t.Fatalf("unexpected authType: %s", cfgs[0].Server.AuthType)
}
}

func TestParseConfigMissingFile(t *testing.T) {
_, err := ParseConfig("/tmp/nonexistent-config-xyz.json")
if err == nil {
t.Fatal("expected error for missing file")
}
}

func TestParseConfigInvalidJSON(t *testing.T) {
f, _ := os.CreateTemp("", "bad-json")
f.WriteString("{ invalid json }")
f.Close()
defer os.Remove(f.Name())

_, err := ParseConfig(f.Name())
if err == nil {
t.Fatal("expected JSON parse error")
}
}

func TestParseConfigValidWithComments(t *testing.T) {
f, _ := os.CreateTemp("", "config")
f.WriteString(`[
// comment
{
"name": "test",
"type": "L",
"localPort": 8080,
"localHost": "127.0.0.1",
"remotePort": 80,
"remoteHost": "127.0.0.1",
"server": {
"host": "h",
"port": 22,
"username": "u",
"authType": "PASS",
"password": "p"
}
} /* comment */
]`)
f.Close()
defer os.Remove(f.Name())

cfgs, err := ParseConfig(f.Name())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(cfgs) != 1 {
t.Fatalf("expected 1 config, got %d", len(cfgs))
}
}
26 changes: 26 additions & 0 deletions services/connection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package services

import "testing"

func TestLiveConnectionIsActive(t *testing.T) {
lc := &LiveConnection{}
if lc.IsActive() {
t.Fatalf("expected inactive when cancel is nil")
}
lc2 := NewLiveConnection(func() {})
if !lc2.IsActive() {
t.Fatalf("expected active when cancel is set")
}
}

func TestNewLiveConnection(t *testing.T) {
cancelCalled := false
lc := NewLiveConnection(func() { cancelCalled = true })
if lc.cancel == nil {
t.Fatal("cancel should be set")
}
lc.cancel()
if !cancelCalled {
t.Fatal("cancel function not called")
}
}
123 changes: 123 additions & 0 deletions services/server_lifecycle_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
package services

import (
"context"
"sync"
"testing"
"time"
"github.com/tech-thinker/telepath/models"
)

func TestNewServerInitialization(t *testing.T) {
cfgs := []models.Config{{Name: "c1"}, {Name: "c2"}}
s := NewServer(cfgs)
if s == nil {
t.Fatal("NewServer returned nil")
}
}

func TestStartWithNilServer(t *testing.T) {
s := &srv{cfg: []models.Config{{Name: "test"}}, liveConnections: make(map[string]*LiveConnection)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.start(ctx, models.Config{Name: "test", Server: nil})
}

func TestStartTypeR(t *testing.T) {
s := &srv{cfg: []models.Config{}, liveConnections: make(map[string]*LiveConnection)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := models.Config{Name: "test", Type: "R", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1}
s.start(ctx, cfg)
}

func TestStartTypeL(t *testing.T) {
s := &srv{cfg: []models.Config{}, liveConnections: make(map[string]*LiveConnection)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := models.Config{Name: "test", Type: "L", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1}
s.start(ctx, cfg)
}

func TestBuildSSHClientNilServer(t *testing.T) {
s := &srv{}
c, err := s.buildSSHClient(models.Config{Server: nil})
if c != nil || err != nil {
t.Fatalf("expected nil,nil got %v,%v", c, err)
}
}

func TestStartAllMultiple(t *testing.T) {
cfgs := []models.Config{{Name: "c1", Server: nil}, {Name: "c2", Server: nil}}
s := &srv{cfg: cfgs, liveConnections: make(map[string]*LiveConnection)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
s.StartAll(ctx, &wg)
wg.Wait()
}

func TestStopAllMultiple(t *testing.T) {
s := &srv{liveConnections: map[string]*LiveConnection{
"c1": NewLiveConnection(func() {}),
"c2": NewLiveConnection(func() {}),
}}
s.StopAll()
}

func TestStartWithServerErrorAfterSSH(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := models.Config{
Name: "ssh-error",
Type: "L",
Server: &models.Server{Host: "invalid.host", Port: 22, Username: "u", AuthType: "PASS", Password: "p"},
LocalPort: 8080,
RemotePort: 80,
}
s := &srv{liveConnections: make(map[string]*LiveConnection)}
s.start(ctx, cfg)
time.Sleep(10 * time.Millisecond)
}

func TestStartWithAlreadyActiveConnection(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
cfg := models.Config{Name: "active", Server: nil}
s := &srv{liveConnections: make(map[string]*LiveConnection)}
active := NewLiveConnection(func() {})
s.liveConnections[cfg.Name] = active
s.start(ctx, cfg)
if _, ok := s.liveConnections[cfg.Name]; !ok {
t.Fatal("expected active connection to remain")
}
}

func TestStartWithTypeRLocalBoth(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s := &srv{liveConnections: make(map[string]*LiveConnection)}
cfgR := models.Config{Name: "r", Type: "R", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1}
s.start(ctx, cfgR)
s2 := &srv{liveConnections: make(map[string]*LiveConnection)}
cfgL := models.Config{Name: "l", Type: "L", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1}
s2.start(ctx, cfgL)
s3 := &srv{liveConnections: make(map[string]*LiveConnection)}
cfgEmpty := models.Config{Name: "empty", Type: "", Server: &models.Server{Host: "x", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}, LocalPort: 1, RemotePort: 1}
s3.start(ctx, cfgEmpty)
time.Sleep(10 * time.Millisecond)
}

func TestStartAllWithMixedConfigs(t *testing.T) {
cfgs := []models.Config{
{Name: "c1", Server: nil},
{Name: "c2", Server: &models.Server{Host: "invalid", Port: 22, Username: "u", AuthType: "PASS", Password: "p"}},
{Name: "c3", Server: nil},
}
s := &srv{cfg: cfgs, liveConnections: make(map[string]*LiveConnection)}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var wg sync.WaitGroup
s.StartAll(ctx, &wg)
wg.Wait()
}
24 changes: 24 additions & 0 deletions services/server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package services

import (
"testing"
"github.com/tech-thinker/telepath/models"
)

func TestMakeHostChainOrder(t *testing.T) {
hop2 := &models.Server{Host: "hop2", Port: 2222}
hop1 := &models.Server{Host: "hop1", Port: 2221, Jump: hop2}
final := &models.Server{Host: "final", Port: 22, Jump: hop1}

s := &srv{cfg: nil, liveConnections: nil}
chain := s.makeHostChain(final)
if len(chain) != 3 {
t.Fatalf("expected chain length 3, got %d", len(chain))
}
expected := []string{"hop2", "hop1", "final"}
for i, srv := range chain {
if srv.Host != expected[i] {
t.Fatalf("at index %d expected host %s, got %s", i, expected[i], srv.Host)
}
}
}
Loading
Loading