Interactive install, with flags kept for unattended installs
In a terminal, install now asks for the domain, Let's Encrypt email, endpoint, WireGuard port and admin password, shows a summary and changes nothing until confirmed. Re-runs offer the current settings and say how many devices need a new config when the endpoint or port changes. Flags still work and skip their question; -y or no terminal skips all questions. New -port flag sets the WireGuard UDP port. Every value is checked before the system is touched.
This commit is contained in:
@@ -55,8 +55,8 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
## Requirements
|
||||
|
||||
- Linux with kernel 5.6 or newer (WireGuard built in), nftables and systemd
|
||||
- Ports: UDP 51820 (WireGuard), TCP 443 (web), TCP 80 (optional, Let's Encrypt
|
||||
http-01 and redirect)
|
||||
- Ports: UDP 51820 (WireGuard; another port can be chosen at install), TCP 443
|
||||
(web), TCP 80 (optional, Let's Encrypt http-01 and redirect)
|
||||
|
||||
## Build
|
||||
|
||||
@@ -77,12 +77,62 @@ The binary installs itself. Copy it to the server and run it as root:
|
||||
```sh
|
||||
scp dist/amd64/GHOSTWIRE server:/tmp/
|
||||
ssh server
|
||||
sudo /tmp/GHOSTWIRE install -domain vpn.example.net -email you@example.net
|
||||
sudo /tmp/GHOSTWIRE install
|
||||
```
|
||||
|
||||
`-domain` turns on Let's Encrypt and is also used as the WireGuard endpoint.
|
||||
Without it, the web interface uses a self-signed certificate; set the endpoint
|
||||
later in the web interface or with `-endpoint`.
|
||||
It asks a few questions, shows a summary and changes nothing until you
|
||||
confirm:
|
||||
|
||||
```
|
||||
Web interface
|
||||
Domain name for the web interface (empty: no domain, self-signed certificate)
|
||||
> vpn.example.net
|
||||
Email for Let's Encrypt expiry warnings (optional)
|
||||
> you@example.net
|
||||
|
||||
WireGuard
|
||||
Address devices connect to [vpn.example.net]
|
||||
>
|
||||
UDP port [51820]
|
||||
>
|
||||
|
||||
Admin account
|
||||
Password for "admin" (at least 12 characters): ************
|
||||
Repeat password: ************
|
||||
|
||||
Summary
|
||||
Web interface https://vpn.example.net/ (Let's Encrypt, you@example.net)
|
||||
Endpoint vpn.example.net:51820/udp
|
||||
Tunnel network 10.214.86.0/24 (random free range) · IPv6 on
|
||||
Firewall 443/tcp, 80/tcp, 51820/udp must be reachable
|
||||
|
||||
Install with these settings? [Y/n]
|
||||
```
|
||||
|
||||
A domain turns on Let's Encrypt and is also the default WireGuard endpoint.
|
||||
Without one, the web interface uses a self-signed certificate and the
|
||||
endpoint defaults to the server's detected public IP.
|
||||
|
||||
### Unattended install
|
||||
|
||||
For scripts, cloud-init or Ansible, give the settings as flags. Questions
|
||||
are skipped for every flag given, and entirely with `-y` or when there is no
|
||||
terminal:
|
||||
|
||||
```sh
|
||||
sudo /tmp/GHOSTWIRE install -y -domain vpn.example.net -email you@example.net -port 51820
|
||||
```
|
||||
|
||||
| Flag | Default |
|
||||
|---|---|
|
||||
| `-domain` | none: self-signed certificate |
|
||||
| `-email` | none |
|
||||
| `-endpoint` | the domain |
|
||||
| `-port` | 51820, or the current port when already installed |
|
||||
|
||||
The admin password is then read from standard input, e.g.
|
||||
`echo "$PASSWORD" | sudo ./GHOSTWIRE install -y …`. Every value is checked
|
||||
before anything is changed.
|
||||
|
||||
`install`:
|
||||
|
||||
@@ -92,10 +142,13 @@ later in the web interface or with `-endpoint`.
|
||||
4. writes `/etc/sysctl.d/99-ghostwire.conf` (IP forwarding) and
|
||||
`/etc/modules-load.d/ghostwire.conf`, and loads the kernel module
|
||||
5. writes `/etc/systemd/system/ghostwire.service`
|
||||
6. asks for the admin password (first install only)
|
||||
6. sets the admin password (first install only)
|
||||
7. enables and starts the service, and checks that it stays up
|
||||
|
||||
Running it again is safe: steps that are already done are skipped.
|
||||
Running it again is safe: steps that are already done are skipped, and the
|
||||
questions offer the current settings, so Enter keeps them. If a changed
|
||||
endpoint or port means existing devices need a new config, the summary says
|
||||
how many.
|
||||
|
||||
Then open `https://vpn.example.net` and sign in as `admin`. Root is needed only
|
||||
for the commands below, never for the running service.
|
||||
@@ -104,7 +157,7 @@ for the commands below, never for the running service.
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h]` | Sets up and starts the service, as above. |
|
||||
| `GHOSTWIRE install [-domain d] [-email e] [-endpoint h] [-port p] [-y]` | Sets up and starts the service, as above. Asks for the settings no flag gave; `-y` never asks. |
|
||||
| `GHOSTWIRE update [-force]` | Run from the new binary, e.g. `sudo /tmp/GHOSTWIRE update`. Checks that it can read the current `config.json` (nothing changes if not), backs up the config to `config.json.bak-<old version>`, replaces the binary, updates the unit if needed and restarts. If the new version does not stay up, the old binary is put back and restarted. It refuses older versions without `-force`. |
|
||||
| `GHOSTWIRE uninstall [-purge] [-y]` | Stops and removes the service, `wg0` and the firewall table. `-purge` also deletes `/opt/ghostwire` and the user. |
|
||||
| `GHOSTWIRE passwd` | Sets the admin password and reloads the running service. |
|
||||
|
||||
@@ -474,22 +474,30 @@ func (a *App) rotateServerKey(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (a *App) detectIP(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
ip, err := detectPublicIP(r.Context())
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("%v", err))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ip": ip.String()})
|
||||
}
|
||||
|
||||
// detectPublicIP asks an outside service which address this server has.
|
||||
func detectPublicIP(ctx context.Context) (netip.Addr, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://checkip.amazonaws.com", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("could not detect the public IP: %v", err))
|
||||
return
|
||||
return netip.Addr{}, fmt.Errorf("could not detect the public IP: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 100))
|
||||
ip, err := netip.ParseAddr(strings.TrimSpace(string(b)))
|
||||
if err != nil {
|
||||
writeErr(w, badRequest("unexpected answer from the IP service"))
|
||||
return
|
||||
return netip.Addr{}, errors.New("unexpected answer from the IP service")
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ip": ip.String()})
|
||||
return ip, nil
|
||||
}
|
||||
|
||||
// --- peers ---
|
||||
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// installPlan is what install changes in config.json. It comes from the
|
||||
// flags, and in a terminal from the answers to its questions.
|
||||
type installPlan struct {
|
||||
domain, email, endpoint string
|
||||
port int
|
||||
noDomain bool // turn an existing domain off (self-signed certificate)
|
||||
noEmail bool // remove an existing Let's Encrypt email
|
||||
passwordHash string // asked in the terminal; empty: asked later
|
||||
ipv4 string // tunnel network of a new install
|
||||
}
|
||||
|
||||
var domainRe = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$`)
|
||||
|
||||
func checkDomain(d string) error {
|
||||
if !domainRe.MatchString(d) {
|
||||
return fmt.Errorf("%q is not a domain name like vpn.example.net", d)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkEmail(e string) error {
|
||||
if at := strings.Index(e, "@"); at < 1 || at == len(e)-1 || strings.ContainsAny(e, " ,;") {
|
||||
return fmt.Errorf("%q is not an email address", e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkEndpoint(h string) error {
|
||||
if net.ParseIP(h) != nil || domainRe.MatchString(h) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%q is not a host name or IP address (without port)", h)
|
||||
}
|
||||
|
||||
func checkPort(p int) error {
|
||||
if p < 1 || p > 65535 {
|
||||
return errors.New("the port must be 1–65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// check validates the flags before anything is changed.
|
||||
func (p installPlan) check() error {
|
||||
if p.domain != "" {
|
||||
if err := checkDomain(p.domain); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.email != "" {
|
||||
if err := checkEmail(p.email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.endpoint != "" {
|
||||
if err := checkEndpoint(p.endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.port != 0 {
|
||||
return checkPort(p.port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p installPlan) changes() bool {
|
||||
return p.domain != "" || p.email != "" || p.endpoint != "" || p.port != 0 || p.noDomain || p.noEmail || p.passwordHash != ""
|
||||
}
|
||||
|
||||
func (p installPlan) apply(c *Config) {
|
||||
if p.noDomain {
|
||||
if c.Web.TLS.Mode == "acme" {
|
||||
c.Web.TLS.Mode = "selfsigned"
|
||||
}
|
||||
c.Web.TLS.Domain, c.Web.TLS.Email = "", ""
|
||||
}
|
||||
if p.domain != "" {
|
||||
c.Web.TLS.Mode, c.Web.TLS.Domain = "acme", p.domain
|
||||
if c.Server.Endpoint == "" {
|
||||
c.Server.Endpoint = p.domain
|
||||
}
|
||||
}
|
||||
if p.noEmail {
|
||||
c.Web.TLS.Email = ""
|
||||
}
|
||||
if p.email != "" {
|
||||
c.Web.TLS.Email = p.email
|
||||
}
|
||||
if p.endpoint != "" {
|
||||
c.Server.Endpoint = p.endpoint
|
||||
}
|
||||
if p.port != 0 {
|
||||
c.Server.ListenPort = p.port
|
||||
}
|
||||
if p.passwordHash != "" {
|
||||
c.Admin.PasswordHash = p.passwordHash
|
||||
}
|
||||
}
|
||||
|
||||
// reissueCount is the number of devices whose config stops working because
|
||||
// the endpoint host or port changes.
|
||||
func (p installPlan) reissueCount(cur *Config, existing bool) int {
|
||||
if !existing {
|
||||
return 0
|
||||
}
|
||||
next := cur.clone()
|
||||
p.apply(next)
|
||||
if endpointString(next) == endpointString(cur) {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for i := range cur.Peers {
|
||||
if cur.Peers[i].hasKey() {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// --- questions ---
|
||||
|
||||
var errCancelled = errors.New("install cancelled; nothing was changed")
|
||||
|
||||
type prompter struct{ r *bufio.Reader }
|
||||
|
||||
// ask prints a question and returns the answer, def on Enter. check may
|
||||
// reject an answer; the question is then asked again.
|
||||
func (pr prompter) ask(question, def string, check func(string) error) (string, error) {
|
||||
for {
|
||||
if def != "" {
|
||||
fmt.Printf(" %s [%s]\n > ", question, def)
|
||||
} else {
|
||||
fmt.Printf(" %s\n > ", question)
|
||||
}
|
||||
line, err := pr.r.ReadString('\n')
|
||||
if err != nil && (err != io.EOF || line == "") {
|
||||
fmt.Println()
|
||||
return "", errCancelled
|
||||
}
|
||||
v := strings.TrimSpace(line)
|
||||
if v == "" {
|
||||
v = def
|
||||
}
|
||||
if check != nil {
|
||||
if err := check(v); err != nil {
|
||||
fmt.Printf(" ✗ %v\n", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (pr prompter) confirm(question string) (bool, error) {
|
||||
fmt.Printf("%s [Y/n] ", question)
|
||||
line, err := pr.r.ReadString('\n')
|
||||
if err != nil && line == "" {
|
||||
fmt.Println()
|
||||
return false, errCancelled
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(line)) {
|
||||
case "", "y", "yes":
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// askInstall asks for every setting no flag gave, shows a summary and asks
|
||||
// for confirmation. Nothing on the system has changed when it returns.
|
||||
func askInstall(in io.Reader, cur *Config, existing bool, given map[string]bool, p installPlan) (installPlan, error) {
|
||||
pr := prompter{bufio.NewReader(in)}
|
||||
fmt.Printf("\n%s %s — WireGuard server manager\n", appName, version)
|
||||
if existing {
|
||||
fmt.Println("Already installed: the current settings are in [brackets]. Press Enter to keep them.")
|
||||
} else {
|
||||
fmt.Println("Press Enter to accept the value in [brackets].")
|
||||
}
|
||||
|
||||
// Web interface
|
||||
fmt.Println("\nWeb interface")
|
||||
curDomain := ""
|
||||
if cur.Web.TLS.Mode == "acme" {
|
||||
curDomain = cur.Web.TLS.Domain
|
||||
}
|
||||
domain := p.domain
|
||||
if !given["domain"] {
|
||||
q := "Domain name for the web interface (empty: no domain, self-signed certificate)"
|
||||
if curDomain != "" {
|
||||
q = "Domain name for the web interface (none: no domain, self-signed certificate)"
|
||||
}
|
||||
v, err := pr.ask(q, curDomain, func(v string) error {
|
||||
if v == "" || v == "none" {
|
||||
return nil
|
||||
}
|
||||
return checkDomain(v)
|
||||
})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
switch {
|
||||
case v == "none" || v == "":
|
||||
p.noDomain, domain = curDomain != "", ""
|
||||
case v != curDomain:
|
||||
p.domain, domain = v, v
|
||||
default:
|
||||
domain = v
|
||||
}
|
||||
}
|
||||
if domain == "" && cur.Web.TLS.Mode == "acme" && !p.noDomain {
|
||||
domain = curDomain
|
||||
}
|
||||
if domain != "" && !given["email"] {
|
||||
def := cur.Web.TLS.Email
|
||||
q := "Email for Let's Encrypt expiry warnings (optional)"
|
||||
if def != "" {
|
||||
q = "Email for Let's Encrypt expiry warnings (none: no email)"
|
||||
}
|
||||
v, err := pr.ask(q, def, func(v string) error {
|
||||
if v == "" || v == "none" {
|
||||
return nil
|
||||
}
|
||||
return checkEmail(v)
|
||||
})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
switch {
|
||||
case v == "none":
|
||||
p.noEmail = true
|
||||
case v != cur.Web.TLS.Email:
|
||||
p.email = v
|
||||
}
|
||||
}
|
||||
|
||||
// WireGuard
|
||||
fmt.Println("\nWireGuard")
|
||||
if !given["endpoint"] {
|
||||
def, hint := cur.Server.Endpoint, ""
|
||||
// An endpoint that followed the old domain follows the new one.
|
||||
if def == "" || (domain != "" && def == curDomain) {
|
||||
def = domain
|
||||
}
|
||||
if def == "" {
|
||||
if ip, err := detectPublicIP(context.Background()); err == nil {
|
||||
def, hint = ip.String(), " (detected public IP)"
|
||||
}
|
||||
}
|
||||
v, err := pr.ask("Address devices connect to"+hint, def, func(v string) error {
|
||||
if v == "" {
|
||||
return errors.New("devices need an address to connect to")
|
||||
}
|
||||
return checkEndpoint(v)
|
||||
})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if v != cur.Server.Endpoint {
|
||||
p.endpoint = v
|
||||
}
|
||||
}
|
||||
if !given["port"] {
|
||||
v, err := pr.ask("UDP port", strconv.Itoa(cur.Server.ListenPort), func(v string) error {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return errors.New("the port must be a number")
|
||||
}
|
||||
return checkPort(n)
|
||||
})
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if n, _ := strconv.Atoi(v); n != cur.Server.ListenPort {
|
||||
p.port = n
|
||||
}
|
||||
}
|
||||
|
||||
// Admin account, only when no password is set yet.
|
||||
if cur.Admin.PasswordHash == "" {
|
||||
fmt.Println("\nAdmin account")
|
||||
for {
|
||||
pw, err := readSecret(fmt.Sprintf(" Password for %q (at least 12 characters): ", cur.Admin.Username))
|
||||
if err != nil {
|
||||
return p, errCancelled
|
||||
}
|
||||
if err := validatePassword(pw); err != nil {
|
||||
fmt.Printf(" ✗ %v\n", err)
|
||||
continue
|
||||
}
|
||||
again, err := readSecret(" Repeat password: ")
|
||||
if err != nil {
|
||||
return p, errCancelled
|
||||
}
|
||||
if again != pw {
|
||||
fmt.Println(" ✗ the passwords do not match")
|
||||
continue
|
||||
}
|
||||
if p.passwordHash, err = hashPassword(pw); err != nil {
|
||||
return p, err
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
printInstallSummary(cur, existing, p)
|
||||
ok, err := pr.confirm("\nInstall with these settings?")
|
||||
if err != nil {
|
||||
return p, err
|
||||
}
|
||||
if !ok {
|
||||
return p, errCancelled
|
||||
}
|
||||
fmt.Println()
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func printInstallSummary(cur *Config, existing bool, p installPlan) {
|
||||
next := cur.clone()
|
||||
if !existing {
|
||||
next.Server.IPv4 = p.ipv4
|
||||
next.Server.IPv6Enabled = hasGlobalIPv6()
|
||||
}
|
||||
p.apply(next)
|
||||
if next.Server.Endpoint == "" {
|
||||
next.Server.Endpoint = next.Web.TLS.Domain
|
||||
}
|
||||
|
||||
host := next.Web.TLS.Domain
|
||||
if host == "" {
|
||||
host = next.Server.Endpoint
|
||||
}
|
||||
_, webPort, _ := strings.Cut(next.Web.Listen, ":")
|
||||
if webPort != "" && webPort != "443" {
|
||||
host += ":" + webPort
|
||||
}
|
||||
web := "https://" + host + "/"
|
||||
switch next.Web.TLS.Mode {
|
||||
case "acme":
|
||||
web += " (Let's Encrypt"
|
||||
if next.Web.TLS.Email != "" {
|
||||
web += ", " + next.Web.TLS.Email
|
||||
}
|
||||
web += ")"
|
||||
case "selfsigned":
|
||||
web += " (self-signed certificate: the browser warns once)"
|
||||
case "files":
|
||||
web += " (your certificate files)"
|
||||
case "off":
|
||||
web = "http://" + host + "/ (plain HTTP behind a reverse proxy)"
|
||||
}
|
||||
|
||||
ep := endpointString(next) + "/udp"
|
||||
if n := p.reissueCount(cur, existing); n > 0 {
|
||||
ep += fmt.Sprintf(" (was %s — %d existing device(s) need a new config)", endpointString(cur), n)
|
||||
}
|
||||
tunnel := next.Server.IPv4
|
||||
if !existing {
|
||||
tunnel += " (random free range)"
|
||||
}
|
||||
if next.Server.IPv6Enabled {
|
||||
tunnel += " · IPv6 on"
|
||||
} else {
|
||||
tunnel += " · IPv6 off (no public IPv6 address)"
|
||||
}
|
||||
|
||||
var ports []string
|
||||
if next.Web.TLS.Mode != "off" {
|
||||
if webPort == "" {
|
||||
webPort = "443"
|
||||
}
|
||||
ports = append(ports, webPort+"/tcp")
|
||||
if next.Web.TLS.Mode == "acme" {
|
||||
ports = append(ports, "80/tcp")
|
||||
}
|
||||
}
|
||||
ports = append(ports, strconv.Itoa(next.Server.ListenPort)+"/udp")
|
||||
|
||||
fmt.Println("\nSummary")
|
||||
fmt.Printf(" Web interface %s\n", web)
|
||||
fmt.Printf(" Endpoint %s\n", ep)
|
||||
fmt.Printf(" Tunnel network %s\n", tunnel)
|
||||
fmt.Printf(" Firewall %s must be reachable\n", strings.Join(ports, ", "))
|
||||
if p.passwordHash != "" {
|
||||
fmt.Printf(" Admin %s (password set)\n", next.Admin.Username)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -619,3 +620,64 @@ func TestSetupLink(t *testing.T) {
|
||||
call(admin, "DELETE", "/api/v1/peers/"+id+"/setup", nil, 200)
|
||||
call(admin, "GET", "/api/v1/peers/"+id+"/setup", nil, 404)
|
||||
}
|
||||
|
||||
// TestInstallQuestions answers the interactive install's questions.
|
||||
func TestInstallQuestions(t *testing.T) {
|
||||
hash, _ := hashPassword("a long test password")
|
||||
fresh := func() *Config {
|
||||
c := &Config{}
|
||||
c.applyDefaults()
|
||||
c.Admin.PasswordHash = hash // skips the password question
|
||||
return c
|
||||
}
|
||||
|
||||
// New install: domain, email, endpoint from the domain, own port. A bad
|
||||
// port is asked again.
|
||||
in := "vpn.example.net\nyou@example.net\n\nabc\n70000\n51900\ny\n"
|
||||
p, err := askInstall(strings.NewReader(in), fresh(), false, map[string]bool{}, installPlan{ipv4: "10.9.8.0/24"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := fresh()
|
||||
p.apply(c)
|
||||
if c.Web.TLS.Mode != "acme" || c.Web.TLS.Domain != "vpn.example.net" || c.Web.TLS.Email != "you@example.net" ||
|
||||
c.Server.Endpoint != "vpn.example.net" || c.Server.ListenPort != 51900 {
|
||||
t.Fatalf("answers not applied: %+v %+v", c.Web.TLS, c.Server)
|
||||
}
|
||||
|
||||
// Re-run with a device: Enter keeps everything, so no change.
|
||||
cur := fresh()
|
||||
p.apply(cur)
|
||||
cur.Peers = []Peer{{ID: "a", Name: "phone", IPv4: "10.9.8.2", PublicKey: "k"}}
|
||||
p2, err := askInstall(strings.NewReader("\n\n\n\ny\n"), cur, true, map[string]bool{}, installPlan{})
|
||||
if err != nil || p2.changes() {
|
||||
t.Fatalf("Enter should keep the settings: %+v %v", p2, err)
|
||||
}
|
||||
|
||||
// Changing the port warns about the device; answering n cancels.
|
||||
p3, err := askInstall(strings.NewReader("\n\n\n51820\nn\n"), cur, true, map[string]bool{}, installPlan{})
|
||||
if !errors.Is(err, errCancelled) {
|
||||
t.Fatalf("want cancel, got %v", err)
|
||||
}
|
||||
if p3.reissueCount(cur, true) != 1 {
|
||||
t.Fatal("port change should need a new config for the device")
|
||||
}
|
||||
|
||||
// "none" turns the domain off; given flags are not asked.
|
||||
p4, err := askInstall(strings.NewReader("none\n\ny\n"), cur, true, map[string]bool{"port": true}, installPlan{})
|
||||
if err != nil || !p4.noDomain {
|
||||
t.Fatalf("none should remove the domain: %+v %v", p4, err)
|
||||
}
|
||||
c = cur.clone()
|
||||
p4.apply(c)
|
||||
if c.Web.TLS.Mode != "selfsigned" || c.Web.TLS.Domain != "" || c.Server.Endpoint != "vpn.example.net" {
|
||||
t.Fatalf("domain not removed: %+v", c.Web.TLS)
|
||||
}
|
||||
|
||||
// Flags are checked before anything changes.
|
||||
for _, bad := range []installPlan{{port: 70000}, {domain: "not a domain"}, {email: "nope"}, {endpoint: "host:51820"}} {
|
||||
if bad.check() == nil {
|
||||
t.Errorf("%+v should be rejected", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ func usage() {
|
||||
fmt.Fprintf(os.Stderr, `%s %s — WireGuard server manager
|
||||
|
||||
Usage (as root):
|
||||
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host]
|
||||
set up user, folder, config, sysctls and systemd service; start it
|
||||
%s install [-domain vpn.example.net] [-email you@example.net] [-endpoint host] [-port 51820] [-y]
|
||||
set up user, folder, config, sysctls and systemd service; start it.
|
||||
In a terminal it asks for the settings no flag gave; -y never asks
|
||||
%s update [-force]
|
||||
replace the installed binary with this one and restart
|
||||
%s uninstall [-purge] [-y]
|
||||
@@ -330,7 +331,11 @@ func cmdInstall(args []string) error {
|
||||
domain := fs.String("domain", "", "domain for the web interface; enables Let's Encrypt")
|
||||
email := fs.String("email", "", "contact email for Let's Encrypt (optional)")
|
||||
endpoint := fs.String("endpoint", "", "host or IP clients connect to (default: the domain)")
|
||||
port := fs.Int("port", 0, "UDP port WireGuard listens on (default: 51820, or the current port when already installed)")
|
||||
yes := fs.Bool("y", false, "do not ask; use the flags and defaults")
|
||||
_ = fs.Parse(args)
|
||||
given := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
||||
if err := requireRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -339,6 +344,33 @@ func cmdInstall(args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read the current settings without changing anything, so questions
|
||||
// and checks happen before the system is touched.
|
||||
_, statErr := os.Stat(configFile)
|
||||
existing := statErr == nil
|
||||
cur, err := loadConfigFile(configFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan := installPlan{domain: *domain, email: *email, endpoint: *endpoint, port: *port}
|
||||
if err := plan.check(); err != nil {
|
||||
return err
|
||||
}
|
||||
if !existing {
|
||||
n, err := randomSubnet(24)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
plan.ipv4 = n.String()
|
||||
}
|
||||
if !*yes && term.IsTerminal(int(os.Stdin.Fd())) {
|
||||
if plan, err = askInstall(os.Stdin, cur, existing, given, plan); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if n := plan.reissueCount(cur, existing); n > 0 {
|
||||
fmt.Printf("Note: %d existing device(s) need a new config: the endpoint or port changes.\n", n)
|
||||
}
|
||||
|
||||
// User and folder
|
||||
if _, err := user.Lookup(serviceName); err != nil {
|
||||
step("Creating system user %s", serviceName)
|
||||
@@ -375,11 +407,11 @@ func cmdInstall(args []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Config: created with defaults (server key, free subnet) if missing.
|
||||
_, statErr := os.Stat(configFile)
|
||||
if errors.Is(statErr, os.ErrNotExist) {
|
||||
// Config: created with defaults (server key, the chosen subnet) if missing.
|
||||
if !existing {
|
||||
step("Creating %s", configFile)
|
||||
if err := os.WriteFile(configFile, []byte("{}\n"), 0o600); err != nil {
|
||||
initial := fmt.Sprintf("{\"server\": {\"ipv4\": %q}}\n", plan.ipv4)
|
||||
if err := os.WriteFile(configFile, []byte(initial), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -390,23 +422,9 @@ func cmdInstall(args []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *domain != "" || *email != "" || *endpoint != "" {
|
||||
step("Saving domain and endpoint settings")
|
||||
if err := store.Update(func(c *Config) error {
|
||||
if *domain != "" {
|
||||
c.Web.TLS.Mode, c.Web.TLS.Domain = "acme", *domain
|
||||
if c.Server.Endpoint == "" {
|
||||
c.Server.Endpoint = *domain
|
||||
}
|
||||
}
|
||||
if *email != "" {
|
||||
c.Web.TLS.Email = *email
|
||||
}
|
||||
if *endpoint != "" {
|
||||
c.Server.Endpoint = *endpoint
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
if plan.changes() {
|
||||
step("Saving the settings")
|
||||
if err := store.Update(func(c *Config) error { plan.apply(c); return nil }); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user