diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..703442f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,46 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + groups: + dev-patch-updates: + dependency-type: "development" + update-types: + - "patch" + dev-minor-updates: + dependency-type: "development" + update-types: + - "minor" + dev-major-updates: + dependency-type: "development" + update-types: + - "major" + prod-patch-updates: + dependency-type: "production" + update-types: + - "patch" + prod-minor-updates: + dependency-type: "production" + update-types: + - "minor" + prod-major-updates: + dependency-type: "production" + update-types: + - "major" + + - package-ecosystem: "docker" + directory: "/" + schedule: + interval: "daily" + groups: + patch-updates: + update-types: + - "patch" + minor-updates: + update-types: + - "minor" + major-updates: + update-types: + - "major" diff --git a/.gitignore b/.gitignore index 100fc81..bd45f8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ newt .DS_Store bin/ -nohup.out \ No newline at end of file +nohup.out +.idea +*.iml +certs/ diff --git a/Dockerfile b/Dockerfile index 504cd8c..b25ebe3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.23.1-alpine AS builder +FROM golang:1.24.4-alpine AS builder # Set the working directory inside the container WORKDIR /app @@ -15,9 +15,9 @@ COPY . . # Build the application RUN CGO_ENABLED=0 GOOS=linux go build -o /newt -FROM alpine:3.19 AS runner +FROM alpine:3.22 AS runner -RUN apk --no-cache add ca-certificates +RUN apk --no-cache add ca-certificates tzdata COPY --from=builder /newt /usr/local/bin/ COPY entrypoint.sh / diff --git a/README.md b/README.md index 8f0d1c3..9cd2768 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,6 @@ Newt is a fully user space [WireGuard](https://www.wireguard.com/) tunnel client Newt is used with Pangolin and Gerbil as part of the larger system. See documentation below: -- [Installation Instructions](https://docs.fossorial.io) - [Full Documentation](https://docs.fossorial.io) ## Preview @@ -37,8 +36,10 @@ 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) +- `docker-socket` (optional): Set the Docker socket to use the container discovery integration + +- Example: ```bash ./newt \ @@ -75,23 +76,15 @@ services: - --endpoint https://example.com ``` -Finally a basic systemd service: +### Docker Socket Integration -``` -[Unit] -Description=Newt VPN Client -After=network.target +Newt can integrate with the Docker socket to provide remote inspection of Docker containers. This allows Pangolin to query and retrieve detailed information about containers running on the Newt client, including metadata, network configuration, port mappings, and more. -[Service] -ExecStart=/usr/local/bin/newt --id 31frd0uzbjvp721 --secret h51mmlknrvrwv8s4r1i210azhumt6isgbpyavxodibx1k2d6 --endpoint https://example.com -Restart=always -User=root +**Configuration:** -[Install] -WantedBy=multi-user.target -``` +You can specify the Docker socket path using the `--docker-socket` CLI argument or by setting the `DOCKER_SOCKET` environment variable. On most linux systems the socket is `/var/run/docker.sock` -Make sure to `mv ./newt /usr/local/bin/newt`! +If the Docker socket is not available or accessible, Newt will gracefully disable Docker integration and continue normal operation. ### Updown @@ -107,6 +100,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 +150,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. diff --git a/docker/client.go b/docker/client.go new file mode 100644 index 0000000..98936fe --- /dev/null +++ b/docker/client.go @@ -0,0 +1,166 @@ +package docker + +import ( + "context" + "fmt" + "net" + "strings" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/fosrl/newt/logger" +) + +// Container represents a Docker container +type Container struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` + Status string `json:"status"` + Ports []Port `json:"ports"` + Labels map[string]string `json:"labels"` + Created int64 `json:"created"` + Networks map[string]Network `json:"networks"` +} + +// Port represents a port mapping for a Docker container +type Port struct { + PrivatePort int `json:"privatePort"` + PublicPort int `json:"publicPort,omitempty"` + Type string `json:"type"` + IP string `json:"ip,omitempty"` +} + +// Network represents network information for a Docker container +type Network struct { + NetworkID string `json:"networkId"` + EndpointID string `json:"endpointId"` + Gateway string `json:"gateway,omitempty"` + IPAddress string `json:"ipAddress,omitempty"` + IPPrefixLen int `json:"ipPrefixLen,omitempty"` + IPv6Gateway string `json:"ipv6Gateway,omitempty"` + GlobalIPv6Address string `json:"globalIPv6Address,omitempty"` + GlobalIPv6PrefixLen int `json:"globalIPv6PrefixLen,omitempty"` + MacAddress string `json:"macAddress,omitempty"` + Aliases []string `json:"aliases,omitempty"` + DNSNames []string `json:"dnsNames,omitempty"` +} + +// CheckSocket checks if Docker socket is available +func CheckSocket(socketPath string) bool { + // Use the provided socket path or default to standard location + if socketPath == "" { + socketPath = "/var/run/docker.sock" + } + + // Try to create a connection to the Docker socket + conn, err := net.Dial("unix", socketPath) + if err != nil { + logger.Debug("Docker socket not available at %s: %v", socketPath, err) + return false + } + defer conn.Close() + + logger.Debug("Docker socket is available at %s", socketPath) + return true +} + +// ListContainers lists all Docker containers with their network information +func ListContainers(socketPath string) ([]Container, error) { + // Use the provided socket path or default to standard location + if socketPath == "" { + socketPath = "/var/run/docker.sock" + } + + // Create a new Docker client + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Create client with custom socket path + cli, err := client.NewClientWithOpts( + client.WithHost("unix://"+socketPath), + client.WithAPIVersionNegotiation(), + ) + if err != nil { + return nil, fmt.Errorf("failed to create Docker client: %v", err) + } + defer cli.Close() + + // List containers + containers, err := cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, fmt.Errorf("failed to list containers: %v", err) + } + + var dockerContainers []Container + for _, c := range containers { + // Convert ports + var ports []Port + for _, port := range c.Ports { + dockerPort := Port{ + PrivatePort: int(port.PrivatePort), + Type: port.Type, + } + if port.PublicPort != 0 { + dockerPort.PublicPort = int(port.PublicPort) + } + if port.IP != "" { + dockerPort.IP = port.IP + } + ports = append(ports, dockerPort) + } + + // Get container name (remove leading slash) + name := "" + if len(c.Names) > 0 { + name = strings.TrimPrefix(c.Names[0], "/") + } + + // Get network information by inspecting the container + networks := make(map[string]Network) + + // Inspect container to get detailed network information + containerInfo, err := cli.ContainerInspect(ctx, c.ID) + if err != nil { + logger.Debug("Failed to inspect container %s for network info: %v", c.ID[:12], err) + // Continue without network info if inspection fails + } else { + // Extract network information from inspection + if containerInfo.NetworkSettings != nil && containerInfo.NetworkSettings.Networks != nil { + for networkName, endpoint := range containerInfo.NetworkSettings.Networks { + dockerNetwork := Network{ + NetworkID: endpoint.NetworkID, + EndpointID: endpoint.EndpointID, + Gateway: endpoint.Gateway, + IPAddress: endpoint.IPAddress, + IPPrefixLen: endpoint.IPPrefixLen, + IPv6Gateway: endpoint.IPv6Gateway, + GlobalIPv6Address: endpoint.GlobalIPv6Address, + GlobalIPv6PrefixLen: endpoint.GlobalIPv6PrefixLen, + MacAddress: endpoint.MacAddress, + Aliases: endpoint.Aliases, + DNSNames: endpoint.DNSNames, + } + networks[networkName] = dockerNetwork + } + } + } + + dockerContainer := Container{ + ID: c.ID[:12], // Show short ID like docker ps + Name: name, + Image: c.Image, + State: c.State, + Status: c.Status, + Ports: ports, + Labels: c.Labels, + Created: c.Created, + Networks: networks, + } + dockerContainers = append(dockerContainers, dockerContainer) + } + + return dockerContainers, nil +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..39a4a7e --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1749086602, + "narHash": "sha256-DJcgJMekoxVesl9kKjfLPix2Nbr42i7cpEHJiTnBUwU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "4792576cb003c994bd7cc1edada3129def20b27d", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..eaddd2e --- /dev/null +++ b/flake.nix @@ -0,0 +1,65 @@ +{ + description = "newt - A tunneling client for Pangolin"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + }; + + 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.2.1"; + + src = ./.; + + vendorHash = "sha256-Yc5IXnShciek/bKkVezkAcaq47zGiZP8vUHFb9p09LI="; + + 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 + ]; + }; + } + ); + }; +} diff --git a/go.mod b/go.mod index 1912c9c..5e43fab 100644 --- a/go.mod +++ b/go.mod @@ -9,13 +9,26 @@ require ( 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.41.0 + github.com/docker/docker v28.2.2+incompatible golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 + software.sslmate.com/src/go-pkcs12 v0.5.0 ) require ( + github.com/Microsoft/go-winio v0.6.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.4.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect 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 @@ -23,9 +36,24 @@ require ( 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/crypto v0.39.0 // indirect golang.org/x/sync v0.11.0 // indirect - golang.org/x/sys v0.28.0 // indirect + golang.org/x/sys v0.33.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.36.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect + go.opentelemetry.io/otel/metric v1.36.0 // indirect + go.opentelemetry.io/otel/trace v1.36.0 // indirect + golang.org/x/mod v0.12.0 // indirect golang.org/x/time v0.7.0 // indirect + golang.org/x/tools v0.13.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect ) diff --git a/go.sum b/go.sum deleted file mode 100644 index 386e554..0000000 --- a/go.sum +++ /dev/null @@ -1,56 +0,0 @@ -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/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= -gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ= -gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY= diff --git a/main.go b/main.go index d9a3cef..fc00942 100644 --- a/main.go +++ b/main.go @@ -19,6 +19,7 @@ import ( "syscall" "time" + "github.com/fosrl/newt/docker" "github.com/fosrl/newt/logger" "github.com/fosrl/newt/proxy" "github.com/fosrl/newt/websocket" @@ -58,7 +59,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: %v", err) } // Convert to hex @@ -197,7 +198,7 @@ func monitorConnectionStatus(tnet *netstack.Net, serverIP string, client *websoc // Tell the server we're back err := client.SendMessage("newt/wg/register", map[string]interface{}{ - "publicKey": fmt.Sprintf("%s", privateKey.PublicKey()), + "publicKey": privateKey.PublicKey().String(), }) if err != nil { @@ -360,6 +361,9 @@ var ( generateAndSaveKeyTo string rm bool acceptClients bool + updownScript string + tlsPrivateKey string + dockerSocket string ) func main() { @@ -375,6 +379,8 @@ func main() { generateAndSaveKeyTo = os.Getenv("GENERATE_AND_SAVE_KEY_TO") rm = os.Getenv("RM") == "true" acceptClients = os.Getenv("ACCEPT_CLIENTS") == "true" + tlsPrivateKey = os.Getenv("TLS_CLIENT_CERT") + dockerSocket = os.Getenv("DOCKER_SOCKET") if endpoint == "" { flag.StringVar(&endpoint, "endpoint", "", "Endpoint of your pangolin server") @@ -405,15 +411,24 @@ func main() { } flag.BoolVar(&rm, "rm", false, "Remove the WireGuard interface") flag.BoolVar(&acceptClients, "accept-clients", false, "Accept clients on the WireGuard interface") + if tlsPrivateKey == "" { + flag.StringVar(&tlsPrivateKey, "tls-client-cert", "", "Path to client certificate used for mTLS") + } + if dockerSocket == "" { + flag.StringVar(&dockerSocket, "docker-socket", "", "Path to Docker socket (typically /var/run/docker.sock)") + } // do a --version check version := flag.Bool("version", false, "Print the version") flag.Parse() + newtVersion := "Newt version replaceme" if *version { - fmt.Println("Newt version replaceme") + fmt.Println(newtVersion) os.Exit(0) + } else { + logger.Info(newtVersion) } logger.Init() @@ -430,12 +445,16 @@ 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) @@ -550,7 +569,7 @@ func main() { public_key=%s allowed_ip=%s/32 endpoint=%s -persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey(wgData.PublicKey), wgData.ServerIP, endpoint) +persistent_keepalive_interval=5`, fixKey(privateKey.String()), fixKey(wgData.PublicKey), wgData.ServerIP, endpoint) err = dev.IpcSet(config) if err != nil { @@ -685,12 +704,70 @@ persistent_keepalive_interval=5`, fixKey(fmt.Sprintf("%s", privateKey)), fixKey( } }) + // Register handler for Docker socket check + client.RegisterHandler("newt/socket/check", func(msg websocket.WSMessage) { + logger.Info("Received Docker socket check request") + + if dockerSocket == "" { + logger.Info("Docker socket path is not set") + err := client.SendMessage("newt/socket/status", map[string]interface{}{ + "available": false, + "socketPath": dockerSocket, + }) + if err != nil { + logger.Error("Failed to send Docker socket check response: %v", err) + } + return + } + + // Check if Docker socket is available + isAvailable := docker.CheckSocket(dockerSocket) + + // Send response back to server + err := client.SendMessage("newt/socket/status", map[string]interface{}{ + "available": isAvailable, + "socketPath": dockerSocket, + }) + if err != nil { + logger.Error("Failed to send Docker socket check response: %v", err) + } else { + logger.Info("Docker socket check response sent: available=%t", isAvailable) + } + }) + + // Register handler for Docker container listing + client.RegisterHandler("newt/socket/fetch", func(msg websocket.WSMessage) { + logger.Info("Received Docker container fetch request") + + if dockerSocket == "" { + logger.Info("Docker socket path is not set") + return + } + + // List Docker containers + containers, err := docker.ListContainers(dockerSocket) + if err != nil { + logger.Error("Failed to list Docker containers: %v", err) + return + } + + // Send container list back to server + err = client.SendMessage("newt/socket/containers", map[string]interface{}{ + "containers": containers, + }) + if err != nil { + logger.Error("Failed to send Docker container list: %v", err) + } else { + logger.Info("Docker container list sent, count: %d", len(containers)) + } + }) + client.OnConnect(func() error { publicKey := privateKey.PublicKey() logger.Debug("Public key: %s", publicKey) err := client.SendMessage("newt/wg/register", map[string]interface{}{ - "publicKey": fmt.Sprintf("%s", publicKey), + "publicKey": publicKey.String(), }) if err != nil { logger.Error("Failed to send registration message: %v", err) @@ -720,7 +797,7 @@ 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() diff --git a/self-signed-certs-for-mtls.sh b/self-signed-certs-for-mtls.sh new file mode 100755 index 0000000..3265123 --- /dev/null +++ b/self-signed-certs-for-mtls.sh @@ -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" diff --git a/websocket/client.go b/websocket/client.go index 98c9388..1d75ea8 100644 --- a/websocket/client.go +++ b/websocket/client.go @@ -2,27 +2,29 @@ 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 @@ -42,6 +44,12 @@ func WithBaseURL(url string) ClientOption { } } +func WithTLSConfig(tlsClientCertPath string) ClientOption { + return func(c *Client) { + c.config.TlsClientCert = tlsClientCertPath + } +} + func (c *Client) OnConnect(callback func() error) { c.onConnect = callback } @@ -68,8 +76,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 +167,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 +203,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 +251,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) @@ -303,7 +334,16 @@ func (c *Client) establishConnection() error { 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 +401,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 +} diff --git a/websocket/config.go b/websocket/config.go index 794ff1e..e2b0055 100644 --- a/websocket/config.go +++ b/websocket/config.go @@ -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 diff --git a/websocket/types.go b/websocket/types.go index 084465a..0ea24fc 100644 --- a/websocket/types.go +++ b/websocket/types.go @@ -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 {