diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddbfee22..82571a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,9 @@ jobs: - name: Run Gluetun container with Mullvad configuration run: echo -e "${{ secrets.MULLVAD_WIREGUARD_PRIVATE_KEY }}\n${{ secrets.MULLVAD_WIREGUARD_ADDRESS }}" | ./ci/runner mullvad + - name: Run Gluetun container with ProtonVPN configuration + run: echo -e "${{ secrets.PROTONVPN_WIREGUARD_PRIVATE_KEY }}" | ./ci/runner protonvpn + codeql: runs-on: ubuntu-latest permissions: diff --git a/ci/cmd/main.go b/ci/cmd/main.go index 23b55a06..50ee679e 100644 --- a/ci/cmd/main.go +++ b/ci/cmd/main.go @@ -21,6 +21,8 @@ func main() { switch os.Args[1] { case "mullvad": err = internal.MullvadTest(ctx) + case "protonvpn": + err = internal.ProtonVPNTest(ctx) default: err = fmt.Errorf("unknown command: %s", os.Args[1]) } diff --git a/ci/internal/mullvad.go b/ci/internal/mullvad.go index 1c1881ad..d8e87d2f 100644 --- a/ci/internal/mullvad.go +++ b/ci/internal/mullvad.go @@ -1,193 +1,27 @@ package internal import ( - "bufio" "context" "fmt" - "io" - "os" - "regexp" - "strings" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/network" - "github.com/docker/docker/client" - v1 "github.com/opencontainers/image-spec/specs-go/v1" ) func MullvadTest(ctx context.Context) error { - secrets, err := readSecrets(ctx) + expectedSecrets := []string{ + "Wireguard private key", + "Wireguard address", + } + secrets, err := readSecrets(ctx, expectedSecrets) if err != nil { return fmt.Errorf("reading secrets: %w", err) } - const timeout = 15 * time.Second - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) - if err != nil { - return fmt.Errorf("creating Docker client: %w", err) - } - defer client.Close() - - config := &container.Config{ - Image: "qmcgaw/gluetun", - StopTimeout: ptrTo(3), - Env: []string{ - "VPN_SERVICE_PROVIDER=mullvad", - "VPN_TYPE=wireguard", - "LOG_LEVEL=debug", - "SERVER_COUNTRIES=USA", - "WIREGUARD_PRIVATE_KEY=" + secrets.mullvadWireguardPrivateKey, - "WIREGUARD_ADDRESSES=" + secrets.mullvadWireguardAddress, - }, - } - hostConfig := &container.HostConfig{ - AutoRemove: true, - CapAdd: []string{"NET_ADMIN", "NET_RAW"}, - } - networkConfig := (*network.NetworkingConfig)(nil) - platform := (*v1.Platform)(nil) - const containerName = "" // auto-generated name - - response, err := client.ContainerCreate(ctx, config, hostConfig, networkConfig, platform, containerName) - if err != nil { - return fmt.Errorf("creating container: %w", err) - } - for _, warning := range response.Warnings { - fmt.Println("Warning during container creation:", warning) - } - containerID := response.ID - defer stopContainer(client, containerID) - - beforeStartTime := time.Now() - - err = client.ContainerStart(ctx, containerID, container.StartOptions{}) - if err != nil { - return fmt.Errorf("starting container: %w", err) - } - - return waitForLogLine(ctx, client, containerID, beforeStartTime) -} - -func ptrTo[T any](v T) *T { return &v } - -type secrets struct { - mullvadWireguardPrivateKey string - mullvadWireguardAddress string -} - -func readSecrets(ctx context.Context) (secrets, error) { - expectedSecrets := [...]string{ - "Mullvad Wireguard private key", - "Mullvad Wireguard address", - } - - scanner := bufio.NewScanner(os.Stdin) - lines := make([]string, 0, len(expectedSecrets)) - - for i := range expectedSecrets { - fmt.Println("🤫 reading", expectedSecrets[i], "from Stdin...") - if !scanner.Scan() { - break - } - lines = append(lines, strings.TrimSpace(scanner.Text())) - if ctx.Err() != nil { - return secrets{}, ctx.Err() - } - } - - if err := scanner.Err(); err != nil { - return secrets{}, fmt.Errorf("reading secrets from stdin: %w", err) - } - - if len(lines) < len(expectedSecrets) { - return secrets{}, fmt.Errorf("expected %d secrets via Stdin, but only received %d", - len(expectedSecrets), len(lines)) - } - for i, line := range lines { - if line == "" { - return secrets{}, fmt.Errorf("secret on line %d/%d was empty", i+1, len(lines)) - } - } - - return secrets{ - mullvadWireguardPrivateKey: lines[0], - mullvadWireguardAddress: lines[1], - }, nil -} - -func stopContainer(client *client.Client, containerID string) { - const stopTimeout = 5 * time.Second // must be higher than 3s, see above [container.Config]'s StopTimeout field - stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout) - defer stopCancel() - - err := client.ContainerStop(stopCtx, containerID, container.StopOptions{}) - if err != nil { - fmt.Println("failed to stop container:", err) - } -} - -var successRegexp = regexp.MustCompile(`^.+Public IP address is .+$`) - -func waitForLogLine(ctx context.Context, client *client.Client, containerID string, - beforeStartTime time.Time, -) error { - logOptions := container.LogsOptions{ - ShowStdout: true, - Follow: true, - Since: beforeStartTime.Format(time.RFC3339Nano), - } - - reader, err := client.ContainerLogs(ctx, containerID, logOptions) - if err != nil { - return fmt.Errorf("error getting container logs: %w", err) - } - defer reader.Close() - - var linesSeen []string - scanner := bufio.NewScanner(reader) - for ctx.Err() == nil { - if scanner.Scan() { - line := scanner.Text() - if len(line) > 8 { // remove Docker log prefix - line = line[8:] - } - linesSeen = append(linesSeen, line) - if successRegexp.MatchString(line) { - fmt.Println("✅ Success line logged") - return nil - } - continue - } - err := scanner.Err() - if err != nil && err != io.EOF { - logSeenLines(linesSeen) - return fmt.Errorf("reading log stream: %w", err) - } - - // The scanner is either done or cannot read because of EOF - fmt.Println("The log scanner stopped") - logSeenLines(linesSeen) - - // Check if the container is still running - inspect, err := client.ContainerInspect(ctx, containerID) - if err != nil { - return fmt.Errorf("inspecting container: %w", err) - } - if !inspect.State.Running { - return fmt.Errorf("container stopped unexpectedly while waiting for log line. Exit code: %d", inspect.State.ExitCode) - } - } - - return ctx.Err() -} - -func logSeenLines(lines []string) { - fmt.Println("Logs seen so far:") - for _, line := range lines { - fmt.Println(" " + line) + env := []string{ + "VPN_SERVICE_PROVIDER=mullvad", + "VPN_TYPE=wireguard", + "LOG_LEVEL=debug", + "SERVER_COUNTRIES=USA", + "WIREGUARD_PRIVATE_KEY=" + secrets[0], + "WIREGUARD_ADDRESSES=" + secrets[1], } + return simpleTest(ctx, env) } diff --git a/ci/internal/protonvpn.go b/ci/internal/protonvpn.go new file mode 100644 index 00000000..1b6feedf --- /dev/null +++ b/ci/internal/protonvpn.go @@ -0,0 +1,25 @@ +package internal + +import ( + "context" + "fmt" +) + +func ProtonVPNTest(ctx context.Context) error { + expectedSecrets := []string{ + "Wireguard private key", + } + secrets, err := readSecrets(ctx, expectedSecrets) + if err != nil { + return fmt.Errorf("reading secrets: %w", err) + } + + env := []string{ + "VPN_SERVICE_PROVIDER=protonvpn", + "VPN_TYPE=wireguard", + "LOG_LEVEL=debug", + "SERVER_COUNTRIES=United States", + "WIREGUARD_PRIVATE_KEY=" + secrets[0], + } + return simpleTest(ctx, env) +} diff --git a/ci/internal/secrets.go b/ci/internal/secrets.go new file mode 100644 index 00000000..d98f5541 --- /dev/null +++ b/ci/internal/secrets.go @@ -0,0 +1,42 @@ +package internal + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" +) + +func readSecrets(ctx context.Context, expectedSecrets []string) (lines []string, err error) { + scanner := bufio.NewScanner(os.Stdin) + lines = make([]string, 0, len(expectedSecrets)) + + for i := range expectedSecrets { + fmt.Println("🤫 reading", expectedSecrets[i], "from Stdin...") + if !scanner.Scan() { + break + } + lines = append(lines, strings.TrimSpace(scanner.Text())) + fmt.Println("🤫 "+expectedSecrets[i], "secret read successfully") + if ctx.Err() != nil { + return nil, ctx.Err() + } + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading secrets from stdin: %w", err) + } + + if len(lines) < len(expectedSecrets) { + return nil, fmt.Errorf("expected %d secrets via Stdin, but only received %d", + len(expectedSecrets), len(lines)) + } + for i, line := range lines { + if line == "" { + return nil, fmt.Errorf("secret on line %d/%d was empty", i+1, len(lines)) + } + } + + return lines, nil +} diff --git a/ci/internal/simple.go b/ci/internal/simple.go new file mode 100644 index 00000000..746795e2 --- /dev/null +++ b/ci/internal/simple.go @@ -0,0 +1,134 @@ +package internal + +import ( + "bufio" + "context" + "fmt" + "io" + "regexp" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + v1 "github.com/opencontainers/image-spec/specs-go/v1" +) + +func ptrTo[T any](v T) *T { return &v } + +func simpleTest(ctx context.Context, env []string) error { + const timeout = 15 * time.Second + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + client, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return fmt.Errorf("creating Docker client: %w", err) + } + defer client.Close() + + config := &container.Config{ + Image: "qmcgaw/gluetun", + StopTimeout: ptrTo(3), + Env: env, + } + hostConfig := &container.HostConfig{ + AutoRemove: true, + CapAdd: []string{"NET_ADMIN", "NET_RAW"}, + } + networkConfig := (*network.NetworkingConfig)(nil) + platform := (*v1.Platform)(nil) + const containerName = "" // auto-generated name + + response, err := client.ContainerCreate(ctx, config, hostConfig, networkConfig, platform, containerName) + if err != nil { + return fmt.Errorf("creating container: %w", err) + } + for _, warning := range response.Warnings { + fmt.Println("Warning during container creation:", warning) + } + containerID := response.ID + defer stopContainer(client, containerID) + + beforeStartTime := time.Now() + + err = client.ContainerStart(ctx, containerID, container.StartOptions{}) + if err != nil { + return fmt.Errorf("starting container: %w", err) + } + + return waitForLogLine(ctx, client, containerID, beforeStartTime) +} + +func stopContainer(client *client.Client, containerID string) { + const stopTimeout = 5 * time.Second // must be higher than 3s, see above [container.Config]'s StopTimeout field + stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout) + defer stopCancel() + + err := client.ContainerStop(stopCtx, containerID, container.StopOptions{}) + if err != nil { + fmt.Println("failed to stop container:", err) + } +} + +var successRegexp = regexp.MustCompile(`^.+Public IP address is .+$`) + +func waitForLogLine(ctx context.Context, client *client.Client, containerID string, + beforeStartTime time.Time, +) error { + logOptions := container.LogsOptions{ + ShowStdout: true, + Follow: true, + Since: beforeStartTime.Format(time.RFC3339Nano), + } + + reader, err := client.ContainerLogs(ctx, containerID, logOptions) + if err != nil { + return fmt.Errorf("error getting container logs: %w", err) + } + defer reader.Close() + + var linesSeen []string + scanner := bufio.NewScanner(reader) + for ctx.Err() == nil { + if scanner.Scan() { + line := scanner.Text() + if len(line) > 8 { // remove Docker log prefix + line = line[8:] + } + linesSeen = append(linesSeen, line) + if successRegexp.MatchString(line) { + fmt.Println("✅ Success line logged") + return nil + } + continue + } + err := scanner.Err() + if err != nil && err != io.EOF { + logSeenLines(linesSeen) + return fmt.Errorf("reading log stream: %w", err) + } + + // The scanner is either done or cannot read because of EOF + fmt.Println("The log scanner stopped") + logSeenLines(linesSeen) + + // Check if the container is still running + inspect, err := client.ContainerInspect(ctx, containerID) + if err != nil { + return fmt.Errorf("inspecting container: %w", err) + } + if !inspect.State.Running { + return fmt.Errorf("container stopped unexpectedly while waiting for log line. Exit code: %d", inspect.State.ExitCode) + } + } + + return ctx.Err() +} + +func logSeenLines(lines []string) { + fmt.Println("Logs seen so far:") + for _, line := range lines { + fmt.Println(" " + line) + } +}