Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 511c6026ac | |||
| 85b401d05e | |||
| b54ff1b002 | |||
| 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
|
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||||
certificate files, or plain HTTP behind a reverse proxy.
|
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
|
## Security
|
||||||
|
|
||||||
- **Client private keys are never stored.** A config is shown once, as a
|
- **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
|
`CAP_NET_ADMIN` and `CAP_NET_BIND_SERVICE`, and can write only to
|
||||||
`/opt/ghostwire`.
|
`/opt/ghostwire`.
|
||||||
- **Sign-in:** one or more users, all admins. Passwords are stored as argon2id hashes.
|
- **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.
|
HttpOnly, SameSite=Strict cookie and last 12 hours by default.
|
||||||
- **Two-step sign-in:** each user can add an authenticator app (TOTP) and
|
- **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
|
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 |
|
| File | Content |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GHOSTWIRE` | the program |
|
| `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 |
|
| `stats.json` | traffic and connection history per peer |
|
||||||
| `geo-country.mmdb`, `geo-asn.mmdb` | DB-IP Lite databases for country and network lookups |
|
| `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` |
|
| `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
|
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
|
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
|
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
|
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
|
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,
|
The native iPhone app (SwiftUI, iOS 17+) lives in its own project,
|
||||||
GHOSTWIRE-Companion. It does everything the web interface does except
|
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
|
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
|
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||||
pairing.
|
pairing.
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
slog.Warn("login failed", "user", in.Username, "remote", ip, "reason", err.Error())
|
||||||
code := http.StatusUnauthorized
|
code := http.StatusUnauthorized
|
||||||
if errors.Is(err, errLocked) {
|
if errors.Is(err, errLocked) || errors.Is(err, errBusy) {
|
||||||
code = http.StatusTooManyRequests
|
code = http.StatusTooManyRequests
|
||||||
}
|
}
|
||||||
writeJSON(w, code, map[string]string{"error": err.Error()})
|
writeJSON(w, code, map[string]string{"error": err.Error()})
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ td { padding: 12px; border-bottom: 1px solid var(--line-2); vertical-align: midd
|
|||||||
tr:last-child td { border-bottom: 0; }
|
tr:last-child td { border-bottom: 0; }
|
||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td .note { font-size: 12px; color: var(--ink-3); }
|
td .note { font-size: 12px; color: var(--ink-3); }
|
||||||
|
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||||
|
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
||||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
|
|||||||
@@ -257,13 +257,26 @@
|
|||||||
else if (okMsg) toast(okMsg);
|
else if (okMsg) toast(okMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dialog shows a modal dialog. Extensions such as Bitwarden move elements
|
||||||
|
// around in <body>; a moved dialog stays open but drops out of the top
|
||||||
|
// layer to the bottom of the page, so it is shown as a modal again. That
|
||||||
|
// goes through close(), whose close event arrives after the dialog is open
|
||||||
|
// again and is kept from the listeners added by callers.
|
||||||
function dialog(build) {
|
function dialog(build) {
|
||||||
const d = h('dialog');
|
const d = h('dialog');
|
||||||
const close = () => d.close();
|
const close = () => d.close();
|
||||||
d.addEventListener('close', () => d.remove());
|
const moved = new MutationObserver(() => {
|
||||||
|
if (d.open && d.isConnected && !d.matches(':modal')) { d.close(); d.showModal(); }
|
||||||
|
});
|
||||||
|
d.addEventListener('close', (e) => {
|
||||||
|
if (d.open) { e.stopImmediatePropagation(); return; }
|
||||||
|
moved.disconnect();
|
||||||
|
d.remove();
|
||||||
|
});
|
||||||
d.append(build(close));
|
d.append(build(close));
|
||||||
document.body.append(d);
|
document.body.append(d);
|
||||||
d.showModal();
|
d.showModal();
|
||||||
|
moved.observe(document.body, { childList: true, subtree: true });
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1111,7 +1124,7 @@
|
|||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, h('strong', null, p.name)), p.note ? h('div', { class: 'note' }, p.note) : null),
|
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
|
||||||
h('td', { class: 'mono' }, p.ipv4),
|
h('td', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,12 +27,23 @@ const (
|
|||||||
argonKeyLen = 32
|
argonKeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Every argon2 run takes argonMemory (64 MiB). argonSlots caps how many run
|
||||||
|
// at once, so a burst of sign-ins cannot run the server out of memory: two
|
||||||
|
// slots are 128 MiB at most.
|
||||||
|
var argonSlots = make(chan struct{}, 2)
|
||||||
|
|
||||||
|
func argonKey(pw, salt []byte, t, m uint32, p uint8, n uint32) []byte {
|
||||||
|
argonSlots <- struct{}{}
|
||||||
|
defer func() { <-argonSlots }()
|
||||||
|
return argon2.IDKey(pw, salt, t, m, p, n)
|
||||||
|
}
|
||||||
|
|
||||||
func hashPassword(pw string) (string, error) {
|
func hashPassword(pw string) (string, error) {
|
||||||
salt := make([]byte, 16)
|
salt := make([]byte, 16)
|
||||||
if _, err := rand.Read(salt); err != nil {
|
if _, err := rand.Read(salt); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
key := argonKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||||
b64 := base64.RawStdEncoding
|
b64 := base64.RawStdEncoding
|
||||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||||
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
argon2.Version, argonMemory, argonTime, argonThreads, b64.EncodeToString(salt), b64.EncodeToString(key)), nil
|
||||||
@@ -54,7 +66,7 @@ func verifyPassword(encoded, pw string) bool {
|
|||||||
if err1 != nil || err2 != nil {
|
if err1 != nil || err2 != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
got := argonKey([]byte(pw), salt, t, m, p, uint32(len(want)))
|
||||||
return subtle.ConstantTimeCompare(got, want) == 1
|
return subtle.ConstantTimeCompare(got, want) == 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,14 +151,18 @@ type Auth struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
sessions map[string]*session
|
sessions map[string]*session
|
||||||
used map[string]tokenUse
|
used map[string]tokenUse
|
||||||
logins map[string]tokenUse // last sign-in per user ID
|
logins map[string]tokenUse // last sign-in per user ID
|
||||||
fails map[string]*failState
|
fails map[string]*failState // by lockKey
|
||||||
|
waiting int // sign-ins waiting for or running a password check
|
||||||
mfa mfaState
|
mfa mfaState
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxFailures = 5
|
maxFailures = 5
|
||||||
lockoutTime = 15 * time.Minute
|
lockoutTime = 15 * time.Minute
|
||||||
|
// maxWaiting sign-ins may wait for a password check; more are turned
|
||||||
|
// away until the queue is shorter.
|
||||||
|
maxWaiting = 16
|
||||||
)
|
)
|
||||||
|
|
||||||
func newAuth(s *Store) *Auth {
|
func newAuth(s *Store) *Auth {
|
||||||
@@ -155,23 +171,51 @@ func newAuth(s *Store) *Auth {
|
|||||||
|
|
||||||
func cookieName() string { return appName + "_session" }
|
func cookieName() string { return appName + "_session" }
|
||||||
|
|
||||||
var errLocked = errors.New("too many failed attempts, try again later")
|
var (
|
||||||
|
errLocked = errors.New("too many failed attempts, try again later")
|
||||||
|
errBusy = errors.New("too many sign-ins at once, try again in a moment")
|
||||||
|
)
|
||||||
|
|
||||||
|
// lockKey is what failed sign-ins are counted by: the IPv4 address, or the
|
||||||
|
// /64 network of an IPv6 address, since one device can pick any address in
|
||||||
|
// its /64.
|
||||||
|
func lockKey(ip string) string {
|
||||||
|
a, err := netip.ParseAddr(ip)
|
||||||
|
if err != nil || a.Unmap().Is4() {
|
||||||
|
return ip
|
||||||
|
}
|
||||||
|
p, _ := a.Prefix(64)
|
||||||
|
return p.String()
|
||||||
|
}
|
||||||
|
|
||||||
// Login checks the credentials and returns a new session id, or, for a user
|
// Login checks the credentials and returns a new session id, or, for a user
|
||||||
// with two-step sign-in, a ticket for the second step.
|
// with two-step sign-in, a ticket for the second step.
|
||||||
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error) {
|
||||||
a.mu.Lock()
|
|
||||||
f := a.fails[ip]
|
|
||||||
if f != nil && time.Now().Before(f.until) {
|
|
||||||
a.mu.Unlock()
|
|
||||||
return "", "", errLocked
|
|
||||||
}
|
|
||||||
a.mu.Unlock()
|
|
||||||
|
|
||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
if !cfg.passwordSet() {
|
if !cfg.passwordSet() {
|
||||||
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
return "", "", errors.New("no password is set; run: " + appName + " passwd")
|
||||||
}
|
}
|
||||||
|
// The attempt counts as failed before the password is checked, so
|
||||||
|
// parallel attempts cannot get past the lockout; a right password takes
|
||||||
|
// it back.
|
||||||
|
a.mu.Lock()
|
||||||
|
if a.lockedLocked(ip) {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errLocked
|
||||||
|
}
|
||||||
|
if a.waiting >= maxWaiting {
|
||||||
|
a.mu.Unlock()
|
||||||
|
return "", "", errBusy
|
||||||
|
}
|
||||||
|
a.waiting++
|
||||||
|
undo := a.failLocked(ip)
|
||||||
|
a.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
a.mu.Lock()
|
||||||
|
a.waiting--
|
||||||
|
a.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
// An unknown username costs as much time as a wrong password, so the
|
// An unknown username costs as much time as a wrong password, so the
|
||||||
// answer time does not tell which usernames exist.
|
// answer time does not tell which usernames exist.
|
||||||
u := cfg.userByName(strings.TrimSpace(user))
|
u := cfg.userByName(strings.TrimSpace(user))
|
||||||
@@ -185,21 +229,13 @@ func (a *Auth) Login(user, pw, ip string) (sessionID, ticket string, err error)
|
|||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
if !okUser || !okPw {
|
if !okUser || !okPw {
|
||||||
if f == nil {
|
|
||||||
f = &failState{}
|
|
||||||
a.fails[ip] = f
|
|
||||||
}
|
|
||||||
f.count++
|
|
||||||
if f.count >= maxFailures {
|
|
||||||
f.count = 0
|
|
||||||
f.until = time.Now().Add(lockoutTime)
|
|
||||||
}
|
|
||||||
return "", "", errors.New("wrong username or password")
|
return "", "", errors.New("wrong username or password")
|
||||||
}
|
}
|
||||||
|
undo()
|
||||||
if u.hasMFA() {
|
if u.hasMFA() {
|
||||||
return "", a.newTicketLocked(u, ip), nil
|
return "", a.newTicketLocked(u, ip), nil
|
||||||
}
|
}
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip}), "", nil
|
||||||
}
|
}
|
||||||
@@ -321,9 +357,9 @@ func (a *Auth) sweep() {
|
|||||||
delete(a.sessions, id)
|
delete(a.sessions, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for ip, f := range a.fails {
|
for key, f := range a.fails {
|
||||||
if now.After(f.until) && f.count == 0 {
|
if now.After(f.until) && f.count == 0 {
|
||||||
delete(a.fails, ip)
|
delete(a.fails, key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for id, t := range a.mfa.tickets {
|
for id, t := range a.mfa.tickets {
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -38,6 +40,15 @@ type Kernel interface {
|
|||||||
Close() error
|
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
|
// Reconciler applies the config to the kernel whenever it is triggered and
|
||||||
// remembers the outcome for the health report.
|
// remembers the outcome for the health report.
|
||||||
type Reconciler struct {
|
type Reconciler struct {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/vishvananda/netlink"
|
"github.com/vishvananda/netlink"
|
||||||
"golang.zx2c4.com/wireguard/wgctrl"
|
"golang.zx2c4.com/wireguard/wgctrl"
|
||||||
@@ -318,14 +318,6 @@ func publicAddr(uplink string, v6 bool) (bool, string) {
|
|||||||
return false, "no address on " + uplink
|
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 {
|
func (k *linuxKernel) Checks(c *Config) []Check {
|
||||||
var out []Check
|
var out []Check
|
||||||
link, err := netlink.LinkByName(c.Server.Interface)
|
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")
|
v := readSysctl("/proc/sys/net/ipv6/conf/all/forwarding")
|
||||||
out = append(out, Check{"IPv6 forwarding", v == "1", "net.ipv6.conf.all.forwarding=" + v})
|
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()
|
ok, detail := firewallPresent()
|
||||||
out = append(out, Check{"nftables rules", ok, detail})
|
out = append(out, Check{"nftables rules", ok, detail})
|
||||||
up4 := k.Uplink(c, false)
|
up4 := k.Uplink(c, false)
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -376,6 +378,92 @@ 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 TestLoginLockout(t *testing.T) {
|
||||||
|
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||||
|
a := newAuth(store)
|
||||||
|
const right, wrong = "a long test password", "a wrong password"
|
||||||
|
|
||||||
|
// Ten wrong attempts at once from one /64: five are checked, the others
|
||||||
|
// are locked out before any password check.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var mu sync.Mutex
|
||||||
|
got := map[string]int{}
|
||||||
|
for i := range 10 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _, err := a.Login("admin", wrong, fmt.Sprintf("2001:db8::%x", i+1))
|
||||||
|
mu.Lock()
|
||||||
|
got[err.Error()]++
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if got["wrong username or password"] != 5 || got[errLocked.Error()] != 5 {
|
||||||
|
t.Fatalf("parallel attempts: %v", got)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8::ffff"); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("same /64: %v, want locked", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, "2001:db8:0:1::1"); err != nil {
|
||||||
|
t.Fatalf("other /64: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A right password takes its own attempt back. With two-step sign-in
|
||||||
|
// the earlier failures stay, so wrong codes still lead to the lockout.
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].MFA = &UserMFA{TOTPSecret: newTOTPSecret()}; return nil })
|
||||||
|
ip := "192.0.2.7"
|
||||||
|
for range maxFailures - 1 {
|
||||||
|
_, _, _ = a.Login("admin", wrong, ip)
|
||||||
|
}
|
||||||
|
if _, tk, err := a.Login("admin", right, ip); err != nil || tk == "" {
|
||||||
|
t.Fatalf("5th attempt, right password: ticket %q, %v", tk, err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", wrong, ip); err == nil || errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("6th attempt: %v, want wrong password", err)
|
||||||
|
}
|
||||||
|
if _, _, err := a.Login("admin", right, ip); !errors.Is(err, errLocked) {
|
||||||
|
t.Fatalf("7th attempt: %v, want locked", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWriteIfChanged(t *testing.T) {
|
func TestWriteIfChanged(t *testing.T) {
|
||||||
p := filepath.Join(t.TempDir(), "x.conf")
|
p := filepath.Join(t.TempDir(), "x.conf")
|
||||||
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
if ch, err := writeIfChanged(p, "a\n", 0o644); !ch || err != nil {
|
||||||
|
|||||||
@@ -236,23 +236,34 @@ func newMFAState() mfaState {
|
|||||||
|
|
||||||
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
var errBadTicket = errors.New("the sign-in expired; enter your password again")
|
||||||
|
|
||||||
// failLocked counts a failed attempt from ip toward the lockout. a.mu must
|
// failLocked counts a failed attempt from ip toward the lockout and returns
|
||||||
// be held.
|
// a function that takes it back, for an attempt counted before it was
|
||||||
func (a *Auth) failLocked(ip string) {
|
// checked. a.mu must be held, also when calling undo.
|
||||||
f := a.fails[ip]
|
func (a *Auth) failLocked(ip string) (undo func()) {
|
||||||
|
key := lockKey(ip)
|
||||||
|
f := a.fails[key]
|
||||||
if f == nil {
|
if f == nil {
|
||||||
f = &failState{}
|
f = &failState{}
|
||||||
a.fails[ip] = f
|
a.fails[key] = f
|
||||||
}
|
}
|
||||||
f.count++
|
f.count++
|
||||||
if f.count >= maxFailures {
|
locked := f.count >= maxFailures
|
||||||
|
if locked {
|
||||||
f.count = 0
|
f.count = 0
|
||||||
f.until = time.Now().Add(lockoutTime)
|
f.until = time.Now().Add(lockoutTime)
|
||||||
}
|
}
|
||||||
|
return func() {
|
||||||
|
switch {
|
||||||
|
case locked:
|
||||||
|
f.count, f.until = maxFailures-1, time.Time{}
|
||||||
|
case f.count > 0:
|
||||||
|
f.count--
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Auth) lockedLocked(ip string) bool {
|
func (a *Auth) lockedLocked(ip string) bool {
|
||||||
f := a.fails[ip]
|
f := a.fails[lockKey(ip)]
|
||||||
return f != nil && time.Now().Before(f.until)
|
return f != nil && time.Now().Before(f.until)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,7 +313,7 @@ func (a *Auth) finishSignIn(u *User, ip string) string {
|
|||||||
cfg := a.store.Get()
|
cfg := a.store.Get()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
defer a.mu.Unlock()
|
defer a.mu.Unlock()
|
||||||
delete(a.fails, ip)
|
delete(a.fails, lockKey(ip))
|
||||||
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
a.logins[u.ID] = tokenUse{At: time.Now(), IP: ip}
|
||||||
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
return a.newSessionLocked(cfg, u, sessionInfo{Started: time.Now(), IP: ip})
|
||||||
}
|
}
|
||||||
|
|||||||
|
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"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -273,7 +274,51 @@ WantedBy=multi-user.target
|
|||||||
// rewrite the unit for every release.
|
// rewrite the unit for every release.
|
||||||
const unitVersion = "unit-1"
|
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
|
// writeSystemFiles writes the unit, sysctl and module files. It reports
|
||||||
// whether the unit changed (systemd must then reload).
|
// 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 {
|
if unitChanged, err = writeIfChanged(unitPath, unitFile(), 0o644); err != nil {
|
||||||
return false, err
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|||||||