diff --git a/CHANGELOG.md b/CHANGELOG.md index 45afe16..f8f8e8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [v1.0.59] - 2026-01-23 + +### Added +- `vpn config` command to Generate VPN configuration file without connecting +- `vpn up` command now supports flags to skip DNS configuration + ## [v1.0.58] - 2026-01-21 ### Fixed diff --git a/src/cmd/logout.go b/src/cmd/logout.go index 49d883a..1163799 100644 --- a/src/cmd/logout.go +++ b/src/cmd/logout.go @@ -33,7 +33,7 @@ func logoutCmd() *cmdBuilder.Cmd { return err } if vpnActive { - _ = disconnectVpn(ctx, uxBlocks) + _ = disconnectVpn(ctx, uxBlocks, false, false) } uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.LogoutSuccess))) diff --git a/src/cmd/vpn.go b/src/cmd/vpn.go index 3eb8722..ba26bf9 100644 --- a/src/cmd/vpn.go +++ b/src/cmd/vpn.go @@ -12,6 +12,7 @@ func vpnCmd() *cmdBuilder.Cmd { HelpFlag(i18n.T(i18n.CmdHelpVpn)). AddChildrenCmd(vpnUpCmd()). AddChildrenCmd(vpnDownCmd()). + AddChildrenCmd(vpnConfigCmd()). AddChildrenCmd(vpnClearCmd()). AddChildrenCmd(vpnKeyCmd()) } diff --git a/src/cmd/vpnClear.go b/src/cmd/vpnClear.go index 96829fe..00e92fc 100644 --- a/src/cmd/vpnClear.go +++ b/src/cmd/vpnClear.go @@ -23,7 +23,7 @@ func vpnClearCmd() *cmdBuilder.Cmd { return err } - if vpnDownErr := disconnectVpn(ctx, cmdData.UxBlocks); vpnDownErr != nil { + if vpnDownErr := disconnectVpn(ctx, cmdData.UxBlocks, false, false); vpnDownErr != nil { cmdData.UxBlocks.PrintWarningTextf("vpn down: %s", vpnDownErr) } diff --git a/src/cmd/vpnConfig.go b/src/cmd/vpnConfig.go new file mode 100644 index 0000000..da713d3 --- /dev/null +++ b/src/cmd/vpnConfig.go @@ -0,0 +1,121 @@ +package cmd + +import ( + "context" + "os" + "time" + + "github.com/zeropsio/zcli/src/cliStorage" + "github.com/zeropsio/zcli/src/cmdBuilder" + "github.com/zeropsio/zcli/src/constants" + "github.com/zeropsio/zcli/src/entity" + "github.com/zeropsio/zcli/src/file" + "github.com/zeropsio/zcli/src/i18n" + "github.com/zeropsio/zcli/src/uxBlock/styles" + "github.com/zeropsio/zcli/src/wg" + "github.com/zeropsio/zerops-go/dto/input/body" + "github.com/zeropsio/zerops-go/dto/input/path" + "github.com/zeropsio/zerops-go/types" + "github.com/zeropsio/zerops-go/types/uuid" +) + +func vpnConfigCmd() *cmdBuilder.Cmd { + return cmdBuilder.NewCmd(). + Use("config"). + Short("Generate VPN configuration file without connecting"). + ScopeLevel(cmdBuilder.ScopeProject()). + Arg(cmdBuilder.ProjectArgName, cmdBuilder.OptionalArg()). + IntFlag(vpnFlagMtu, 1420, i18n.T(i18n.VpnMtuFlag)). + BoolFlag(vpnFlagSkipDnsSetup, false, "skip DNS configuration - you will need to use IP addresses to connect to services instead of domain names"). + StringFlag(vpnFlagOutput, "", "output file path (use '-' for stdout, empty for default location)"). + HelpFlag("Generate WireGuard VPN configuration file for the project without establishing connection"). + LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + dnsSetup := !cmdData.Params.GetBool(vpnFlagSkipDnsSetup) + + uxBlocks := cmdData.UxBlocks + project, err := cmdData.Project.Expect("project is null") + if err != nil { + return err + } + + privateKey, err := getOrCreatePrivateVpnKey(project, cmdData) + if err != nil { + return err + } + + publicKey := privateKey.PublicKey() + + postProjectResponse, err := cmdData.RestApiClient.PostProjectVpn( + ctx, + path.ProjectId{Id: project.Id}, + body.PostProjectVpn{PublicKey: types.String(publicKey.String())}, + ) + if err != nil { + return err + } + + vpnSettings, err := postProjectResponse.Output() + if err != nil { + return err + } + + outputPath := cmdData.Params.GetString(vpnFlagOutput) + + // Determine output destination + var f *os.File + var filePath string + var fileMode os.FileMode + + switch outputPath { + case "-": + // Output to stdout + f = os.Stdout + filePath = "stdout" + case "": + // Use default location + filePath, fileMode, err = constants.WgConfigFilePath() + if err != nil { + return err + } + f, err = file.Open(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) + if err != nil { + return err + } + defer f.Close() + default: + // Use custom file path + filePath = outputPath + fileMode = 0600 + f, err = os.OpenFile(filePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, fileMode) + if err != nil { + return err + } + defer f.Close() + } + + if err := wg.GenerateConfig(f, privateKey, vpnSettings, cmdData.Params.GetInt(vpnFlagMtu), dnsSetup); err != nil { + return err + } + + if outputPath != "-" { + uxBlocks.PrintInfo(styles.InfoWithValueLine(i18n.T(i18n.VpnConfigSaved), filePath)) + } + + if _, err = cmdData.CliStorage.Update(func(data cliStorage.Data) cliStorage.Data { + if data.ProjectVpnKeyRegistry == nil { + data.ProjectVpnKeyRegistry = make(map[uuid.ProjectId]entity.VpnKey) + } + data.ProjectVpnKeyRegistry[project.Id] = entity.VpnKey{ + ProjectId: project.Id, + Key: privateKey.String(), + CreatedAt: time.Now(), + } + + return data + }); err != nil { + return err + } + + return nil + }) +} diff --git a/src/cmd/vpnDown.go b/src/cmd/vpnDown.go index 7d2214b..4e6dbe5 100644 --- a/src/cmd/vpnDown.go +++ b/src/cmd/vpnDown.go @@ -19,13 +19,17 @@ func vpnDownCmd() *cmdBuilder.Cmd { Use("down"). Short(i18n.T(i18n.CmdDescVpnDown)). HelpFlag(i18n.T(i18n.CmdHelpVpnDown)). + BoolFlag(vpnFlagSkipDnsSetup, false, "skip DNS configuration - you will need to use IP addresses to connect to services instead of domain names"). + BoolFlag(vpnFlagSkipCheckInstallation, false, "skip WireGuard installation check"). LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { - return disconnectVpn(ctx, cmdData.UxBlocks) + checkInstallation := !cmdData.Params.GetBool(vpnFlagSkipCheckInstallation) + dnsSetup := !cmdData.Params.GetBool(vpnFlagSkipDnsSetup) + return disconnectVpn(ctx, cmdData.UxBlocks, dnsSetup, checkInstallation) }) } -func disconnectVpn(ctx context.Context, uxBlocks uxBlock.UxBlocks) error { - err := wg.CheckWgInstallation() +func disconnectVpn(ctx context.Context, uxBlocks uxBlock.UxBlocks, dnsSetup, checkInstallation bool) error { + err := wg.CheckWgInstallation(checkInstallation, dnsSetup) if err != nil { return err } diff --git a/src/cmd/vpnUp.go b/src/cmd/vpnUp.go index 2c72456..7c61416 100644 --- a/src/cmd/vpnUp.go +++ b/src/cmd/vpnUp.go @@ -28,16 +28,33 @@ import ( const vpnCheckAddress = "logger.core.zerops" +const ( + vpnFlagMtu = "mtu" + vpnFlagAutoDisconnect = "auto-disconnect" + vpnFlagSkipDnsSetup = "skip-dns-setup" + vpnFlagSkipVpnTest = "skip-vpn-test" + vpnFlagSkipCheckInstallation = "skip-check-installation" + vpnFlagSkipConnect = "skip-connect" + vpnFlagOutput = "output" +) + func vpnUpCmd() *cmdBuilder.Cmd { return cmdBuilder.NewCmd(). Use("up"). Short(i18n.T(i18n.CmdDescVpnUp)). ScopeLevel(cmdBuilder.ScopeProject()). Arg(cmdBuilder.ProjectArgName, cmdBuilder.OptionalArg()). - IntFlag("mtu", 1420, i18n.T(i18n.VpnMtuFlag)). - BoolFlag("auto-disconnect", false, i18n.T(i18n.VpnAutoDisconnectFlag)). + IntFlag(vpnFlagMtu, 1420, i18n.T(i18n.VpnMtuFlag)). + BoolFlag(vpnFlagAutoDisconnect, false, i18n.T(i18n.VpnAutoDisconnectFlag)). + BoolFlag(vpnFlagSkipDnsSetup, false, "skip DNS configuration - you will need to use IP addresses to connect to services instead of domain names"). + BoolFlag(vpnFlagSkipVpnTest, false, "skip VPN connectivity test after connection is established"). + BoolFlag(vpnFlagSkipCheckInstallation, false, "skip WireGuard installation check"). HelpFlag(i18n.T(i18n.CmdHelpVpnUp)). LoggedUserRunFunc(func(ctx context.Context, cmdData *cmdBuilder.LoggedUserCmdData) error { + dnsSetup := !cmdData.Params.GetBool(vpnFlagSkipDnsSetup) + vpnTest := !cmdData.Params.GetBool(vpnFlagSkipVpnTest) + checkInstallation := !cmdData.Params.GetBool(vpnFlagSkipCheckInstallation) + uxBlocks := cmdData.UxBlocks project, err := cmdData.Project.Expect("project is null") if err != nil { @@ -49,8 +66,8 @@ func vpnUpCmd() *cmdBuilder.Cmd { return err } if vpnActive { - if cmdData.Params.GetBool("auto-disconnect") { - if err := disconnectVpn(ctx, uxBlocks); err != nil { + if cmdData.Params.GetBool(vpnFlagAutoDisconnect) { + if err := disconnectVpn(ctx, uxBlocks, dnsSetup, checkInstallation); err != nil { return err } } else { @@ -66,7 +83,7 @@ func vpnUpCmd() *cmdBuilder.Cmd { return nil } - if err := disconnectVpn(ctx, uxBlocks); err != nil { + if err := disconnectVpn(ctx, uxBlocks, dnsSetup, checkInstallation); err != nil { return err } } @@ -104,8 +121,7 @@ func vpnUpCmd() *cmdBuilder.Cmd { } defer f.Close() - err = wg.GenerateConfig(f, privateKey, vpnSettings, cmdData.Params.GetInt("mtu")) - if err != nil { + if err := wg.GenerateConfig(f, privateKey, vpnSettings, cmdData.Params.GetInt(vpnFlagMtu), dnsSetup); err != nil { return err } @@ -127,8 +143,7 @@ func vpnUpCmd() *cmdBuilder.Cmd { return err } - err = wg.CheckWgInstallation() - if err != nil { + if err := wg.CheckWgInstallation(checkInstallation, dnsSetup); err != nil { return err } @@ -138,11 +153,13 @@ func vpnUpCmd() *cmdBuilder.Cmd { return err } - // wait for the vpn to be up - if isVpnUp(ctx, uxBlocks, 6) { - uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.VpnUp))) - } else { - uxBlocks.PrintWarning(styles.WarningLine(i18n.T(i18n.VpnPingFailed))) + if vpnTest && dnsSetup { + // wait for the vpn to be up + if isVpnUp(ctx, uxBlocks, 6) { + uxBlocks.PrintInfo(styles.SuccessLine(i18n.T(i18n.VpnUp))) + } else { + uxBlocks.PrintWarning(styles.WarningLine(i18n.T(i18n.VpnPingFailed))) + } } return nil diff --git a/src/wg/darwin.go b/src/wg/darwin.go index c659d41..2af4dfa 100644 --- a/src/wg/darwin.go +++ b/src/wg/darwin.go @@ -20,7 +20,10 @@ import ( const wgRunDir = "/var/run/wireguard/" -func CheckWgInstallation() error { +func CheckWgInstallation(checkInstallation, _ bool) error { + if !checkInstallation { + return nil + } _, err := exec.LookPath("wg-quick") if err != nil { return errors.New(i18n.T(i18n.VpnWgQuickIsNotInstalled)) @@ -29,8 +32,8 @@ func CheckWgInstallation() error { return nil } -func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int) error { - data, err := defaultTemplateData(privateKey, vpnSettings, mtu) +func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int, dnsSetup bool) error { + data, err := defaultTemplateData(privateKey, vpnSettings, mtu, dnsSetup) if err != nil { return err } @@ -71,11 +74,13 @@ PrivateKey = {{.PrivateKey}} MTU = {{.Mtu}} Address = {{if .AssignedIpv4Address}}{{.AssignedIpv4Address}}/32{{end}}, {{.AssignedIpv6Address}}/128 +{{if .DnsSetup -}} PostUp = mkdir -p /etc/resolver PostUp = echo "nameserver {{.Ipv4NetworkGateway}}" > /etc/resolver/zerops PostUp = echo "domain zerops" >> /etc/resolver/zerops PostUp = echo "search zerops" >> /etc/resolver/zerops PostDown = rm /etc/resolver/zerops +{{end}} [Peer] PublicKey = {{.PublicKey}} diff --git a/src/wg/linux.go b/src/wg/linux.go index 5d7bf8e..9ffff98 100644 --- a/src/wg/linux.go +++ b/src/wg/linux.go @@ -18,19 +18,24 @@ import ( "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) -func CheckWgInstallation() error { +func CheckWgInstallation(checkInstallation, dnsSetup bool) error { + if !checkInstallation { + return nil + } if _, err := exec.LookPath("wg-quick"); err != nil { return errors.New(i18n.T(i18n.VpnWgQuickIsNotInstalled)) } // Debian does not have it by default anymore - if _, err := exec.LookPath("resolvectl"); err != nil { - return errors.New(i18n.T(i18n.VpnResolveCtlIsNotInstalled)) + if dnsSetup { + if _, err := exec.LookPath("resolvectl"); err != nil { + return errors.New(i18n.T(i18n.VpnResolveCtlIsNotInstalled)) + } } return nil } -func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int) error { - data, err := defaultTemplateData(privateKey, vpnSettings, mtu) +func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int, dnsSetup bool) error { + data, err := defaultTemplateData(privateKey, vpnSettings, mtu, dnsSetup) if err != nil { return err } @@ -63,9 +68,10 @@ PrivateKey = {{.PrivateKey}} MTU = {{.Mtu}} Address = {{if .AssignedIpv4Address}}{{.AssignedIpv4Address}}/32{{end}}, {{.AssignedIpv6Address}}/128 +{{if .DnsSetup -}} PostUp = resolvectl dns %i {{.Ipv4NetworkGateway}} PostUp = resolvectl domain %i zerops - +{{end}} [Peer] PublicKey = {{.PublicKey}} diff --git a/src/wg/wg.go b/src/wg/wg.go index 6d33fcb..f486782 100644 --- a/src/wg/wg.go +++ b/src/wg/wg.go @@ -2,19 +2,33 @@ package wg import ( "net" - "strconv" "github.com/pkg/errors" "github.com/zeropsio/zerops-go/dto/output" "golang.zx2c4.com/wireguard/wgctrl/wgtypes" ) -func defaultTemplateData(privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int) (map[string]string, error) { +type TemplateData struct { + Mtu int + PrivateKey string + PublicKey string + AssignedIpv4Address string + AssignedIpv6Address string + Ipv4NetworkGateway string + ProjectIpv4Network string + ProjectIpv6Network string + Ipv4Network string + Ipv6Network string + DnsSetup bool + ProjectIpv4SharedEndpoint string +} + +func defaultTemplateData(privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int, dnsSetup bool) (TemplateData, error) { projectIpv4Network := "" if vpnSettings.Project.Ipv4.Network.Network != "" { _, n, err := net.ParseCIDR(string(vpnSettings.Project.Ipv4.Network.Network)) if err != nil { - return nil, errors.Wrap(err, "failed to parse projectIpv4Network network") + return TemplateData{}, errors.Wrap(err, "failed to parse projectIpv4Network network") } projectIpv4Network = n.String() } @@ -23,7 +37,7 @@ func defaultTemplateData(privateKey wgtypes.Key, vpnSettings output.ProjectVpnIt if vpnSettings.Project.Ipv6.Network.Network != "" { _, n, err := net.ParseCIDR(string(vpnSettings.Project.Ipv6.Network.Network)) if err != nil { - return nil, errors.Wrap(err, "failed to parse projectIpv6Network network") + return TemplateData{}, errors.Wrap(err, "failed to parse projectIpv6Network network") } projectIpv6Network = n.String() } @@ -32,7 +46,7 @@ func defaultTemplateData(privateKey wgtypes.Key, vpnSettings output.ProjectVpnIt if vpnSettings.Peer.Ipv4.Network.Network != "" { _, n, err := net.ParseCIDR(string(vpnSettings.Peer.Ipv4.Network.Network)) if err != nil { - return nil, errors.Wrap(err, "failed to parse Ipv4Network network") + return TemplateData{}, errors.Wrap(err, "failed to parse Ipv4Network network") } ipv4Network = n.String() } @@ -41,22 +55,23 @@ func defaultTemplateData(privateKey wgtypes.Key, vpnSettings output.ProjectVpnIt if vpnSettings.Peer.Ipv6.Network.Network != "" { _, n, err := net.ParseCIDR(string(vpnSettings.Peer.Ipv6.Network.Network)) if err != nil { - return nil, errors.Wrap(err, "failed to parse Ipv6Network network") + return TemplateData{}, errors.Wrap(err, "failed to parse Ipv6Network network") } ipv6Network = n.String() } - return map[string]string{ - "Mtu": strconv.Itoa(mtu), - "PrivateKey": privateKey.String(), - "PublicKey": string(vpnSettings.Project.PublicKey), - "AssignedIpv4Address": string(vpnSettings.Peer.Ipv4.AssignedIpAddress), - "AssignedIpv6Address": string(vpnSettings.Peer.Ipv6.AssignedIpAddress), - "Ipv4NetworkGateway": string(vpnSettings.Project.Ipv4.Network.Gateway), - "ProjectIpv4Network": projectIpv4Network, - "ProjectIpv6Network": projectIpv6Network, - "Ipv4Network": ipv4Network, - "Ipv6Network": ipv6Network, - "ProjectIpv4SharedEndpoint": string(vpnSettings.Project.Ipv4.SharedEndpoint), + return TemplateData{ + Mtu: mtu, + PrivateKey: privateKey.String(), + PublicKey: string(vpnSettings.Project.PublicKey), + AssignedIpv4Address: string(vpnSettings.Peer.Ipv4.AssignedIpAddress), + AssignedIpv6Address: string(vpnSettings.Peer.Ipv6.AssignedIpAddress), + Ipv4NetworkGateway: string(vpnSettings.Project.Ipv4.Network.Gateway), + ProjectIpv4Network: projectIpv4Network, + ProjectIpv6Network: projectIpv6Network, + Ipv4Network: ipv4Network, + Ipv6Network: ipv6Network, + DnsSetup: dnsSetup, + ProjectIpv4SharedEndpoint: string(vpnSettings.Project.Ipv4.SharedEndpoint), }, nil } diff --git a/src/wg/windows.go b/src/wg/windows.go index 3998852..b58bc17 100644 --- a/src/wg/windows.go +++ b/src/wg/windows.go @@ -25,7 +25,10 @@ import ( // Only (simple) way I found to achieve this is to run Start-Process cmdlet with param '-Verb RunAS' // https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.4 -func CheckWgInstallation() error { +func CheckWgInstallation(checkInstallation, _ bool) error { + if !checkInstallation { + return nil + } _, err := exec.LookPath("wireguard") if err != nil { return errors.New(i18n.T(i18n.VpnWgQuickIsNotInstalledWindows)) @@ -34,8 +37,8 @@ func CheckWgInstallation() error { return nil } -func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int) error { - data, err := defaultTemplateData(privateKey, vpnSettings, mtu) +func GenerateConfig(f io.Writer, privateKey wgtypes.Key, vpnSettings output.ProjectVpnItem, mtu int, dnsSetup bool) error { + data, err := defaultTemplateData(privateKey, vpnSettings, mtu, dnsSetup) if err != nil { return err } @@ -132,10 +135,12 @@ PrivateKey = {{.PrivateKey}} MTU = {{.Mtu}} Address = {{if .AssignedIpv4Address}}{{.AssignedIpv4Address}}/32{{end}}, {{.AssignedIpv6Address}}/128 +{{if .DnsSetup -}} DNS = {{.Ipv4NetworkGateway}}, zerops ### Alternative DNS # PostUp = powershell -command "Add-DnsClientNrptRule -Namespace 'zerops' -NameServers '{{.Ipv4NetworkGateway}}'" # PostDown = powershell -command "Get-DnsClientNrptRule | Where { $_.Namespace -match '.*zerops' } | Remove-DnsClientNrptRule -force" +{{end}} [Peer] PublicKey = {{.PublicKey}}