Compare commits

..

12 Commits

Author SHA1 Message Date
Owen Schwartz
a1a439c75c Merge pull request #28 from fosrl/dev
MTLS, Connection Monitoring, time zone logger
2025-04-06 14:09:59 -04:00
Owen Schwartz
e7c8dbc1c8 Merge pull request #26 from progressive-kiwi/feat-mtls-support
Feat: mTLS support
2025-04-02 21:23:17 -04:00
progressive-kiwi
d28e3ca5e8 feat/mtls-support-cert: doc update, removing config.Endpoint loading duplicates, handling null-pointer case and some logging 2025-04-02 21:00:09 +02:00
progressive-kiwi
b41570eb2c feat/mtls-support-cert: config support 2025-04-01 20:43:42 +02:00
Owen
72e0adc1bf Monitor connection with pings and keep pining
Resolves #24
2025-03-30 19:31:55 -04:00
progressive-kiwi
435b638701 feat/mtls-support-cert-script 2025-03-31 00:52:48 +02:00
progressive-kiwi
9b3c82648b feat/mtls-support 2025-03-31 00:06:40 +02:00
Owen Schwartz
f713c294b2 Merge pull request #25 from firecat53/flake
Add flake for build and devshell.
2025-03-30 10:55:00 -04:00
Owen
b3e8bf7d12 Add LOGGER_TIMEZONE env to control the time zone
Closes #23

If the name is "" or "UTC", LoadLocation returns UTC. If the name is
"Local", LoadLocation returns Local.

Otherwise, the name is taken to be a location name corresponding to a
file in the IANA Time Zone database, such as "America/New_York".

LoadLocation looks for the IANA Time Zone database in the following
locations in order:

the directory or uncompressed zip file named by the ZONEINFO environment
variable
on a Unix system, the system standard installation location
$GOROOT/lib/time/zoneinfo.zip
the time/tzdata package, if it was imported
2025-03-30 10:52:07 -04:00
Scott Hansen
7852f11e8d Add flake for build and devshell.
Package named newt-pangolin to avoid conflicts with existing package name
2025-03-25 15:46:12 -07:00
Owen
2ff8df9a8d Merge branch 'dev' 2025-03-22 12:54:31 -04:00
Owen
9d80161ab7 Increases ping attempts to 15
Might help #7
2025-03-21 17:24:04 -04:00
14 changed files with 546 additions and 1209 deletions

4
.gitignore vendored
View File

@@ -1,4 +1,6 @@
newt
.DS_Store
bin/
nohup.out
.idea
*.iml
certs/

View File

@@ -37,8 +37,9 @@ When Newt receives WireGuard control messages, it will use the information encod
- `dns`: DNS server to use to resolve the endpoint
- `log-level` (optional): The log level to use. Default: INFO
- `updown` (optional): A script to be called when targets are added or removed.
Example:
- `tls-client-cert` (optional): Client certificate (p12 or pfx) for mTLS. See [mTLS](#mtls)
- Example:
```bash
./newt \
@@ -107,6 +108,38 @@ Returning a string from the script in the format of a target (`ip:dst` so `10.0.
You can look at updown.py as a reference script to get started!
### mTLS
Newt supports mutual TLS (mTLS) authentication, if the server has been configured to request a client certificate.
* Only PKCS12 (.p12 or .pfx) file format is accepted
* The PKCS12 file must contain:
* Private key
* Public certificate
* CA certificate
* Encrypted PKCS12 files are currently not supported
Examples:
```bash
./newt \
--id 31frd0uzbjvp721 \
--secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 \
--endpoint https://example.com \
--tls-client-cert ./client.p12
```
```yaml
services:
newt:
image: fosrl/newt
container_name: newt
restart: unless-stopped
environment:
- PANGOLIN_ENDPOINT=https://example.com
- NEWT_ID=2ix2t8xk22ubpfy
- NEWT_SECRET=nnisrfsdfc7prqsp9ewo1dvtvci50j5uiqotez00dgap0ii2
- TLS_CLIENT_CERT=./client.p12
```
## Build
### Container
@@ -125,6 +158,16 @@ Make sure to have Go 1.23.1 installed.
make local
```
### Nix Flake
```bash
nix build
```
Binary will be at `./result/bin/newt`
Development shell available with `nix develop`
## Licensing
Newt is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.

27
flake.lock generated Normal file
View File

@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1742669843,
"narHash": "sha256-G5n+FOXLXcRx+3hCJ6Rt6ZQyF1zqQ0DL0sWAMn2Nk0w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1e5b653dff12029333a6546c11e108ede13052eb",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

65
flake.nix Normal file
View File

@@ -0,0 +1,65 @@
{
description = "newt - A tunneling client for Pangolin";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs =
{ self, nixpkgs }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
in
{
packages = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = self.packages.${system}.pangolin-newt;
pangolin-newt = pkgs.buildGoModule {
pname = "pangolin-newt";
version = "1.1.2";
src = ./.;
vendorHash = "sha256-sTtiBBkZ9cuhWnrn2VG20kv4nzNFfdzP5p+ewESCjyM=";
meta = with pkgs.lib; {
description = "A tunneling client for Pangolin";
homepage = "https://github.com/fosrl/newt";
license = licenses.gpl3;
maintainers = [ ];
};
};
}
);
devShells = forAllSystems (
system:
let
pkgs = pkgsFor system;
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [
go
gopls
gotools
go-outline
gopkgs
godef
golint
];
};
}
);
};
}

18
go.mod
View File

@@ -5,27 +5,19 @@ go 1.23.1
toolchain go1.23.2
require (
github.com/google/gopacket v1.1.19
github.com/gorilla/websocket v1.5.3
github.com/vishvananda/netlink v1.3.0
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa
golang.org/x/net v0.33.0
golang.org/x/net v0.30.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259
software.sslmate.com/src/go-pkcs12 v0.5.0
)
require (
github.com/google/btree v1.1.2 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/mdlayher/genetlink v1.3.2 // indirect
github.com/mdlayher/netlink v1.7.2 // indirect
github.com/mdlayher/socket v0.5.1 // indirect
github.com/vishvananda/netns v0.0.4 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/time v0.7.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
)

52
go.sum
View File

@@ -2,55 +2,23 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o=
github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws=
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
github.com/vishvananda/netlink v1.3.0 h1:X7l42GfcV4S6E4vHTsw48qbrV+9PVojNfIhZcwQdrZk=
github.com/vishvananda/netlink v1.3.0/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4=
golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ=
golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
software.sslmate.com/src/go-pkcs12 v0.5.0 h1:EC6R394xgENTpZ4RltKydeDUjtlM5drOYIG9c6TVj2M=
software.sslmate.com/src/go-pkcs12 v0.5.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=

View File

@@ -53,7 +53,23 @@ func (l *Logger) log(level LogLevel, format string, args ...interface{}) {
if level < l.level {
return
}
timestamp := time.Now().Format("2006/01/02 15:04:05")
// Get timezone from environment variable or use local timezone
timezone := os.Getenv("LOGGER_TIMEZONE")
var location *time.Location
var err error
if timezone != "" {
location, err = time.LoadLocation(timezone)
if err != nil {
// If invalid timezone, fall back to local
location = time.Local
}
} else {
location = time.Local
}
timestamp := time.Now().In(location).Format("2006/01/02 15:04:05")
message := fmt.Sprintf(format, args...)
l.logger.Printf("%s: %s %s", level.String(), timestamp, message)
}

273
main.go
View File

@@ -13,7 +13,6 @@ import (
"os"
"os/exec"
"os/signal"
"runtime"
"strconv"
"strings"
"syscall"
@@ -22,7 +21,6 @@ import (
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/proxy"
"github.com/fosrl/newt/websocket"
"github.com/fosrl/newt/wg"
"golang.org/x/net/icmp"
"golang.org/x/net/ipv4"
@@ -57,7 +55,7 @@ func fixKey(key string) string {
// Decode from base64
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
logger.Fatal("Error decoding base64")
logger.Fatal("Error decoding base64:", err)
}
// Convert to hex
@@ -117,7 +115,12 @@ func ping(tnet *netstack.Net, dst string) error {
}
func startPingCheck(tnet *netstack.Net, serverIP string, stopChan chan struct{}) {
ticker := time.NewTicker(10 * time.Second)
initialInterval := 10 * time.Second
maxInterval := 60 * time.Second
currentInterval := initialInterval
consecutiveFailures := 0
ticker := time.NewTicker(currentInterval)
defer ticker.Stop()
go func() {
@@ -126,8 +129,34 @@ func startPingCheck(tnet *netstack.Net, serverIP string, stopChan chan struct{})
case <-ticker.C:
err := ping(tnet, serverIP)
if err != nil {
logger.Warn("Periodic ping failed: %v", err)
consecutiveFailures++
logger.Warn("Periodic ping failed (%d consecutive failures): %v",
consecutiveFailures, err)
logger.Warn("HINT: Do you have UDP port 51820 (or the port in config.yml) open on your Pangolin server?")
// Increase interval if we have consistent failures, with a maximum cap
if consecutiveFailures >= 3 && currentInterval < maxInterval {
// Increase by 50% each time, up to the maximum
currentInterval = time.Duration(float64(currentInterval) * 1.5)
if currentInterval > maxInterval {
currentInterval = maxInterval
}
ticker.Reset(currentInterval)
logger.Info("Increased ping check interval to %v due to consecutive failures",
currentInterval)
}
} else {
// On success, if we've backed off, gradually return to normal interval
if currentInterval > initialInterval {
currentInterval = time.Duration(float64(currentInterval) * 0.8)
if currentInterval < initialInterval {
currentInterval = initialInterval
}
ticker.Reset(currentInterval)
logger.Info("Decreased ping check interval to %v after successful ping",
currentInterval)
}
consecutiveFailures = 0
}
case <-stopChan:
logger.Info("Stopping ping check")
@@ -137,34 +166,97 @@ func startPingCheck(tnet *netstack.Net, serverIP string, stopChan chan struct{})
}()
}
// Function to track connection status and trigger reconnection as needed
func monitorConnectionStatus(tnet *netstack.Net, serverIP string, client *websocket.Client) {
const checkInterval = 30 * time.Second
connectionLost := false
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
// Try a ping to see if connection is alive
err := ping(tnet, serverIP)
if err != nil && !connectionLost {
// We just lost connection
connectionLost = true
logger.Warn("Connection to server lost. Continuous reconnection attempts will be made.")
// Notify the user they might need to check their network
logger.Warn("Please check your internet connection and ensure the Pangolin server is online.")
logger.Warn("Newt will continue reconnection attempts automatically when connectivity is restored.")
} else if err == nil && connectionLost {
// Connection has been restored
connectionLost = false
logger.Info("Connection to server restored!")
// Tell the server we're back
err := client.SendMessage("newt/wg/register", map[string]interface{}{
"publicKey": fmt.Sprintf("%s", privateKey.PublicKey()),
})
if err != nil {
logger.Error("Failed to send registration message after reconnection: %v", err)
} else {
logger.Info("Successfully re-registered with server after reconnection")
}
}
}
}
}
func pingWithRetry(tnet *netstack.Net, dst string) error {
const (
maxAttempts = 5
retryDelay = 2 * time.Second
initialMaxAttempts = 15
initialRetryDelay = 2 * time.Second
maxRetryDelay = 60 * time.Second // Cap the maximum delay
)
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
logger.Info("Ping attempt %d of %d", attempt, maxAttempts)
if err := ping(tnet, dst); err != nil {
lastErr = err
logger.Warn("Ping attempt %d failed: %v", attempt, err)
if attempt < maxAttempts {
time.Sleep(retryDelay)
continue
}
return fmt.Errorf("all ping attempts failed after %d tries, last error: %w",
maxAttempts, lastErr)
}
attempt := 1
retryDelay := initialRetryDelay
// First try with the initial parameters
logger.Info("Ping attempt %d", attempt)
if err := ping(tnet, dst); err == nil {
// Successful ping
return nil
} else {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
}
// This shouldn't be reached due to the return in the loop, but added for completeness
return fmt.Errorf("unexpected error: all ping attempts failed")
// Start a goroutine that will attempt pings indefinitely with increasing delays
go func() {
attempt = 2 // Continue from attempt 2
for {
logger.Info("Ping attempt %d", attempt)
if err := ping(tnet, dst); err != nil {
logger.Warn("Ping attempt %d failed: %v", attempt, err)
// Increase delay after certain thresholds but cap it
if attempt%5 == 0 && retryDelay < maxRetryDelay {
retryDelay = time.Duration(float64(retryDelay) * 1.5)
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
logger.Info("Increasing ping retry delay to %v", retryDelay)
}
time.Sleep(retryDelay)
attempt++
} else {
// Successful ping
logger.Info("Ping succeeded after %d attempts", attempt)
return
}
}
}()
// Return an error for the first batch of attempts (to maintain compatibility with existing code)
return fmt.Errorf("initial ping attempts failed, continuing in background")
}
func parseLogLevel(level string) logger.LogLevel {
@@ -215,9 +307,6 @@ func resolveDomain(domain string) (string, error) {
host = strings.TrimPrefix(host, "https://")
}
// if there are any trailing slashes, remove them
host = strings.TrimSuffix(host, "/")
// Lookup IP addresses
ips, err := net.LookupIP(host)
if err != nil {
@@ -251,18 +340,17 @@ func resolveDomain(domain string) (string, error) {
}
var (
endpoint string
id string
secret string
mtu string
mtuInt int
dns string
privateKey wgtypes.Key
err error
logLevel string
updownScript string
interfaceName string
generateAndSaveKeyTo string
endpoint string
id string
secret string
mtu string
mtuInt int
dns string
privateKey wgtypes.Key
err error
logLevel string
updownScript string
tlsPrivateKey string
)
func main() {
@@ -274,8 +362,7 @@ func main() {
dns = os.Getenv("DNS")
logLevel = os.Getenv("LOG_LEVEL")
updownScript = os.Getenv("UPDOWN_SCRIPT")
interfaceName = os.Getenv("INTERFACE")
generateAndSaveKeyTo = os.Getenv("GENERATE_AND_SAVE_KEY_TO")
tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT")
if endpoint == "" {
flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server")
@@ -298,11 +385,8 @@ func main() {
if updownScript == "" {
flag.StringVar(&updownScript, "updown", "", "Path to updown script to be called when targets are added or removed")
}
if interfaceName == "" {
flag.StringVar(&interfaceName, "interface", "wg1", "Name of the WireGuard interface")
}
if generateAndSaveKeyTo == "" {
flag.StringVar(&generateAndSaveKeyTo, "generateAndSaveKeyTo", "", "Path to save generated private key")
if tlsPrivateKey == "" {
flag.StringVar(&tlsPrivateKey, "tls-client-cert", "", "Path to client certificate used for mTLS")
}
// do a --version check
@@ -329,18 +413,21 @@ func main() {
if err != nil {
logger.Fatal("Failed to generate private key: %v", err)
}
var opt websocket.ClientOption
if tlsPrivateKey != "" {
opt = websocket.WithTLSConfig(tlsPrivateKey)
}
// Create a new client
client, err := websocket.NewClient(
id, // CLI arg takes precedence
secret, // CLI arg takes precedence
endpoint,
opt,
)
if err != nil {
logger.Fatal("Failed to create client: %v", err)
}
var wgService *wg.WireGuardService
// Create TUN device and network stack
var tun tun.Device
var tnet *netstack.Net
@@ -349,30 +436,6 @@ func main() {
var connected bool
var wgData WgData
if generateAndSaveKeyTo != "" {
// make sure we are running on linux
if runtime.GOOS != "linux" {
logger.Fatal("Tunnel management is only supported on Linux right now!")
os.Exit(1)
}
var host = endpoint
if strings.HasPrefix(host, "http://") {
host = strings.TrimPrefix(host, "http://")
} else if strings.HasPrefix(host, "https://") {
host = strings.TrimPrefix(host, "https://")
}
host = strings.TrimSuffix(host, "/")
// Create WireGuard service
wgService, err = wg.NewWireGuardService(interfaceName, mtuInt, generateAndSaveKeyTo, host, id, client)
if err != nil {
logger.Fatal("Failed to create WireGuard service: %v", err)
}
defer wgService.Close()
}
client.RegisterHandler("newt/terminate", func(msg websocket.WSMessage) {
logger.Info("Received terminate message")
if pm != nil {
@@ -393,13 +456,8 @@ func main() {
if connected {
logger.Info("Already connected! But I will send a ping anyway...")
// ping(tnet, wgData.ServerIP)
err = pingWithRetry(tnet, wgData.ServerIP)
if err != nil {
// Handle complete failure after all retries
logger.Warn("Failed to ping %s: %v", wgData.ServerIP, err)
logger.Warn("HINT: Do you have UDP port 51820 (or the port in config.yml) open on your Pangolin server?")
}
// Even if pingWithRetry returns an error, it will continue trying in the background
_ = pingWithRetry(tnet, wgData.ServerIP) // Ignoring initial error as pings will continue
return
}
@@ -414,10 +472,6 @@ func main() {
return
}
if wgService != nil {
wgService.SetServerPubKey(wgData.PublicKey)
}
logger.Info("Received: %+v", msg)
tun, tnet, err = netstack.CreateNetTUN(
[]netip.Addr{netip.MustParseAddr(wgData.TunnelIP)},
@@ -458,18 +512,18 @@ persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey(
}
logger.Info("WireGuard device created. Lets ping the server now...")
// Ping to bring the tunnel up on the server side quickly
// ping(tnet, wgData.ServerIP)
err = pingWithRetry(tnet, wgData.ServerIP)
if err != nil {
// Handle complete failure after all retries
logger.Error("Failed to ping %s: %v", wgData.ServerIP, err)
fmt.Sprintf("%s", privateKey)
}
// Even if pingWithRetry returns an error, it will continue trying in the background
_ = pingWithRetry(tnet, wgData.ServerIP)
// Always mark as connected and start the proxy manager regardless of initial ping result
// as the pings will continue in the background
if !connected {
logger.Info("Starting ping check")
startPingCheck(tnet, wgData.ServerIP, pingStopChan)
// Start connection monitoring in a separate goroutine
go monitorConnectionStatus(tnet, wgData.ServerIP, client)
}
// Create proxy manager
@@ -486,13 +540,6 @@ persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey(
updateTargets(pm, "add", wgData.TunnelIP, "udp", TargetData{Targets: wgData.Targets.UDP})
}
// first make sure the wpgService has a port
if wgService != nil {
// add a udp proxy for localost and the wgService port
// TODO: make sure this port is not used in a target
pm.AddTarget("udp", wgData.TunnelIP, int(wgService.Port), fmt.Sprintf("127.0.0.1:%d", wgService.Port))
}
err = pm.Start()
if err != nil {
logger.Error("Failed to start proxy manager: %v", err)
@@ -591,20 +638,10 @@ persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey(
return err
}
if wgService != nil {
wgService.LoadRemoteConfig()
}
logger.Info("Sent registration message")
return nil
})
client.OnTokenUpdate(func(token string) {
if wgService != nil {
wgService.SetToken(token)
}
})
// Connect to the WebSocket server
if err := client.Connect(); err != nil {
logger.Fatal("Failed to connect to server: %v", err)
@@ -614,23 +651,13 @@ persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey(
// Wait for interrupt signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
sigReceived := <-sigCh
dev.Close()
if wgService != nil {
wgService.Close()
// Cleanup
logger.Info("Received %s signal, stopping", sigReceived.String())
if dev != nil {
dev.Close()
}
if pm != nil {
pm.Stop()
}
if client != nil {
client.Close()
}
logger.Info("Exiting...")
os.Exit(0)
}
func parseTargetData(data interface{}) (TargetData, error) {

View File

@@ -1,202 +0,0 @@
package network
import (
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net"
"time"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/vishvananda/netlink"
"golang.org/x/net/bpf"
"golang.org/x/net/ipv4"
)
const (
udpProtocol = 17
// EmptyUDPSize is the size of an empty UDP packet
EmptyUDPSize = 28
timeout = time.Second * 10
)
// Server stores data relating to the server
type Server struct {
Hostname string
Addr *net.IPAddr
Port uint16
}
// PeerNet stores data about a peer's endpoint
type PeerNet struct {
Resolved bool
IP net.IP
Port uint16
NewtID string
}
// GetClientIP gets source ip address that will be used when sending data to dstIP
func GetClientIP(dstIP net.IP) net.IP {
routes, err := netlink.RouteGet(dstIP)
if err != nil {
log.Fatalln("Error getting route:", err)
}
return routes[0].Src
}
// HostToAddr resolves a hostname, whether DNS or IP to a valid net.IPAddr
func HostToAddr(hostStr string) *net.IPAddr {
remoteAddrs, err := net.LookupHost(hostStr)
if err != nil {
log.Fatalln("Error parsing remote address:", err)
}
for _, addrStr := range remoteAddrs {
if remoteAddr, err := net.ResolveIPAddr("ip4", addrStr); err == nil {
return remoteAddr
}
}
return nil
}
// SetupRawConn creates an ipv4 and udp only RawConn and applies packet filtering
func SetupRawConn(server *Server, client *PeerNet) *ipv4.RawConn {
packetConn, err := net.ListenPacket("ip4:udp", client.IP.String())
if err != nil {
log.Fatalln("Error creating packetConn:", err)
}
rawConn, err := ipv4.NewRawConn(packetConn)
if err != nil {
log.Fatalln("Error creating rawConn:", err)
}
ApplyBPF(rawConn, server, client)
return rawConn
}
// ApplyBPF constructs a BPF program and applies it to the RawConn
func ApplyBPF(rawConn *ipv4.RawConn, server *Server, client *PeerNet) {
const ipv4HeaderLen = 20
const srcIPOffset = 12
const srcPortOffset = ipv4HeaderLen + 0
const dstPortOffset = ipv4HeaderLen + 2
ipArr := []byte(server.Addr.IP.To4())
ipInt := uint32(ipArr[0])<<(3*8) + uint32(ipArr[1])<<(2*8) + uint32(ipArr[2])<<8 + uint32(ipArr[3])
bpfRaw, err := bpf.Assemble([]bpf.Instruction{
bpf.LoadAbsolute{Off: srcIPOffset, Size: 4},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: ipInt, SkipFalse: 5, SkipTrue: 0},
bpf.LoadAbsolute{Off: srcPortOffset, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(server.Port), SkipFalse: 3, SkipTrue: 0},
bpf.LoadAbsolute{Off: dstPortOffset, Size: 2},
bpf.JumpIf{Cond: bpf.JumpEqual, Val: uint32(client.Port), SkipFalse: 1, SkipTrue: 0},
bpf.RetConstant{Val: 1<<(8*4) - 1},
bpf.RetConstant{Val: 0},
})
if err != nil {
log.Fatalln("Error assembling BPF:", err)
}
err = rawConn.SetBPF(bpfRaw)
if err != nil {
log.Fatalln("Error setting BPF:", err)
}
}
// MakePacket constructs a request packet to send to the server
func MakePacket(payload []byte, server *Server, client *PeerNet) []byte {
buf := gopacket.NewSerializeBuffer()
opts := gopacket.SerializeOptions{
FixLengths: true,
ComputeChecksums: true,
}
ipHeader := layers.IPv4{
SrcIP: client.IP,
DstIP: server.Addr.IP,
Version: 4,
TTL: 64,
Protocol: layers.IPProtocolUDP,
}
udpHeader := layers.UDP{
SrcPort: layers.UDPPort(client.Port),
DstPort: layers.UDPPort(server.Port),
}
payloadLayer := gopacket.Payload(payload)
udpHeader.SetNetworkLayerForChecksum(&ipHeader)
gopacket.SerializeLayers(buf, opts, &ipHeader, &udpHeader, &payloadLayer)
return buf.Bytes()
}
// SendPacket sends packet to the Server
func SendPacket(packet []byte, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
fullPacket := MakePacket(packet, server, client)
_, err := conn.WriteToIP(fullPacket, server.Addr)
return err
}
// SendDataPacket sends a JSON payload to the Server
func SendDataPacket(data interface{}, conn *ipv4.RawConn, server *Server, client *PeerNet) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
return SendPacket(jsonData, conn, server, client)
}
// RecvPacket receives a UDP packet from server
func RecvPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, int, error) {
err := conn.SetReadDeadline(time.Now().Add(timeout))
if err != nil {
return nil, 0, err
}
response := make([]byte, 4096)
n, err := conn.Read(response)
if err != nil {
return nil, n, err
}
return response, n, nil
}
// RecvDataPacket receives and unmarshals a JSON packet from server
func RecvDataPacket(conn *ipv4.RawConn, server *Server, client *PeerNet) ([]byte, error) {
response, n, err := RecvPacket(conn, server, client)
if err != nil {
return nil, err
}
// Extract payload from UDP packet
payload := response[EmptyUDPSize:n]
return payload, nil
}
// ParseResponse takes a response packet and parses it into an IP and port
func ParseResponse(response []byte) (net.IP, uint16) {
ip := net.IP(response[:4])
port := binary.BigEndian.Uint16(response[4:6])
return ip, port
}
func parseForBPF(response []byte) (srcIP net.IP, srcPort uint16, dstPort uint16) {
srcIP = net.IP(response[12:16])
srcPort = binary.BigEndian.Uint16(response[20:22])
dstPort = binary.BigEndian.Uint16(response[22:24])
return
}

125
self-signed-certs-for-mtls.sh Executable file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env bash
set -eu
echo -n "Enter username for certs (eg alice): "
read CERT_USERNAME
echo
echo -n "Enter domain of user (eg example.com): "
read DOMAIN
echo
# Prompt for password at the start
echo -n "Enter password for certificate: "
read -s PASSWORD
echo
echo -n "Confirm password: "
read -s PASSWORD2
echo
if [ "$PASSWORD" != "$PASSWORD2" ]; then
echo "Passwords don't match!"
exit 1
fi
CA_DIR="./certs/ca"
CLIENT_DIR="./certs/clients"
FILE_PREFIX=$(echo "$CERT_USERNAME-at-$DOMAIN" | sed 's/\./-/')
mkdir -p "$CA_DIR"
mkdir -p "$CLIENT_DIR"
if [ ! -f "$CA_DIR/ca.crt" ]; then
# Generate CA private key
openssl genrsa -out "$CA_DIR/ca.key" 4096
echo "CA key ✅"
# Generate CA root certificate
openssl req -x509 -new -nodes \
-key "$CA_DIR/ca.key" \
-sha256 \
-days 3650 \
-out "$CA_DIR/ca.crt" \
-subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=ca.$DOMAIN"
echo "CA cert ✅"
fi
# Generate client private key
openssl genrsa -aes256 -passout pass:"$PASSWORD" -out "$CLIENT_DIR/$FILE_PREFIX.key" 2048
echo "Client key ✅"
# Generate client Certificate Signing Request (CSR)
openssl req -new \
-key "$CLIENT_DIR/$FILE_PREFIX.key" \
-out "$CLIENT_DIR/$FILE_PREFIX.csr" \
-passin pass:"$PASSWORD" \
-subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=$CERT_USERNAME@$DOMAIN"
echo "Client cert ✅"
echo -n "Signing client cert..."
# Create client certificate configuration file
cat > "$CLIENT_DIR/$FILE_PREFIX.ext" << EOF
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = $DOMAIN
EOF
# Generate client certificate signed by CA
openssl x509 -req \
-in "$CLIENT_DIR/$FILE_PREFIX.csr" \
-CA "$CA_DIR/ca.crt" \
-CAkey "$CA_DIR/ca.key" \
-CAcreateserial \
-out "$CLIENT_DIR/$FILE_PREFIX.crt" \
-days 365 \
-sha256 \
-extfile "$CLIENT_DIR/$FILE_PREFIX.ext"
# Verify the client certificate
openssl verify -CAfile "$CA_DIR/ca.crt" "$CLIENT_DIR/$FILE_PREFIX.crt"
echo "Signed ✅"
# Create encrypted PEM bundle
openssl rsa -in "$CLIENT_DIR/$FILE_PREFIX.key" -passin pass:"$PASSWORD" \
| cat "$CLIENT_DIR/$FILE_PREFIX.crt" - > "$CLIENT_DIR/$FILE_PREFIX-bundle.enc.pem"
# Convert to PKCS12
echo "Converting to PKCS12 format..."
openssl pkcs12 -export \
-out "$CLIENT_DIR/$FILE_PREFIX.enc.p12" \
-inkey "$CLIENT_DIR/$FILE_PREFIX.key" \
-in "$CLIENT_DIR/$FILE_PREFIX.crt" \
-certfile "$CA_DIR/ca.crt" \
-name "$CERT_USERNAME@$DOMAIN" \
-passin pass:"$PASSWORD" \
-passout pass:"$PASSWORD"
echo "Converted to encrypted p12 for macOS ✅"
# Convert to PKCS12 format without encryption
echo "Converting to non-encrypted PKCS12 format..."
openssl pkcs12 -export \
-out "$CLIENT_DIR/$FILE_PREFIX.p12" \
-inkey "$CLIENT_DIR/$FILE_PREFIX.key" \
-in "$CLIENT_DIR/$FILE_PREFIX.crt" \
-certfile "$CA_DIR/ca.crt" \
-name "$CERT_USERNAME@$DOMAIN" \
-passin pass:"$PASSWORD" \
-passout pass:""
echo "Converted to non-encrypted p12 ✅"
# Clean up intermediate files
rm "$CLIENT_DIR/$FILE_PREFIX.csr" "$CLIENT_DIR/$FILE_PREFIX.ext" "$CA_DIR/ca.srl"
echo
echo
echo "CA certificate: $CA_DIR/ca.crt"
echo "CA private key: $CA_DIR/ca.key"
echo "Client certificate: $CLIENT_DIR/$FILE_PREFIX.crt"
echo "Client private key: $CLIENT_DIR/$FILE_PREFIX.key"
echo "Client cert bundle: $CLIENT_DIR/$FILE_PREFIX.p12"
echo "Client cert bundle (encrypted): $CLIENT_DIR/$FILE_PREFIX.enc.p12"

View File

@@ -2,33 +2,34 @@ package websocket
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"software.sslmate.com/src/go-pkcs12"
"strings"
"sync"
"time"
"github.com/fosrl/newt/logger"
"github.com/gorilla/websocket"
)
type Client struct {
conn *websocket.Conn
config *Config
baseURL string
handlers map[string]MessageHandler
done chan struct{}
handlersMux sync.RWMutex
conn *websocket.Conn
config *Config
baseURL string
handlers map[string]MessageHandler
done chan struct{}
handlersMux sync.RWMutex
reconnectInterval time.Duration
isConnected bool
reconnectMux sync.RWMutex
onConnect func() error
onTokenUpdate func(token string)
onConnect func() error
}
type ClientOption func(*Client)
@@ -42,12 +43,14 @@ func WithBaseURL(url string) ClientOption {
}
}
func (c *Client) OnConnect(callback func() error) {
c.onConnect = callback
func WithTLSConfig(tlsClientCertPath string) ClientOption {
return func(c *Client) {
c.config.TlsClientCert = tlsClientCertPath
}
}
func (c *Client) OnTokenUpdate(callback func(token string)) {
c.onTokenUpdate = callback
func (c *Client) OnConnect(callback func() error) {
c.onConnect = callback
}
// NewClient creates a new Newt client
@@ -68,8 +71,13 @@ func NewClient(newtID, secret string, endpoint string, opts ...ClientOption) (*C
}
// Apply options before loading config
for _, opt := range opts {
opt(client)
if opts != nil {
for _, opt := range opts {
if opt == nil {
continue
}
opt(client)
}
}
// Load existing config if available
@@ -154,6 +162,14 @@ func (c *Client) getToken() (string, error) {
// Ensure we have the base URL without trailing slashes
baseEndpoint := strings.TrimRight(baseURL.String(), "/")
var tlsConfig *tls.Config = nil
if c.config.TlsClientCert != "" {
tlsConfig, err = loadClientCertificate(c.config.TlsClientCert)
if err != nil {
return "", fmt.Errorf("failed to load certificate %s: %w", c.config.TlsClientCert, err)
}
}
// If we already have a token, try to use it
if c.config.Token != "" {
tokenCheckData := map[string]interface{}{
@@ -182,6 +198,11 @@ func (c *Client) getToken() (string, error) {
// Make the request
client := &http.Client{}
if tlsConfig != nil {
client.Transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to check token validity: %w", err)
@@ -225,6 +246,11 @@ func (c *Client) getToken() (string, error) {
// Make the request
client := &http.Client{}
if tlsConfig != nil {
client.Transport = &http.Transport{
TLSClientConfig: tlsConfig,
}
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request new token: %w", err)
@@ -275,8 +301,6 @@ func (c *Client) establishConnection() error {
return fmt.Errorf("failed to get token: %w", err)
}
c.onTokenUpdate(token)
// Parse the base URL to determine protocol and hostname
baseURL, err := url.Parse(c.baseURL)
if err != nil {
@@ -299,11 +323,19 @@ func (c *Client) establishConnection() error {
// Add token to query parameters
q := u.Query()
q.Set("token", token)
q.Set("clientType", "newt")
u.RawQuery = q.Encode()
// Connect to WebSocket
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
dialer := websocket.DefaultDialer
if c.config.TlsClientCert != "" {
logger.Info("Adding tls to req")
tlsConfig, err := loadClientCertificate(c.config.TlsClientCert)
if err != nil {
return fmt.Errorf("failed to load certificate %s: %w", c.config.TlsClientCert, err)
}
dialer.TLSClientConfig = tlsConfig
}
conn, _, err := dialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("failed to connect to WebSocket: %w", err)
}
@@ -361,3 +393,42 @@ func (c *Client) setConnected(status bool) {
defer c.reconnectMux.Unlock()
c.isConnected = status
}
// LoadClientCertificate Helper method to load client certificates
func loadClientCertificate(p12Path string) (*tls.Config, error) {
logger.Info("Loading tls-client-cert %s", p12Path)
// Read the PKCS12 file
p12Data, err := os.ReadFile(p12Path)
if err != nil {
return nil, fmt.Errorf("failed to read PKCS12 file: %w", err)
}
// Parse PKCS12 with empty password for non-encrypted files
privateKey, certificate, caCerts, err := pkcs12.DecodeChain(p12Data, "")
if err != nil {
return nil, fmt.Errorf("failed to decode PKCS12: %w", err)
}
// Create certificate
cert := tls.Certificate{
Certificate: [][]byte{certificate.Raw},
PrivateKey: privateKey,
}
// Optional: Add CA certificates if present
rootCAs, err := x509.SystemCertPool()
if err != nil {
return nil, fmt.Errorf("failed to load system cert pool: %w", err)
}
if len(caCerts) > 0 {
for _, caCert := range caCerts {
rootCAs.AddCert(caCert)
}
}
// Create TLS configuration
return &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: rootCAs,
}, nil
}

View File

@@ -54,6 +54,9 @@ func (c *Client) loadConfig() error {
if c.config.Secret == "" {
c.config.Secret = config.Secret
}
if c.config.TlsClientCert == "" {
c.config.TlsClientCert = config.TlsClientCert
}
if c.config.Endpoint == "" {
c.config.Endpoint = config.Endpoint
c.baseURL = config.Endpoint

View File

@@ -1,10 +1,11 @@
package websocket
type Config struct {
NewtID string `json:"newtId"`
Secret string `json:"secret"`
Token string `json:"token"`
Endpoint string `json:"endpoint"`
NewtID string `json:"newtId"`
Secret string `json:"secret"`
Token string `json:"token"`
Endpoint string `json:"endpoint"`
TlsClientCert string `json:"tlsClientCert"`
}
type TokenResponse struct {

801
wg/wg.go
View File

@@ -1,801 +0,0 @@
package wg
import (
"encoding/json"
"fmt"
"net"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/fosrl/newt/logger"
"github.com/fosrl/newt/network"
"github.com/fosrl/newt/websocket"
"github.com/vishvananda/netlink"
"golang.org/x/crypto/chacha20poly1305"
"golang.org/x/crypto/curve25519"
"golang.org/x/exp/rand"
"golang.zx2c4.com/wireguard/conn"
"golang.zx2c4.com/wireguard/wgctrl"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
)
type WgConfig struct {
IpAddress string `json:"ipAddress"`
Peers []Peer `json:"peers"`
}
type Peer struct {
PublicKey string `json:"publicKey"`
AllowedIPs []string `json:"allowedIps"`
Endpoint string `json:"endpoint"`
}
type PeerBandwidth struct {
PublicKey string `json:"publicKey"`
BytesIn float64 `json:"bytesIn"`
BytesOut float64 `json:"bytesOut"`
}
type PeerReading struct {
BytesReceived int64
BytesTransmitted int64
LastChecked time.Time
}
type WireGuardService struct {
interfaceName string
mtu int
client *websocket.Client
wgClient *wgctrl.Client
config WgConfig
key wgtypes.Key
newtId string
lastReadings map[string]PeerReading
mu sync.Mutex
Port uint16
stopHolepunch chan struct{}
host string
serverPubKey string
token string
}
// Add this type definition
type fixedPortBind struct {
port uint16
conn.Bind
}
func (b *fixedPortBind) Open(port uint16) ([]conn.ReceiveFunc, uint16, error) {
// Ignore the requested port and use our fixed port
return b.Bind.Open(b.port)
}
func NewFixedPortBind(port uint16) conn.Bind {
return &fixedPortBind{
port: port,
Bind: conn.NewDefaultBind(),
}
}
func FindAvailableUDPPort(minPort, maxPort uint16) (uint16, error) {
if maxPort < minPort {
return 0, fmt.Errorf("invalid port range: min=%d, max=%d", minPort, maxPort)
}
// Create a slice of all ports in the range
portRange := make([]uint16, maxPort-minPort+1)
for i := range portRange {
portRange[i] = minPort + uint16(i)
}
// Fisher-Yates shuffle to randomize the port order
rand.Seed(uint64(time.Now().UnixNano()))
for i := len(portRange) - 1; i > 0; i-- {
j := rand.Intn(i + 1)
portRange[i], portRange[j] = portRange[j], portRange[i]
}
// Try each port in the randomized order
for _, port := range portRange {
addr := &net.UDPAddr{
IP: net.ParseIP("127.0.0.1"),
Port: int(port),
}
conn, err := net.ListenUDP("udp", addr)
if err != nil {
continue // Port is in use or there was an error, try next port
}
_ = conn.SetDeadline(time.Now())
conn.Close()
return port, nil
}
return 0, fmt.Errorf("no available UDP ports found in range %d-%d", minPort, maxPort)
}
func NewWireGuardService(interfaceName string, mtu int, generateAndSaveKeyTo string, host string, newtId string, wsClient *websocket.Client) (*WireGuardService, error) {
wgClient, err := wgctrl.New()
if err != nil {
return nil, fmt.Errorf("failed to create WireGuard client: %v", err)
}
var key wgtypes.Key
// if generateAndSaveKeyTo is provided, generate a private key and save it to the file. if the file already exists, load the key from the file
if _, err := os.Stat(generateAndSaveKeyTo); os.IsNotExist(err) {
// generate a new private key
key, err = wgtypes.GeneratePrivateKey()
if err != nil {
logger.Fatal("Failed to generate private key: %v", err)
}
// save the key to the file
err = os.WriteFile(generateAndSaveKeyTo, []byte(key.String()), 0644)
if err != nil {
logger.Fatal("Failed to save private key: %v", err)
}
} else {
keyData, err := os.ReadFile(generateAndSaveKeyTo)
if err != nil {
logger.Fatal("Failed to read private key: %v", err)
}
key, err = wgtypes.ParseKey(string(keyData))
if err != nil {
logger.Fatal("Failed to parse private key: %v", err)
}
}
port, err := FindAvailableUDPPort(49152, 65535)
if err != nil {
fmt.Printf("Error finding available port: %v\n", err)
return nil, err
}
service := &WireGuardService{
interfaceName: interfaceName,
mtu: mtu,
client: wsClient,
wgClient: wgClient,
key: key,
newtId: newtId,
lastReadings: make(map[string]PeerReading),
Port: port,
stopHolepunch: make(chan struct{}),
host: host,
}
// Register websocket handlers
wsClient.RegisterHandler("newt/wg/receive-config", service.handleConfig)
wsClient.RegisterHandler("newt/wg/peer/add", service.handleAddPeer)
wsClient.RegisterHandler("newt/wg/peer/remove", service.handleRemovePeer)
return service, nil
}
func (s *WireGuardService) Close() {
s.wgClient.Close()
// Remove the WireGuard interface
if err := s.removeInterface(); err != nil {
logger.Error("Failed to remove WireGuard interface: %v", err)
}
}
func (s *WireGuardService) SetServerPubKey(serverPubKey string) {
s.serverPubKey = serverPubKey
}
func (s *WireGuardService) SetToken(token string) {
s.token = token
}
func (s *WireGuardService) LoadRemoteConfig() error {
// get the exising wireguard port
device, err := s.wgClient.Device(s.interfaceName)
if err == nil {
s.Port = uint16(device.ListenPort)
logger.Info("WireGuard interface %s already exists with port %d\n", s.interfaceName, s.Port)
}
err = s.client.SendMessage("newt/wg/get-config", map[string]interface{}{
"publicKey": fmt.Sprintf("%s", s.key.PublicKey().String()),
"port": s.Port,
})
if err != nil {
logger.Error("Failed to send registration message: %v", err)
return err
}
logger.Info("Requesting WireGuard configuration from remote server")
go s.periodicBandwidthCheck()
return nil
}
func (s *WireGuardService) handleConfig(msg websocket.WSMessage) {
var config WgConfig
logger.Info("Received message: %v", msg)
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
return
}
if err := json.Unmarshal(jsonData, &config); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
return
}
s.config = config
// Ensure the WireGuard interface and peers are configured
if err := s.ensureWireguardInterface(config); err != nil {
logger.Error("Failed to ensure WireGuard interface: %v", err)
}
if err := s.ensureWireguardPeers(config.Peers); err != nil {
logger.Error("Failed to ensure WireGuard peers: %v", err)
}
if err := s.sendUDPHolePunch(s.host + ":21820"); err != nil {
logger.Error("Failed to send UDP hole punch: %v", err)
}
// start the UDP holepunch
go s.keepSendingUDPHolePunch(s.host)
}
func (s *WireGuardService) ensureWireguardInterface(wgconfig WgConfig) error {
// Check if the WireGuard interface exists
_, err := netlink.LinkByName(s.interfaceName)
if err != nil {
if _, ok := err.(netlink.LinkNotFoundError); ok {
// Interface doesn't exist, so create it
err = s.createWireGuardInterface()
if err != nil {
logger.Fatal("Failed to create WireGuard interface: %v", err)
}
logger.Info("Created WireGuard interface %s\n", s.interfaceName)
} else {
logger.Fatal("Error checking for WireGuard interface: %v", err)
}
} else {
logger.Info("WireGuard interface %s already exists\n", s.interfaceName)
// get the exising wireguard port
device, err := s.wgClient.Device(s.interfaceName)
if err != nil {
return fmt.Errorf("failed to get device: %v", err)
}
// get the existing port
s.Port = uint16(device.ListenPort)
logger.Info("WireGuard interface %s already exists with port %d\n", s.interfaceName, s.Port)
return nil
}
logger.Info("Assigning IP address %s to interface %s\n", wgconfig.IpAddress, s.interfaceName)
// Assign IP address to the interface
err = s.assignIPAddress(wgconfig.IpAddress)
if err != nil {
logger.Fatal("Failed to assign IP address: %v", err)
}
// Check if the interface already exists
_, err = s.wgClient.Device(s.interfaceName)
if err != nil {
return fmt.Errorf("interface %s does not exist", s.interfaceName)
}
// Parse the private key
key, err := wgtypes.ParseKey(s.key.String())
if err != nil {
return fmt.Errorf("failed to parse private key: %v", err)
}
config := wgtypes.Config{
PrivateKey: &key,
ListenPort: new(int),
}
// Use the service's fixed port instead of the config port
*config.ListenPort = int(s.Port)
// Create and configure the WireGuard interface
err = s.wgClient.ConfigureDevice(s.interfaceName, config)
if err != nil {
return fmt.Errorf("failed to configure WireGuard device: %v", err)
}
// bring up the interface
link, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %v", err)
}
if err := netlink.LinkSetMTU(link, s.mtu); err != nil {
return fmt.Errorf("failed to set MTU: %v", err)
}
if err := netlink.LinkSetUp(link); err != nil {
return fmt.Errorf("failed to bring up interface: %v", err)
}
// if err := s.ensureMSSClamping(); err != nil {
// logger.Warn("Failed to ensure MSS clamping: %v", err)
// }
logger.Info("WireGuard interface %s created and configured", s.interfaceName)
return nil
}
func (s *WireGuardService) createWireGuardInterface() error {
wgLink := &netlink.GenericLink{
LinkAttrs: netlink.LinkAttrs{Name: s.interfaceName},
LinkType: "wireguard",
}
return netlink.LinkAdd(wgLink)
}
func (s *WireGuardService) assignIPAddress(ipAddress string) error {
link, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %v", err)
}
addr, err := netlink.ParseAddr(ipAddress)
if err != nil {
return fmt.Errorf("failed to parse IP address: %v", err)
}
return netlink.AddrAdd(link, addr)
}
func (s *WireGuardService) ensureWireguardPeers(peers []Peer) error {
// get the current peers
device, err := s.wgClient.Device(s.interfaceName)
if err != nil {
return fmt.Errorf("failed to get device: %v", err)
}
// get the peer public keys
var currentPeers []string
for _, peer := range device.Peers {
currentPeers = append(currentPeers, peer.PublicKey.String())
}
// remove any peers that are not in the config
for _, peer := range currentPeers {
found := false
for _, configPeer := range peers {
if peer == configPeer.PublicKey {
found = true
break
}
}
if !found {
err := s.removePeer(peer)
if err != nil {
return fmt.Errorf("failed to remove peer: %v", err)
}
}
}
// add any peers that are in the config but not in the current peers
for _, configPeer := range peers {
found := false
for _, peer := range currentPeers {
if configPeer.PublicKey == peer {
found = true
break
}
}
if !found {
err := s.addPeer(configPeer)
if err != nil {
return fmt.Errorf("failed to add peer: %v", err)
}
}
}
return nil
}
func (s *WireGuardService) handleAddPeer(msg websocket.WSMessage) {
var peer Peer
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
}
if err := json.Unmarshal(jsonData, &peer); err != nil {
logger.Info("Error unmarshaling target data: %v", err)
}
err = s.addPeer(peer)
if err != nil {
logger.Info("Error adding peer: %v", err)
return
}
}
func (s *WireGuardService) addPeer(peer Peer) error {
pubKey, err := wgtypes.ParseKey(peer.PublicKey)
if err != nil {
return fmt.Errorf("failed to parse public key: %v", err)
}
// parse allowed IPs into array of net.IPNet
var allowedIPs []net.IPNet
for _, ipStr := range peer.AllowedIPs {
_, ipNet, err := net.ParseCIDR(ipStr)
if err != nil {
return fmt.Errorf("failed to parse allowed IP: %v", err)
}
allowedIPs = append(allowedIPs, *ipNet)
}
// add keep alive using *time.Duration of 1 second
keepalive := time.Second
var peerConfig wgtypes.PeerConfig
if peer.Endpoint != "" {
endpoint, err := net.ResolveUDPAddr("udp", peer.Endpoint)
if err != nil {
return fmt.Errorf("failed to resolve endpoint address: %w", err)
}
// make the endpoint localhost to test
peerConfig = wgtypes.PeerConfig{
PublicKey: pubKey,
AllowedIPs: allowedIPs,
PersistentKeepaliveInterval: &keepalive,
Endpoint: endpoint,
}
} else {
peerConfig = wgtypes.PeerConfig{
PublicKey: pubKey,
AllowedIPs: allowedIPs,
PersistentKeepaliveInterval: &keepalive,
}
logger.Info("Added peer with no endpoint!")
}
config := wgtypes.Config{
Peers: []wgtypes.PeerConfig{peerConfig},
}
if err := s.wgClient.ConfigureDevice(s.interfaceName, config); err != nil {
return fmt.Errorf("failed to add peer: %v", err)
}
logger.Info("Peer %s added successfully", peer.PublicKey)
return nil
}
func (s *WireGuardService) handleRemovePeer(msg websocket.WSMessage) {
// parse the publicKey from the message which is json { "publicKey": "asdfasdfl;akjsdf" }
type RemoveRequest struct {
PublicKey string `json:"publicKey"`
}
jsonData, err := json.Marshal(msg.Data)
if err != nil {
logger.Info("Error marshaling data: %v", err)
}
var request RemoveRequest
if err := json.Unmarshal(jsonData, &request); err != nil {
logger.Info("Error unmarshaling data: %v", err)
return
}
if err := s.removePeer(request.PublicKey); err != nil {
logger.Info("Error removing peer: %v", err)
return
}
}
func (s *WireGuardService) removePeer(publicKey string) error {
pubKey, err := wgtypes.ParseKey(publicKey)
if err != nil {
return fmt.Errorf("failed to parse public key: %v", err)
}
peerConfig := wgtypes.PeerConfig{
PublicKey: pubKey,
Remove: true,
}
config := wgtypes.Config{
Peers: []wgtypes.PeerConfig{peerConfig},
}
if err := s.wgClient.ConfigureDevice(s.interfaceName, config); err != nil {
return fmt.Errorf("failed to remove peer: %v", err)
}
logger.Info("Peer %s removed successfully", publicKey)
return nil
}
func (s *WireGuardService) periodicBandwidthCheck() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := s.reportPeerBandwidth(); err != nil {
logger.Info("Failed to report peer bandwidth: %v", err)
}
}
}
func (s *WireGuardService) calculatePeerBandwidth() ([]PeerBandwidth, error) {
device, err := s.wgClient.Device(s.interfaceName)
if err != nil {
return nil, fmt.Errorf("failed to get device: %v", err)
}
peerBandwidths := []PeerBandwidth{}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
for _, peer := range device.Peers {
publicKey := peer.PublicKey.String()
currentReading := PeerReading{
BytesReceived: peer.ReceiveBytes,
BytesTransmitted: peer.TransmitBytes,
LastChecked: now,
}
var bytesInDiff, bytesOutDiff float64
lastReading, exists := s.lastReadings[publicKey]
if exists {
timeDiff := currentReading.LastChecked.Sub(lastReading.LastChecked).Seconds()
if timeDiff > 0 {
// Calculate bytes transferred since last reading
bytesInDiff = float64(currentReading.BytesReceived - lastReading.BytesReceived)
bytesOutDiff = float64(currentReading.BytesTransmitted - lastReading.BytesTransmitted)
// Handle counter wraparound (if the counter resets or overflows)
if bytesInDiff < 0 {
bytesInDiff = float64(currentReading.BytesReceived)
}
if bytesOutDiff < 0 {
bytesOutDiff = float64(currentReading.BytesTransmitted)
}
// Convert to MB
bytesInMB := bytesInDiff / (1024 * 1024)
bytesOutMB := bytesOutDiff / (1024 * 1024)
peerBandwidths = append(peerBandwidths, PeerBandwidth{
PublicKey: publicKey,
BytesIn: bytesInMB,
BytesOut: bytesOutMB,
})
} else {
// If readings are too close together or time hasn't passed, report 0
peerBandwidths = append(peerBandwidths, PeerBandwidth{
PublicKey: publicKey,
BytesIn: 0,
BytesOut: 0,
})
}
} else {
// For first reading of a peer, report 0 to establish baseline
peerBandwidths = append(peerBandwidths, PeerBandwidth{
PublicKey: publicKey,
BytesIn: 0,
BytesOut: 0,
})
}
// Update the last reading
s.lastReadings[publicKey] = currentReading
}
// Clean up old peers
for publicKey := range s.lastReadings {
found := false
for _, peer := range device.Peers {
if peer.PublicKey.String() == publicKey {
found = true
break
}
}
if !found {
delete(s.lastReadings, publicKey)
}
}
return peerBandwidths, nil
}
func (s *WireGuardService) reportPeerBandwidth() error {
bandwidths, err := s.calculatePeerBandwidth()
if err != nil {
return fmt.Errorf("failed to calculate peer bandwidth: %v", err)
}
err = s.client.SendMessage("newt/receive-bandwidth", map[string]interface{}{
"bandwidthData": bandwidths,
})
if err != nil {
return fmt.Errorf("failed to send bandwidth data: %v", err)
}
return nil
}
func (s *WireGuardService) sendUDPHolePunch(serverAddr string) error {
if s.serverPubKey == "" || s.token == "" {
return fmt.Errorf("server public key or token is not set")
}
// Parse server address
serverSplit := strings.Split(serverAddr, ":")
if len(serverSplit) < 2 {
return fmt.Errorf("invalid server address format, expected hostname:port")
}
serverHostname := serverSplit[0]
serverPort, err := strconv.ParseUint(serverSplit[1], 10, 16)
if err != nil {
return fmt.Errorf("failed to parse server port: %v", err)
}
// Resolve server hostname to IP
serverIPAddr := network.HostToAddr(serverHostname)
if serverIPAddr == nil {
return fmt.Errorf("failed to resolve server hostname")
}
// Get client IP based on route to server
clientIP := network.GetClientIP(serverIPAddr.IP)
// Create server and client configs
server := &network.Server{
Hostname: serverHostname,
Addr: serverIPAddr,
Port: uint16(serverPort),
}
client := &network.PeerNet{
IP: clientIP,
Port: s.Port,
NewtID: s.newtId,
}
// Setup raw connection with BPF filtering
rawConn := network.SetupRawConn(server, client)
defer rawConn.Close()
// Create JSON payload
payload := struct {
NewtID string `json:"newtId"`
Token string `json:"token"`
}{
NewtID: s.newtId,
Token: s.token,
}
// Convert payload to JSON
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %v", err)
}
// Encrypt the payload using the server's WireGuard public key
encryptedPayload, err := s.encryptPayload(payloadBytes)
if err != nil {
return fmt.Errorf("failed to encrypt payload: %v", err)
}
// Send the encrypted packet using the raw connection
err = network.SendDataPacket(encryptedPayload, rawConn, server, client)
if err != nil {
return fmt.Errorf("failed to send UDP packet: %v", err)
}
return nil
}
func (s *WireGuardService) encryptPayload(payload []byte) (interface{}, error) {
// Generate an ephemeral keypair for this message
ephemeralPrivateKey, err := wgtypes.GeneratePrivateKey()
if err != nil {
return nil, fmt.Errorf("failed to generate ephemeral private key: %v", err)
}
ephemeralPublicKey := ephemeralPrivateKey.PublicKey()
// Parse the server's public key
serverPubKey, err := wgtypes.ParseKey(s.serverPubKey)
if err != nil {
return nil, fmt.Errorf("failed to parse server public key: %v", err)
}
// Use X25519 for key exchange (replacing deprecated ScalarMult)
var ephPrivKeyFixed [32]byte
copy(ephPrivKeyFixed[:], ephemeralPrivateKey[:])
// Perform X25519 key exchange
sharedSecret, err := curve25519.X25519(ephPrivKeyFixed[:], serverPubKey[:])
if err != nil {
return nil, fmt.Errorf("failed to perform X25519 key exchange: %v", err)
}
// Create an AEAD cipher using the shared secret
aead, err := chacha20poly1305.New(sharedSecret)
if err != nil {
return nil, fmt.Errorf("failed to create AEAD cipher: %v", err)
}
// Generate a random nonce
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, fmt.Errorf("failed to generate nonce: %v", err)
}
// Encrypt the payload
ciphertext := aead.Seal(nil, nonce, payload, nil)
// Prepare the final encrypted message
encryptedMsg := struct {
EphemeralPublicKey string `json:"ephemeralPublicKey"`
Nonce []byte `json:"nonce"`
Ciphertext []byte `json:"ciphertext"`
}{
EphemeralPublicKey: ephemeralPublicKey.String(),
Nonce: nonce,
Ciphertext: ciphertext,
}
return encryptedMsg, nil
}
func (s *WireGuardService) keepSendingUDPHolePunch(host string) {
ticker := time.NewTicker(3 * time.Second)
defer ticker.Stop()
for {
select {
case <-s.stopHolepunch:
logger.Info("Stopping UDP holepunch")
return
case <-ticker.C:
if err := s.sendUDPHolePunch(host + ":21820"); err != nil {
logger.Error("Failed to send UDP hole punch: %v", err)
}
}
}
}
func (s *WireGuardService) removeInterface() error {
// Remove the WireGuard interface
link, err := netlink.LinkByName(s.interfaceName)
if err != nil {
return fmt.Errorf("failed to get interface: %v", err)
}
err = netlink.LinkDel(link)
if err != nil {
return fmt.Errorf("failed to delete interface: %v", err)
}
logger.Info("WireGuard interface %s removed successfully", s.interfaceName)
return nil
}