initial commit: standalone SoftEther VPN client in Go
Built-in DHCP (raw Ethernet frames through tunnel), automatic reconnection, host route management, classless static routes (option 121/249), DNS config. Single static binary, Linux only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
829ca73b1b
340 changed files with 199140 additions and 0 deletions
385
cmd/softether-go/main.go
Normal file
385
cmd/softether-go/main.go
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
// softether-go is a standalone SoftEther VPN client with built-in DHCP and reconnection.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// softether-go -host <server> -user <username> -pass <password> [-hub HUB] [-port 443] [-tap tap0] [-insecure]
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.sagar.ch/sagar/softether-go/pkg/client"
|
||||
"git.sagar.ch/sagar/softether-go/pkg/dhcp"
|
||||
"git.sagar.ch/sagar/softether-go/pkg/tap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
host := flag.String("host", "", "SoftEther server hostname or IP")
|
||||
port := flag.Int("port", 443, "SoftEther server port")
|
||||
hub := flag.String("hub", "DEFAULT", "Virtual hub name")
|
||||
username := flag.String("user", "", "Authentication username")
|
||||
password := flag.String("pass", "", "Authentication password")
|
||||
plainPass := flag.Bool("plain-password", false, "Send password as plaintext (AuthType 2)")
|
||||
tapName := flag.String("tap", "", "TAP interface name (empty = kernel-assigned)")
|
||||
insecure := flag.Bool("insecure", false, "Skip TLS certificate verification")
|
||||
reconnectDelay := flag.Duration("reconnect-delay", 5*time.Second, "Delay between reconnection attempts")
|
||||
acceptDefaultGW := flag.Bool("accept-default-gateway", false, "Install DHCP-provided gateway as default route")
|
||||
acceptStaticRoutes := flag.Bool("accept-static-routes", false, "Install DHCP classless static routes (option 121/249)")
|
||||
acceptDNS := flag.Bool("accept-dns", false, "Set /etc/resolv.conf from DHCP-provided DNS servers")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if *host == "" || *username == "" {
|
||||
fmt.Fprintf(os.Stderr, "Usage: %s -host <server> -user <username> -pass <password> [-hub HUB] [-port 443] [-tap tap0] [-insecure]\n", os.Args[0])
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfg := client.Config{
|
||||
Host: *host,
|
||||
Port: *port,
|
||||
Hub: *hub,
|
||||
Username: *username,
|
||||
Password: *password,
|
||||
PlainPassword: *plainPass,
|
||||
InsecureSkipVerify: *insecure,
|
||||
}
|
||||
|
||||
// Resolve server IP for host route
|
||||
serverIP := resolveHost(*host)
|
||||
|
||||
// Add host route to server via current default gateway
|
||||
cleanupRoute := addServerRoute(serverIP)
|
||||
defer cleanupRoute()
|
||||
|
||||
// Open TAP device once — reused across reconnections
|
||||
dev, err := tap.Open(*tapName)
|
||||
if err != nil {
|
||||
log.Fatalf("tap open: %v", err)
|
||||
}
|
||||
defer dev.Close()
|
||||
log.Printf("tap interface: %s", dev.Name)
|
||||
|
||||
if err := dev.SetUp(); err != nil {
|
||||
log.Fatalf("tap up: %v", err)
|
||||
}
|
||||
|
||||
mac, err := dev.MAC()
|
||||
if err != nil {
|
||||
log.Fatalf("tap mac: %v", err)
|
||||
}
|
||||
log.Printf("tap mac: %s", mac)
|
||||
|
||||
// Signal handling
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
for {
|
||||
err := runSession(cfg, dev, mac, *acceptDefaultGW, *acceptStaticRoutes, *acceptDNS, sig)
|
||||
if err == errShutdown {
|
||||
log.Println("shutting down")
|
||||
return
|
||||
}
|
||||
log.Printf("session ended: %v", err)
|
||||
log.Printf("reconnecting in %v...", *reconnectDelay)
|
||||
|
||||
select {
|
||||
case <-sig:
|
||||
log.Println("shutting down")
|
||||
return
|
||||
case <-time.After(*reconnectDelay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errShutdown = fmt.Errorf("shutdown requested")
|
||||
|
||||
// runSession connects, runs DHCP, and bridges frames until disconnection or signal.
|
||||
func runSession(cfg client.Config, dev *tap.Device, mac net.HardwareAddr, acceptDefaultGW, acceptStaticRoutes, acceptDNS bool, sig chan os.Signal) error {
|
||||
log.Printf("connecting to %s:%d hub=%s user=%s", cfg.Host, cfg.Port, cfg.Hub, cfg.Username)
|
||||
|
||||
sess, err := client.Connect(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
log.Printf("connected: session=%s connection=%s", sess.SessionName, sess.ConnectionName)
|
||||
|
||||
tunnel := client.NewTunnel(sess)
|
||||
tunnel.StartKeepalive()
|
||||
defer tunnel.Close()
|
||||
|
||||
dhcpClient := dhcp.NewClient(mac)
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// bridgeReady gates TAP writes — closed once DHCP completes
|
||||
bridgeReady := make(chan struct{})
|
||||
|
||||
// Server → TAP: start reading immediately so DHCP responses are received
|
||||
go func() {
|
||||
for {
|
||||
frames, err := tunnel.ReadFrames()
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("read from server: %w", err)
|
||||
return
|
||||
}
|
||||
for _, frame := range frames {
|
||||
dhcpClient.FeedFrame(frame)
|
||||
select {
|
||||
case <-bridgeReady:
|
||||
if _, err := dev.Write(frame); err != nil {
|
||||
errCh <- fmt.Errorf("write to tap: %w", err)
|
||||
return
|
||||
}
|
||||
default:
|
||||
// Drop non-DHCP frames until bridge is ready
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Run DHCP through the tunnel
|
||||
lease, err := runDHCP(dhcpClient, tunnel)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dhcp: %w", err)
|
||||
}
|
||||
|
||||
// Configure TAP interface with lease
|
||||
cleanup, err := configureTAP(dev.Name, lease, acceptDefaultGW, acceptStaticRoutes, acceptDNS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("configure tap: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Open the bridge — TAP writes now flow
|
||||
close(bridgeReady)
|
||||
|
||||
// TAP → Server
|
||||
go func() {
|
||||
buf := make([]byte, 1600)
|
||||
for {
|
||||
n, err := dev.Read(buf)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("read from tap: %w", err)
|
||||
return
|
||||
}
|
||||
frame := make([]byte, n)
|
||||
copy(frame, buf[:n])
|
||||
if err := tunnel.WriteFrames([][]byte{frame}); err != nil {
|
||||
errCh <- fmt.Errorf("write to server: %w", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for error or signal
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
case <-sig:
|
||||
return errShutdown
|
||||
}
|
||||
}
|
||||
|
||||
// runDHCP performs a DHCP exchange through the VPN tunnel.
|
||||
func runDHCP(dc *dhcp.Client, tunnel *client.Tunnel) (*dhcp.Lease, error) {
|
||||
log.Println("starting DHCP exchange...")
|
||||
|
||||
sendFrame := func(frame []byte) error {
|
||||
return tunnel.WriteFrames([][]byte{frame})
|
||||
}
|
||||
|
||||
// Run DHCP with a generous timeout — server might take a moment
|
||||
lease, err := dc.Run(sendFrame, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ones, _ := lease.SubnetMask.Size()
|
||||
log.Printf("dhcp lease: ip=%s/%d gw=%s dns=%v ttl=%v",
|
||||
lease.ClientIP, ones, lease.Gateway, lease.DNS, lease.LeaseTime)
|
||||
for _, r := range lease.Routes {
|
||||
log.Printf("dhcp route: %s via %s", r.Dest.String(), r.Gateway)
|
||||
}
|
||||
return lease, nil
|
||||
}
|
||||
|
||||
// configureTAP sets the IP address, routes, and DNS on the TAP interface.
|
||||
// Returns a cleanup function that undoes the changes.
|
||||
func configureTAP(ifname string, lease *dhcp.Lease, acceptDefaultGW, acceptStaticRoutes, acceptDNS bool) (func(), error) {
|
||||
noop := func() {}
|
||||
ones, _ := lease.SubnetMask.Size()
|
||||
addr := fmt.Sprintf("%s/%d", lease.ClientIP, ones)
|
||||
|
||||
if err := run("ip", "addr", "add", addr, "dev", ifname); err != nil {
|
||||
return noop, fmt.Errorf("ip addr add: %w", err)
|
||||
}
|
||||
|
||||
if acceptStaticRoutes && len(lease.Routes) > 0 {
|
||||
for _, r := range lease.Routes {
|
||||
dest := r.Dest.String()
|
||||
if dest == "0.0.0.0/0" {
|
||||
if acceptDefaultGW {
|
||||
if err := run("ip", "route", "add", "default", "via", r.Gateway.String(), "dev", ifname, "metric", "50"); err != nil {
|
||||
log.Printf("warning: static default route: %v", err)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := run("ip", "route", "add", dest, "via", r.Gateway.String(), "dev", ifname); err != nil {
|
||||
log.Printf("warning: static route %s via %s: %v", dest, r.Gateway, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if acceptDefaultGW && lease.Gateway != nil && !hasDefaultRoute(lease.Routes) {
|
||||
if err := run("ip", "route", "add", "default", "via", lease.Gateway.String(), "dev", ifname, "metric", "50"); err != nil {
|
||||
log.Printf("warning: default route: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// DNS
|
||||
var savedResolv []byte
|
||||
if acceptDNS && len(lease.DNS) > 0 {
|
||||
savedResolv = backupResolv()
|
||||
writeResolv(lease.DNS)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
run("ip", "addr", "flush", "dev", ifname)
|
||||
if savedResolv != nil {
|
||||
restoreResolv(savedResolv)
|
||||
}
|
||||
}
|
||||
return cleanup, nil
|
||||
}
|
||||
|
||||
// hasDefaultRoute checks if classless static routes include a default route (0.0.0.0/0).
|
||||
// Per RFC 3442, when option 121 is present it overrides option 3 (Router).
|
||||
func hasDefaultRoute(routes []dhcp.Route) bool {
|
||||
for _, r := range routes {
|
||||
ones, bits := r.Dest.Mask.Size()
|
||||
if ones == 0 && bits == 32 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const resolvPath = "/etc/resolv.conf"
|
||||
|
||||
func backupResolv() []byte {
|
||||
data, err := os.ReadFile(resolvPath)
|
||||
if err != nil {
|
||||
log.Printf("warning: backup resolv.conf: %v", err)
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func writeResolv(servers []net.IP) {
|
||||
var buf strings.Builder
|
||||
buf.WriteString("# Generated by softether-go\n")
|
||||
for _, ip := range servers {
|
||||
fmt.Fprintf(&buf, "nameserver %s\n", ip)
|
||||
}
|
||||
if err := os.WriteFile(resolvPath, []byte(buf.String()), 0644); err != nil {
|
||||
log.Printf("warning: write resolv.conf: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("dns: set nameservers %v", servers)
|
||||
}
|
||||
|
||||
func restoreResolv(saved []byte) {
|
||||
if err := os.WriteFile(resolvPath, saved, 0644); err != nil {
|
||||
log.Printf("warning: restore resolv.conf: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("dns: restored resolv.conf")
|
||||
}
|
||||
|
||||
// resolveHost resolves a hostname to an IPv4 address. If it's already an IP, returns it.
|
||||
func resolveHost(host string) net.IP {
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip
|
||||
}
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
log.Printf("warning: could not resolve %s: %v", host, err)
|
||||
return nil
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if ip.To4() != nil {
|
||||
return ip.To4()
|
||||
}
|
||||
}
|
||||
log.Printf("warning: no IPv4 address for %s", host)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addServerRoute adds a host route to the VPN server via the current default gateway.
|
||||
// Returns a cleanup function that removes the route.
|
||||
func addServerRoute(serverIP net.IP) func() {
|
||||
noop := func() {}
|
||||
if serverIP == nil {
|
||||
return noop
|
||||
}
|
||||
|
||||
gw, dev := getDefaultGateway()
|
||||
if gw == nil {
|
||||
log.Println("warning: no default gateway found, skipping server route")
|
||||
return noop
|
||||
}
|
||||
|
||||
route := serverIP.String() + "/32"
|
||||
args := []string{"route", "add", route, "via", gw.String()}
|
||||
if dev != "" {
|
||||
args = append(args, "dev", dev)
|
||||
}
|
||||
|
||||
if err := run("ip", args...); err != nil {
|
||||
log.Printf("warning: add server route: %v", err)
|
||||
return noop
|
||||
}
|
||||
log.Printf("added route: %s via %s", route, gw)
|
||||
|
||||
return func() {
|
||||
run("ip", "route", "del", route)
|
||||
log.Printf("removed route: %s", route)
|
||||
}
|
||||
}
|
||||
|
||||
// getDefaultGateway parses `ip route` output to find the current default gateway.
|
||||
func getDefaultGateway() (net.IP, string) {
|
||||
out, err := exec.Command("ip", "route", "show", "default").Output()
|
||||
if err != nil {
|
||||
return nil, ""
|
||||
}
|
||||
// Format: "default via 10.0.0.1 dev eth0 ..."
|
||||
fields := strings.Fields(string(out))
|
||||
var gw net.IP
|
||||
var dev string
|
||||
for i, f := range fields {
|
||||
if f == "via" && i+1 < len(fields) {
|
||||
gw = net.ParseIP(fields[i+1])
|
||||
}
|
||||
if f == "dev" && i+1 < len(fields) {
|
||||
dev = fields[i+1]
|
||||
}
|
||||
}
|
||||
return gw, dev
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue