Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a59a095691 | |||
| d32e851b74 | |||
| e3e6d04955 | |||
| 2c1b4a399a |
@@ -40,19 +40,6 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
|  |  |
|
||||
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
|
||||
|  |  |
|
||||
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
|
||||
|  |  |
|
||||
| **My account:** profile, password and your app tokens | **Sign-in** |
|
||||
|
||||
The screenshots show sample data from the built-in simulator.
|
||||
|
||||
## Security
|
||||
|
||||
- **Client private keys are never stored.** A config is shown once, as a
|
||||
@@ -65,7 +52,8 @@ The screenshots show sample data from the built-in simulator.
|
||||
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
|
||||
`/opt/ghostwire`.
|
||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
||||
After 5 failed attempts, sign-in is locked for 15 minutes. Sessions use an
|
||||
After 5 failed attempts from one IP address, sign-in from it is locked for 15
|
||||
minutes; wrong two-step codes count too. Sessions use an
|
||||
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
|
||||
passkeys under My account. A passkey signs in on its own, without username
|
||||
@@ -229,7 +217,7 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
| File | Content |
|
||||
|---|---|
|
||||
| `GHOSTWIRE` | the program |
|
||||
| `config.json` | all settings, server key, peers, token hashes (0600) |
|
||||
| `config.json` | all settings, server key, peers, pending setup links with their PINs, user password hashes, authenticator app secrets, passkeys, recovery code and token hashes (0600) |
|
||||
| `stats.json` | traffic and connection history per peer |
|
||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
||||
| `GHOSTWIRE.jsonl` | log, one JSON object per line. Changes carry `"audit":true` |
|
||||
@@ -239,7 +227,8 @@ After editing `config.json` by hand, run `sudo systemctl reload ghostwire`.
|
||||
|
||||
Base path `/api/v1`. The web interface signs in with a session cookie; every
|
||||
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
|
||||
the token under Settings → Pair iOS app. A token belongs to the user who made
|
||||
the token under Settings → Pair iOS app, or in the iOS app under Settings →
|
||||
Access → API tokens. A token belongs to the user who made
|
||||
it and is revoked when that user is deleted. A read-only token may only use
|
||||
GET. Full-access tokens can do everything the web interface does except backup
|
||||
and restore. Users, passwords and API tokens need a full-access token even for
|
||||
@@ -308,7 +297,8 @@ override a drop in another table, so if ufw or firewalld is active, allow UDP
|
||||
|
||||
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
||||
password, API tokens and backups. Pair it in the web interface under
|
||||
backup and restore, and adding an authenticator app or passkeys for two-step
|
||||
sign-in. Pair it in the web interface under
|
||||
Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and paste
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
@@ -3,6 +3,8 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -38,6 +40,15 @@ type Kernel interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
// Reconciler applies the config to the kernel whenever it is triggered and
|
||||
// remembers the outcome for the health report.
|
||||
type Reconciler struct {
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"golang.zx2c4.com/wireguard/wgctrl"
|
||||
@@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
|
||||
return false, "no address on " + uplink
|
||||
}
|
||||
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
var out []Check
|
||||
link, err := netlink.LinkByName(c.Server.Interface)
|
||||
@@ -341,6 +333,19 @@ func (k *linuxKernel) Checks(c *Config) []Check {
|
||||
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
||||
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
|
||||
}
|
||||
// With IPv6 forwarding on, accept_ra 1 means router announcements are
|
||||
// ignored: an IPv6 route learned from them expires (see sysctlConf).
|
||||
if readSysctl("/proc/sys/net/ipv6/conf/all/forwarding") == "1" {
|
||||
up := cmp.Or(k.Uplink(c, true), k.Uplink(c, false))
|
||||
if ra := readSysctl("/proc/sys/net/ipv6/conf/" + up + "/accept_ra"); up != "" && ra != "" {
|
||||
ok := ra != "1"
|
||||
detail := "net.ipv6.conf." + up + ".accept_ra=" + ra
|
||||
if !ok {
|
||||
detail += ": IPv6 from router announcements stops working; run " + appName + " update"
|
||||
}
|
||||
out = append(out, Check{"IPv6 router announcements", ok, detail})
|
||||
}
|
||||
}
|
||||
ok, detail := firewallPresent()
|
||||
out = append(out, Check{"nftables rules", ok, detail})
|
||||
up4 := k.Uplink(c, false)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -376,6 +377,38 @@ func TestUnitFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSysctlConf(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
conf, sys := filepath.Join(dir, "conf"), filepath.Join(dir, "net")
|
||||
for name, ra := range map[string]string{"eth0": "1", "wlan0": "2", "eth1": "0", "br0": "1", "veth1": "1", "lo": "1"} {
|
||||
_ = os.MkdirAll(filepath.Join(conf, name), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(conf, name, "accept_ra"), []byte(ra+"\n"), 0o644)
|
||||
}
|
||||
for _, name := range []string{"eth0", "wlan0", "eth1"} { // network cards
|
||||
_ = os.MkdirAll(filepath.Join(sys, name, "device"), 0o755)
|
||||
}
|
||||
_ = os.MkdirAll(filepath.Join(sys, "veth1"), 0o755)
|
||||
// br0 carries the default route; the lo line is the kernel's unreachable route.
|
||||
routes := filepath.Join(dir, "ipv6_route")
|
||||
_ = os.WriteFile(routes, []byte(
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 fe800000000000000000000000000001 00000400 00000001 00000000 00000003 br0\n"+
|
||||
"00000000000000000000000000000000 00 00000000000000000000000000000000 00 00000000000000000000000000000000 ffffffff 00000001 00000000 00200200 lo\n"), 0o644)
|
||||
|
||||
got := raInterfaces(conf, sys, routes)
|
||||
if want := []string{"br0", "eth0", "wlan0"}; !slices.Equal(got, want) {
|
||||
t.Fatalf("raInterfaces = %v, want %v", got, want)
|
||||
}
|
||||
c := sysctlConf(got)
|
||||
for _, want := range []string{"net.ipv6.conf.all.forwarding=1\n", "net.ipv6.conf.default.accept_ra=2\n", "net.ipv6.conf.eth0.accept_ra=2\n", "net.ipv6.conf.br0.accept_ra=2\n"} {
|
||||
if !strings.Contains(c, want) {
|
||||
t.Errorf("sysctl conf lacks %q:\n%s", want, c)
|
||||
}
|
||||
}
|
||||
if strings.Contains(c, "eth1") || strings.Contains(c, "veth1") {
|
||||
t.Errorf("sysctl conf names eth1 (accept_ra 0) or veth1 (virtual):\n%s", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteIfChanged(t *testing.T) {
|
||||
p := filepath.Join(t.TempDir(), "x.conf")
|
||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 296 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 81 KiB |
@@ -12,6 +12,7 @@ import (
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
|
||||
// rewrite the unit for every release.
|
||||
const unitVersion = "unit-1"
|
||||
|
||||
const sysctlConf = "net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\n"
|
||||
// sysctlConf turns on forwarding. With IPv6 forwarding on, Linux ignores
|
||||
// router announcements unless accept_ra is 2, and a server that gets its
|
||||
// IPv6 route from them (SLAAC, e.g. a Raspberry Pi at home) loses IPv6 when
|
||||
// the route expires. So every interface in ras keeps accepting them, as
|
||||
// pivpn does for its uplink.
|
||||
func sysctlConf(ras []string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("net.ipv4.ip_forward=1\nnet.ipv6.conf.all.forwarding=1\nnet.ipv6.conf.default.accept_ra=2\n")
|
||||
for _, name := range ras {
|
||||
fmt.Fprintf(&b, "net.ipv6.conf.%s.accept_ra=2\n", name)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// raInterfaces returns the network cards and the interface of the IPv6
|
||||
// default route, except those where router announcements are switched off
|
||||
// (accept_ra 0). The directories are /proc/sys/net/ipv6/conf and
|
||||
// /sys/class/net, routes is /proc/net/ipv6_route.
|
||||
func raInterfaces(confDir, netDir, routes string) []string {
|
||||
want := map[string]bool{}
|
||||
if b, err := os.ReadFile(routes); err == nil {
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
f := strings.Fields(line)
|
||||
if len(f) == 10 && f[0] == strings.Repeat("0", 32) && f[1] == "00" && f[9] != "lo" {
|
||||
want[f[9]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
entries, _ := os.ReadDir(netDir)
|
||||
for _, e := range entries {
|
||||
// Only real devices: bridges, veth and tunnels come and go.
|
||||
if _, err := os.Stat(filepath.Join(netDir, e.Name(), "device")); err == nil {
|
||||
want[e.Name()] = true
|
||||
}
|
||||
}
|
||||
var out []string
|
||||
for name := range want {
|
||||
v := readSysctl(filepath.Join(confDir, name, "accept_ra"))
|
||||
if v == "1" || v == "2" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// writeSystemFiles writes the unit, sysctl and module files. It reports
|
||||
// whether the unit changed (systemd must then reload).
|
||||
@@ -281,7 +326,8 @@ func writeSystemFiles() (unitChanged bool, err error) {
|
||||
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf, 0o644)
|
||||
ras := raInterfaces("/proc/sys/net/ipv6/conf", "/sys/class/net", "/proc/net/ipv6_route")
|
||||
sysChanged, err := writeIfChanged(sysctlPath, sysctlConf(ras), 0o644)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||