Compare commits

...

56 Commits

Author SHA1 Message Date
Juan Font
f491db232b Merge pull request #55 from cure/letsencrypt-more-flexible-config
Turn the combination of TLS-ALPN-01 and listen_addr on a port other than
2021-07-17 11:01:08 +02:00
Ward Vandewege
9a24340bd4 Turn the combination of TLS-ALPN-01 and listen_addr on a port other than
443 into a warning, not an error, refs #53.
2021-07-16 22:02:05 -04:00
Juan Font
6f20a1fc68 Merge pull request #51 from tianon/typo
Fix minor typo
2021-07-16 18:04:46 +02:00
Tianon Gravi
243b961cbe Fix minor typo
> Error: unknown command "namespace" for "headscale"
2021-07-16 15:07:13 +00:00
Juan Font Alonso
5748744134 Use ubuntu 18.04 as build env 2021-07-12 17:04:28 +02:00
Juan Font
31556e1ac0 Merge pull request #48 from juanfont/better-profile-info
Improving namespace/user support
2021-07-11 16:44:16 +02:00
Juan Font Alonso
0159649d0a Send the namespace name as user to the clients 2021-07-11 16:39:19 +02:00
Juan Font Alonso
cf9d920e4a Minor typo 2021-07-11 15:10:37 +02:00
Juan Font Alonso
7d46dfe012 Only load ACLs if a path is present 2021-07-11 15:10:11 +02:00
Juan Font Alonso
eabb1ce881 Fix minor typo on the register webpage 2021-07-11 15:05:32 +02:00
Juan Font Alonso
db20985b06 Show N/A in reusable when key is ephemeral 2021-07-11 13:14:25 +02:00
Juan Font Alonso
29b80e3ca1 Fix debug mode enabled by default in db 2021-07-11 13:13:36 +02:00
Juan Font Alonso
a16a763283 Update README.md with info on ACLs 2021-07-11 13:04:33 +02:00
Juan Font
ad7f03c9dd Merge pull request #47 from juanfont/handle-ephemeral-reconnect
Added HTTP responses on map errors
2021-07-11 11:41:23 +02:00
Juan Font Alonso
bff3d2d613 Added HTTP responses on errors 2021-07-11 11:37:17 +02:00
Juan Font
f66c283756 Merge pull request #46 from Teteros/update-derp-servers
Update DERP server definitions
2021-07-10 23:29:54 +02:00
Teteros
ad454d95b9 Update DERP server definitions 2021-07-10 09:00:35 +01:00
Juan Font
e67a98b758 Merge pull request #44 from juanfont/acls
Add support for Policy ACLs
2021-07-07 16:19:45 +02:00
Juan Font Alonso
ecf258f995 Use gorm connection pool 2021-07-04 21:56:13 +02:00
Juan Font Alonso
d4b27fd54b Merge branch 'main' into acls 2021-07-04 21:54:55 +02:00
Juan Font
90e9ad9a0e Merge pull request #45 from juanfont/reuse-gorm-connection
Use gorm connection pool
2021-07-04 21:51:43 +02:00
Juan Font Alonso
ff9d99b9ea Use gorm connection pool 2021-07-04 21:40:46 +02:00
Juan Font
7590dee1f2 Removed unnecessary prints 2021-07-04 13:47:59 +02:00
Juan Font
315bc6b677 Added acl path key in example config 2021-07-04 13:41:38 +02:00
Juan Font
a1b8f77b1b Fixed tests 2021-07-04 13:40:45 +02:00
Juan Font
19443669bf Fixed linting issues 2021-07-04 13:33:00 +02:00
Juan Font
d446e8a2fb More stuff in go.sum 2021-07-04 13:24:27 +02:00
Juan Font
202d6b506f Load ACL policy on headscale startup 2021-07-04 13:24:05 +02:00
Juan Font
401e6aec32 And more tests 2021-07-04 13:23:31 +02:00
Juan Font
bd86975d10 Added missing go.mod 2021-07-04 13:10:15 +02:00
Juan Font
d0e970f21d Added more unit tests 2021-07-04 13:01:41 +02:00
Juan Font
07e95393b3 Rule generation kinda working, missing tests 2021-07-04 12:35:18 +02:00
Juan Font
136aab9dc8 Work in progress in rule generation 2021-07-03 17:31:32 +02:00
Juan Font
bbd6a67c46 Added more acl test hujsons 2021-07-03 17:31:08 +02:00
Juan Font
31ea67bcaf Minor addenda to README.md 2021-07-03 16:10:22 +02:00
Juan Font
5644dadaf9 Added dependency on hujson 2021-07-03 12:02:46 +02:00
Juan Font
874aa4277d Minor changes in the README.md 2021-07-03 12:01:19 +02:00
Juan Font
b161a92e58 Initial work on ACLs 2021-07-03 11:55:32 +02:00
Juan Font
95fee5aa6f Merge pull request #43 from juanfont/use-plurals-for-cmds
Change all commands to plural words
2021-06-29 23:38:03 +02:00
Juan Font Alonso
f5b8a3f710 Make all commands a plural word 2021-06-28 20:04:05 +02:00
Juan Font
ba87ade9c5 Merge pull request #42 from juanfont/tailscale-1.8.x
Update Headscale to Tailscale 1.10
2021-06-26 18:36:46 +02:00
Juan Font Alonso
aa27709e60 Update code to Tailscale 1.10 2021-06-25 18:57:08 +02:00
Juan Font Alonso
736182f651 Update dependencies, including Tailscale 1.10.x 2021-06-25 18:56:49 +02:00
Juan Font
c4aa9d8aed Merge pull request #41 from juanfont/gorm2
Migrate to GORM 2.0
2021-06-25 10:00:13 +02:00
Juan Font Alonso
d8e0b16512 Do not apply the FK migrations on startup 2021-06-24 23:05:26 +02:00
Juan Font Alonso
d67be9ef58 go.mod updates 2021-06-24 15:49:27 +02:00
Juan Font Alonso
69ba750b38 Update Headscale to depend on gorm v2 2021-06-24 15:44:19 +02:00
Juan Font
df0d214faf Merge pull request #38 from cmars/k8s
Add k8s deployment, standalone app Dockerfile.
2021-06-21 21:18:41 +02:00
Juan Font
73186eeb2f Merge pull request #40 from cmars/upstream-fix-nodes-nil-lastseen
Fix nil dereference in nodes list command.
2021-06-20 11:12:10 +02:00
Casey Marshall
fdcd3bb574 Fix nil dereference in nodes list command.
Fixes a nil pointer dereference observed when listing nodes that have
not yet connected.

```
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=0xb931a4]

goroutine 1 [running]:
github.com/juanfont/headscale/cmd/headscale/cli.glob..func8(0x13c93e0, 0xc0004c4220, 0x0, 0x2)
	/go/src/headscale/cmd/headscale/cli/nodes.go:74 +0x364
github.com/spf13/cobra.(*Command).execute(0x13c93e0, 0xc0004c41e0, 0x2, 0x2, 0x13c93e0, 0xc0004c41e0)
	/go/pkg/mod/github.com/spf13/cobra@v1.1.3/command.go:856 +0x2c2
github.com/spf13/cobra.(*Command).ExecuteC(0x13ca2e0, 0xc000497110, 0xe76416, 0x6)
	/go/pkg/mod/github.com/spf13/cobra@v1.1.3/command.go:960 +0x375
github.com/spf13/cobra.(*Command).Execute(...)
	/go/pkg/mod/github.com/spf13/cobra@v1.1.3/command.go:897
main.main()
	/go/src/headscale/cmd/headscale/headscale.go:89 +0x805
command terminated with exit code 2
```
2021-06-19 18:20:27 -05:00
Casey Marshall
c64d756ea7 Add k8s deployment, standalone app Dockerfile.
Tested with Rancher k3s. See k8s/README.md for site configuration and
deployment instructions.

Add cert-manager, tls, remote headscale script.
2021-06-18 12:45:21 -05:00
Juan Font
a63fb6b007 Update README.me on how to clear tailscaled data (#37) 2021-06-17 14:22:38 +02:00
Juan Font Alonso
27e97cbd09 Use crosscompiling and enable CGO in goreleaser 2021-06-16 19:24:30 +02:00
Juan Font Alonso
39550e262c Improved readme 2021-06-16 19:05:30 +02:00
Juan Font
cfef55447f Fix CI badge 2021-06-15 00:06:03 +02:00
Juan Font
9c276f33bd Change the README to include the new releases 2021-06-14 22:21:54 +02:00
62 changed files with 2473 additions and 535 deletions

View File

@@ -7,7 +7,7 @@ on:
jobs:
goreleaser:
runs-on: ubuntu-latest
runs-on: ubuntu-18.04 # due to CGO we need to user an older version
steps:
-
name: Checkout

View File

@@ -4,29 +4,65 @@ before:
hooks:
- go mod tidy
builds:
- env:
- CGO_ENABLED=0
- id: darwin-amd64
main: ./cmd/headscale/headscale.go
mod_timestamp: '{{ .CommitTimestamp }}'
goos:
- linux
- windows
- darwin
goarch:
- amd64
env:
- PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64
- PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig
- CC=o64-clang
- CXX=o64-clang++
flags:
- -mod=readonly
ldflags:
- -s -w -X main.version={{.Version}}
- id: linux-armhf
main: ./cmd/headscale/headscale.go
mod_timestamp: '{{ .CommitTimestamp }}'
goos:
- linux
goarch:
- arm
- arm64
goarm:
- 7
env:
- CC=arm-linux-gnueabihf-gcc
- CXX=arm-linux-gnueabihf-g++
- CGO_FLAGS=--sysroot=/sysroot/linux/armhf
- CGO_LDFLAGS=--sysroot=/sysroot/linux/armhf
- PKG_CONFIG_SYSROOT_DIR=/sysroot/linux/armhf
- PKG_CONFIG_PATH=/sysroot/linux/armhf/opt/vc/lib/pkgconfig:/sysroot/linux/armhf/usr/lib/arm-linux-gnueabihf/pkgconfig:/sysroot/linux/armhf/usr/lib/pkgconfig:/sysroot/linux/armhf/usr/local/lib/pkgconfig
flags:
- -mod=readonly
ldflags:
- -s -w -X main.version={{.Version}}
- id: linux-amd64
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
goarm:
- 6
- 7
main: ./cmd/headscale/headscale.go
mod_timestamp: '{{ .CommitTimestamp }}'
archives:
- replacements:
darwin: Darwin
linux: Linux
windows: Windows
amd64: x86_64
- id: golang-cross
builds:
- darwin-amd64
- linux-armhf
- linux-amd64
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format: binary
checksum:
name_template: 'checksums.txt'

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:latest AS build
ENV GOPATH /go
COPY . /go/src/headscale
WORKDIR /go/src/headscale
RUN go install -a -ldflags="-extldflags=-static" -tags netgo,sqlite_omit_load_extension ./cmd/headscale
RUN test -e /go/bin/headscale
FROM scratch
COPY --from=build /go/bin/headscale /go/bin/headscale
ENV TZ UTC
EXPOSE 8080/tcp
ENTRYPOINT ["/go/bin/headscale"]

View File

@@ -1,8 +1,8 @@
# Headscale
[![Join the chat at https://gitter.im/headscale-dev/community](https://badges.gitter.im/headscale-dev/community.svg)](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ![ci](https://github.com/juanfont/headscale/actions/workflows/ci.yml/badge.svg)
[![Join the chat at https://gitter.im/headscale-dev/community](https://badges.gitter.im/headscale-dev/community.svg)](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ![ci](https://github.com/juanfont/headscale/actions/workflows/test.yml/badge.svg)
An open source implementation of the Tailscale coordination server.
An open source, self-hosted implementation of the Tailscale coordination server.
## Overview
@@ -10,28 +10,27 @@ Tailscale is [a modern VPN](https://tailscale.com/) built on top of [Wireguard](
Everything in Tailscale is Open Source, except the GUI clients for proprietary OS (Windows and macOS/iOS), and the 'coordination/control server'.
The control server works as an exchange point of cryptographic public keys for the nodes in the Tailscale network. It also assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.
The control server works as an exchange point of Wireguard public keys for the nodes in the Tailscale network. It also assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.
Headscale implements this coordination server.
## Status
- [x] Basic functionality (nodes can communicate with each other)
- [x] Base functionality (nodes can communicate with each other)
- [x] Node registration through the web flow
- [x] Network changes are relied to the nodes
- [x] ~~Multiuser~~ Namespace support
- [x] Basic routing (advertise & accept)
- [ ] Share nodes between ~~users~~ namespaces
- [x] Node registration via pre-auth keys (including reusable keys and ephemeral node support)
- [x] Namespace support (~equivalent to multi-user in Tailscale.com)
- [x] Routing (advertise & accept, including exit nodes)
- [x] Node registration via pre-auth keys (including reusable keys, and ephemeral node support)
- [X] JSON-formatted output
- [ ] ACLs
- [X] ACLs
- [ ] Share nodes between ~~users~~ namespaces
- [ ] DNS
... and probably lots of stuff missing
## Roadmap 🤷
Basic multiuser support (multinamespace, actually) is now implemented. No node sharing or ACLs between namespaces yet though...
We are now focusing on adding integration tests with the official clients.
Suggestions/PRs welcomed!
@@ -39,10 +38,8 @@ Suggestions/PRs welcomed!
## Running it
1. Compile the headscale binary
```shell
make
```
1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH
2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running
@@ -63,33 +60,40 @@ Suggestions/PRs welcomed!
cp config.json.sqlite.example config.json
```
4. Create a namespace (equivalent to a user in tailscale.com)
4. Create a namespace (a namespace is a 'tailnet', a group of Tailscale nodes that can talk to each other)
```shell
./headscale namespace create myfirstnamespace
headscale namespaces create myfirstnamespace
```
5. Run the server
```shell
./headscale serve
headscale serve
```
6. Add your first machine
6. If you used tailscale.com before in your nodes, make sure you clear the tailscaled data folder
```shell
systemctl stop tailscaled
rm -fr /var/lib/tailscale
systemctl start tailscaled
```
7. Add your first machine
```shell
tailscale up -login-server YOUR_HEADSCALE_URL
```
7. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
8. In the server, register your machine to a namespace with the CLI
9. In the server, register your machine to a namespace with the CLI
```shell
./headscale -n myfirstnamespace node register YOURMACHINEKEY
headscale -n myfirstnamespace node register YOURMACHINEKEY
```
Alternatively, you can use Auth Keys to register your machines:
1. Create an authkey
```shell
./headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
```
2. Use the authkey from your machine to register it
@@ -141,6 +145,7 @@ Headscale's configuration file is named `config.json` or `config.yaml`. Headscal
The fields starting with `db_` are used for the PostgreSQL connection information.
### Running the service via TLS (optional)
```
@@ -158,11 +163,21 @@ Headscale can be configured to expose its web service via TLS. To configure the
To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/), set `tls_letsencrypt_hostname` to the desired certificate hostname. This name must resolve to the IP address(es) Headscale is reachable on (i.e., it must correspond to the `server_url` configuration parameter). The certificate and Let's Encrypt account credentials will be stored in the directory configured in `tls_letsencrypt_cache_dir`. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. The certificate will automatically be renewed as needed. The default challenge type HTTP-01 requires that Headscale listens on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, Headscale must be reachable via port 443, but port 80 is not required.
### Policy ACLs
Headscale implements the same policy ACLs as Tailscale.com, adapted to the self-hosted environment.
For instance, instead of referring to users when defining groups you must
use namespaces (which are the equivalent to user/logins in Tailscale.com).
Please check https://tailscale.com/kb/1018/acls/, and `./tests/acls/` in this repo for working examples.
## Disclaimer
1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of writing this was to learn how Tailscale works.
3. ~~I don't use Headscale myself.~~

263
acls.go Normal file
View File

@@ -0,0 +1,263 @@
package headscale
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"strconv"
"strings"
"github.com/tailscale/hujson"
"inet.af/netaddr"
"tailscale.com/tailcfg"
)
const errorEmptyPolicy = Error("empty policy")
const errorInvalidAction = Error("invalid action")
const errorInvalidUserSection = Error("invalid user section")
const errorInvalidGroup = Error("invalid group")
const errorInvalidTag = Error("invalid tag")
const errorInvalidNamespace = Error("invalid namespace")
const errorInvalidPortFormat = Error("invalid port format")
// LoadACLPolicy loads the ACL policy from the specify path, and generates the ACL rules
func (h *Headscale) LoadACLPolicy(path string) error {
policyFile, err := os.Open(path)
if err != nil {
return err
}
defer policyFile.Close()
var policy ACLPolicy
b, err := io.ReadAll(policyFile)
if err != nil {
return err
}
err = hujson.Unmarshal(b, &policy)
if err != nil {
return err
}
if policy.IsZero() {
return errorEmptyPolicy
}
h.aclPolicy = &policy
rules, err := h.generateACLRules()
if err != nil {
return err
}
h.aclRules = rules
return nil
}
func (h *Headscale) generateACLRules() (*[]tailcfg.FilterRule, error) {
rules := []tailcfg.FilterRule{}
for i, a := range h.aclPolicy.ACLs {
if a.Action != "accept" {
return nil, errorInvalidAction
}
r := tailcfg.FilterRule{}
srcIPs := []string{}
for j, u := range a.Users {
srcs, err := h.generateACLPolicySrcIP(u)
if err != nil {
log.Printf("Error parsing ACL %d, User %d", i, j)
return nil, err
}
srcIPs = append(srcIPs, *srcs...)
}
r.SrcIPs = srcIPs
destPorts := []tailcfg.NetPortRange{}
for j, d := range a.Ports {
dests, err := h.generateACLPolicyDestPorts(d)
if err != nil {
log.Printf("Error parsing ACL %d, Port %d", i, j)
return nil, err
}
destPorts = append(destPorts, *dests...)
}
rules = append(rules, tailcfg.FilterRule{
SrcIPs: srcIPs,
DstPorts: destPorts,
})
}
return &rules, nil
}
func (h *Headscale) generateACLPolicySrcIP(u string) (*[]string, error) {
return h.expandAlias(u)
}
func (h *Headscale) generateACLPolicyDestPorts(d string) (*[]tailcfg.NetPortRange, error) {
tokens := strings.Split(d, ":")
if len(tokens) < 2 || len(tokens) > 3 {
return nil, errorInvalidPortFormat
}
var alias string
// We can have here stuff like:
// git-server:*
// 192.168.1.0/24:22
// tag:montreal-webserver:80,443
// tag:api-server:443
// example-host-1:*
if len(tokens) == 2 {
alias = tokens[0]
} else {
alias = fmt.Sprintf("%s:%s", tokens[0], tokens[1])
}
expanded, err := h.expandAlias(alias)
if err != nil {
return nil, err
}
ports, err := h.expandPorts(tokens[len(tokens)-1])
if err != nil {
return nil, err
}
dests := []tailcfg.NetPortRange{}
for _, d := range *expanded {
for _, p := range *ports {
pr := tailcfg.NetPortRange{
IP: d,
Ports: p,
}
dests = append(dests, pr)
}
}
return &dests, nil
}
func (h *Headscale) expandAlias(s string) (*[]string, error) {
if s == "*" {
return &[]string{"*"}, nil
}
if strings.HasPrefix(s, "group:") {
if _, ok := h.aclPolicy.Groups[s]; !ok {
return nil, errorInvalidGroup
}
ips := []string{}
for _, n := range h.aclPolicy.Groups[s] {
nodes, err := h.ListMachinesInNamespace(n)
if err != nil {
return nil, errorInvalidNamespace
}
for _, node := range *nodes {
ips = append(ips, node.IPAddress)
}
}
return &ips, nil
}
if strings.HasPrefix(s, "tag:") {
if _, ok := h.aclPolicy.TagOwners[s]; !ok {
return nil, errorInvalidTag
}
// This will have HORRIBLE performance.
// We need to change the data model to better store tags
machines := []Machine{}
if err := h.db.Where("registered").Find(&machines).Error; err != nil {
return nil, err
}
ips := []string{}
for _, m := range machines {
hostinfo := tailcfg.Hostinfo{}
if len(m.HostInfo) != 0 {
hi, err := m.HostInfo.MarshalJSON()
if err != nil {
return nil, err
}
err = json.Unmarshal(hi, &hostinfo)
if err != nil {
return nil, err
}
// FIXME: Check TagOwners allows this
for _, t := range hostinfo.RequestTags {
if s[4:] == t {
ips = append(ips, m.IPAddress)
break
}
}
}
}
return &ips, nil
}
n, err := h.GetNamespace(s)
if err == nil {
nodes, err := h.ListMachinesInNamespace(n.Name)
if err != nil {
return nil, err
}
ips := []string{}
for _, n := range *nodes {
ips = append(ips, n.IPAddress)
}
return &ips, nil
}
if h, ok := h.aclPolicy.Hosts[s]; ok {
return &[]string{h.String()}, nil
}
ip, err := netaddr.ParseIP(s)
if err == nil {
return &[]string{ip.String()}, nil
}
cidr, err := netaddr.ParseIPPrefix(s)
if err == nil {
return &[]string{cidr.String()}, nil
}
return nil, errorInvalidUserSection
}
func (h *Headscale) expandPorts(s string) (*[]tailcfg.PortRange, error) {
if s == "*" {
return &[]tailcfg.PortRange{{First: 0, Last: 65535}}, nil
}
ports := []tailcfg.PortRange{}
for _, p := range strings.Split(s, ",") {
rang := strings.Split(p, "-")
if len(rang) == 1 {
pi, err := strconv.ParseUint(rang[0], 10, 16)
if err != nil {
return nil, err
}
ports = append(ports, tailcfg.PortRange{
First: uint16(pi),
Last: uint16(pi),
})
} else if len(rang) == 2 {
start, err := strconv.ParseUint(rang[0], 10, 16)
if err != nil {
return nil, err
}
last, err := strconv.ParseUint(rang[1], 10, 16)
if err != nil {
return nil, err
}
ports = append(ports, tailcfg.PortRange{
First: uint16(start),
Last: uint16(last),
})
} else {
return nil, errorInvalidPortFormat
}
}
return &ports, nil
}

160
acls_test.go Normal file
View File

@@ -0,0 +1,160 @@
package headscale
import (
"gopkg.in/check.v1"
)
func (s *Suite) TestWrongPath(c *check.C) {
err := h.LoadACLPolicy("asdfg")
c.Assert(err, check.NotNil)
}
func (s *Suite) TestBrokenHuJson(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/broken.hujson")
c.Assert(err, check.NotNil)
}
func (s *Suite) TestInvalidPolicyHuson(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/invalid.hujson")
c.Assert(err, check.NotNil)
c.Assert(err, check.Equals, errorEmptyPolicy)
}
func (s *Suite) TestParseHosts(c *check.C) {
var hs Hosts
err := hs.UnmarshalJSON([]byte(`{"example-host-1": "100.100.100.100","example-host-2": "100.100.101.100/24"}`))
c.Assert(hs, check.NotNil)
c.Assert(err, check.IsNil)
}
func (s *Suite) TestParseInvalidCIDR(c *check.C) {
var hs Hosts
err := hs.UnmarshalJSON([]byte(`{"example-host-1": "100.100.100.100/42"}`))
c.Assert(hs, check.IsNil)
c.Assert(err, check.NotNil)
}
func (s *Suite) TestRuleInvalidGeneration(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/acl_policy_invalid.hujson")
c.Assert(err, check.NotNil)
}
func (s *Suite) TestBasicRule(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/acl_policy_basic_1.hujson")
c.Assert(err, check.IsNil)
rules, err := h.generateACLRules()
c.Assert(err, check.IsNil)
c.Assert(rules, check.NotNil)
}
func (s *Suite) TestPortRange(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/acl_policy_basic_range.hujson")
c.Assert(err, check.IsNil)
rules, err := h.generateACLRules()
c.Assert(err, check.IsNil)
c.Assert(rules, check.NotNil)
c.Assert(*rules, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts[0].Ports.First, check.Equals, uint16(5400))
c.Assert((*rules)[0].DstPorts[0].Ports.Last, check.Equals, uint16(5500))
}
func (s *Suite) TestPortWildcard(c *check.C) {
err := h.LoadACLPolicy("./tests/acls/acl_policy_basic_wildcards.hujson")
c.Assert(err, check.IsNil)
rules, err := h.generateACLRules()
c.Assert(err, check.IsNil)
c.Assert(rules, check.NotNil)
c.Assert(*rules, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts[0].Ports.First, check.Equals, uint16(0))
c.Assert((*rules)[0].DstPorts[0].Ports.Last, check.Equals, uint16(65535))
c.Assert((*rules)[0].SrcIPs, check.HasLen, 1)
c.Assert((*rules)[0].SrcIPs[0], check.Equals, "*")
}
func (s *Suite) TestPortNamespace(c *check.C) {
n, err := h.CreateNamespace("testnamespace")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("testnamespace", "testmachine")
c.Assert(err, check.NotNil)
ip, _ := h.getAvailableIP()
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
IPAddress: ip.String(),
AuthKeyID: uint(pak.ID),
}
h.db.Save(&m)
err = h.LoadACLPolicy("./tests/acls/acl_policy_basic_namespace_as_user.hujson")
c.Assert(err, check.IsNil)
rules, err := h.generateACLRules()
c.Assert(err, check.IsNil)
c.Assert(rules, check.NotNil)
c.Assert(*rules, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts[0].Ports.First, check.Equals, uint16(0))
c.Assert((*rules)[0].DstPorts[0].Ports.Last, check.Equals, uint16(65535))
c.Assert((*rules)[0].SrcIPs, check.HasLen, 1)
c.Assert((*rules)[0].SrcIPs[0], check.Not(check.Equals), "not an ip")
c.Assert((*rules)[0].SrcIPs[0], check.Equals, ip.String())
}
func (s *Suite) TestPortGroup(c *check.C) {
n, err := h.CreateNamespace("testnamespace")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("testnamespace", "testmachine")
c.Assert(err, check.NotNil)
ip, _ := h.getAvailableIP()
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
IPAddress: ip.String(),
AuthKeyID: uint(pak.ID),
}
h.db.Save(&m)
err = h.LoadACLPolicy("./tests/acls/acl_policy_basic_groups.hujson")
c.Assert(err, check.IsNil)
rules, err := h.generateACLRules()
c.Assert(err, check.IsNil)
c.Assert(rules, check.NotNil)
c.Assert(*rules, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts, check.HasLen, 1)
c.Assert((*rules)[0].DstPorts[0].Ports.First, check.Equals, uint16(0))
c.Assert((*rules)[0].DstPorts[0].Ports.Last, check.Equals, uint16(65535))
c.Assert((*rules)[0].SrcIPs, check.HasLen, 1)
c.Assert((*rules)[0].SrcIPs[0], check.Not(check.Equals), "not an ip")
c.Assert((*rules)[0].SrcIPs[0], check.Equals, ip.String())
}

70
acls_types.go Normal file
View File

@@ -0,0 +1,70 @@
package headscale
import (
"strings"
"github.com/tailscale/hujson"
"inet.af/netaddr"
)
// ACLPolicy represents a Tailscale ACL Policy
type ACLPolicy struct {
Groups Groups `json:"Groups"`
Hosts Hosts `json:"Hosts"`
TagOwners TagOwners `json:"TagOwners"`
ACLs []ACL `json:"ACLs"`
Tests []ACLTest `json:"Tests"`
}
// ACL is a basic rule for the ACL Policy
type ACL struct {
Action string `json:"Action"`
Users []string `json:"Users"`
Ports []string `json:"Ports"`
}
// Groups references a series of alias in the ACL rules
type Groups map[string][]string
// Hosts are alias for IP addresses or subnets
type Hosts map[string]netaddr.IPPrefix
// TagOwners specify what users (namespaces?) are allow to use certain tags
type TagOwners map[string][]string
// ACLTest is not implemented, but should be use to check if a certain rule is allowed
type ACLTest struct {
User string `json:"User"`
Allow []string `json:"Allow"`
Deny []string `json:"Deny,omitempty"`
}
// UnmarshalJSON allows to parse the Hosts directly into netaddr objects
func (h *Hosts) UnmarshalJSON(data []byte) error {
hosts := Hosts{}
hs := make(map[string]string)
err := hujson.Unmarshal(data, &hs)
if err != nil {
return err
}
for k, v := range hs {
if !strings.Contains(v, "/") {
v = v + "/32"
}
prefix, err := netaddr.ParseIPPrefix(v)
if err != nil {
return err
}
hosts[k] = prefix
}
*h = hosts
return nil
}
// IsZero is perhaps a bit naive here
func (p ACLPolicy) IsZero() bool {
if len(p.Groups) == 0 && len(p.Hosts) == 0 && len(p.ACLs) == 0 {
return true
}
return false
}

80
api.go
View File

@@ -3,6 +3,7 @@ package headscale
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -10,12 +11,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/klauspost/compress/zstd"
"gorm.io/datatypes"
"gorm.io/gorm"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// KeyHandler provides the Headscale pub key
@@ -45,7 +46,7 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
<p>
<code>
<b>headscale -n NAMESPACE node register %s</b>
<b>headscale -n NAMESPACE nodes register %s</b>
</code>
</p>
@@ -60,7 +61,7 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
func (h *Headscale) RegistrationHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr)
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
log.Printf("Cannot parse machine key: %s", err)
c.String(http.StatusInternalServerError, "Sad!")
@@ -74,38 +75,30 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
return
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
c.String(http.StatusInternalServerError, ":(")
return
}
defer db.Close()
var m Machine
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Println("New Machine!")
m = Machine{
Expiry: &req.Expiry,
MachineKey: mKey.HexString(),
Name: req.Hostinfo.Hostname,
NodeKey: wgcfg.Key(req.NodeKey).HexString(),
NodeKey: wgkey.Key(req.NodeKey).HexString(),
}
if err := db.Create(&m).Error; err != nil {
if err := h.db.Create(&m).Error; err != nil {
log.Printf("Could not create row: %s", err)
return
}
}
if !m.Registered && req.Auth.AuthKey != "" {
h.handleAuthKey(c, db, mKey, req, m)
h.handleAuthKey(c, h.db, mKey, req, m)
return
}
resp := tailcfg.RegisterResponse{}
// We have the updated key!
if m.NodeKey == wgcfg.Key(req.NodeKey).HexString() {
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
if m.Registered {
log.Printf("[%s] Client is registered and we have the current NodeKey. All clear to /map", m.Name)
resp.AuthURL = ""
@@ -135,10 +128,10 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
}
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
if m.NodeKey == wgcfg.Key(req.OldNodeKey).HexString() {
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
log.Printf("[%s] We have the OldNodeKey in the database. This is a key refresh", m.Name)
m.NodeKey = wgcfg.Key(req.NodeKey).HexString()
db.Save(&m)
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
h.db.Save(&m)
resp.AuthURL = ""
resp.User = *m.Namespace.toUser()
@@ -192,34 +185,31 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
func (h *Headscale) PollNetMapHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr)
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
log.Printf("Cannot parse client key: %s", err)
c.String(http.StatusBadRequest, "")
return
}
req := tailcfg.MapRequest{}
err = decode(body, &req, &mKey, h.privateKey)
if err != nil {
log.Printf("Cannot decode message: %s", err)
c.String(http.StatusBadRequest, "")
return
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return
}
defer db.Close()
var m Machine
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Printf("Ignoring request, cannot find machine with key %s", mKey.HexString())
c.String(http.StatusUnauthorized, "")
return
}
hostinfo, _ := json.Marshal(req.Hostinfo)
m.Name = req.Hostinfo.Hostname
m.HostInfo = datatypes.JSON(hostinfo)
m.DiscoKey = wgcfg.Key(req.DiscoKey).HexString()
m.DiscoKey = wgkey.Key(req.DiscoKey).HexString()
now := time.Now().UTC()
// From Tailscale client:
@@ -235,7 +225,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
m.Endpoints = datatypes.JSON(endpoints)
m.LastSeen = &now
}
db.Save(&m)
h.db.Save(&m)
pollData := make(chan []byte, 1)
update := make(chan []byte, 1)
@@ -300,11 +290,11 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
log.Printf("[%s] Sending data (%d bytes)", m.Name, len(data))
_, err := w.Write(data)
if err != nil {
log.Printf("[%s] 🤮 Cannot write data: %s", m.Name, err)
log.Printf("[%s] Cannot write data: %s", m.Name, err)
}
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
h.db.Save(&m)
return true
case <-update:
@@ -323,7 +313,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
log.Printf("[%s] The client has closed the connection", m.Name)
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
h.db.Save(&m)
h.pollMu.Lock()
cancelKeepAlive <- []byte{}
delete(h.clientsPolling, m.ID)
@@ -335,7 +325,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
})
}
func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) {
func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgkey.Key, req tailcfg.MapRequest, m Machine) {
for {
select {
case <-cancel:
@@ -356,7 +346,7 @@ func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgc
}
}
func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
node, err := m.toNode()
if err != nil {
log.Printf("Cannot convert to node: %s", err)
@@ -367,17 +357,23 @@ func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Mac
log.Printf("Cannot fetch peers: %s", err)
return nil, err
}
profile := tailcfg.UserProfile{
ID: tailcfg.UserID(m.NamespaceID),
LoginName: m.Namespace.Name,
DisplayName: m.Namespace.Name,
}
resp := tailcfg.MapResponse{
KeepAlive: false,
Node: node,
Peers: *peers,
DNS: []netaddr.IP{},
SearchPaths: []string{},
Domain: "foobar@example.com",
PacketFilter: tailcfg.FilterAllowAll,
Domain: "headscale.net",
PacketFilter: *h.aclRules,
DERPMap: h.cfg.DerpMap,
UserProfiles: []tailcfg.UserProfile{},
Roles: []tailcfg.Role{},
UserProfiles: []tailcfg.UserProfile{profile},
}
var respBody []byte
@@ -403,7 +399,7 @@ func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Mac
return &data, nil
}
func (h *Headscale) getMapKeepAliveResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
func (h *Headscale) getMapKeepAliveResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
resp := tailcfg.MapResponse{
KeepAlive: true,
}
@@ -429,7 +425,7 @@ func (h *Headscale) getMapKeepAliveResponse(mKey wgcfg.Key, req tailcfg.MapReque
return &data, nil
}
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgcfg.Key, req tailcfg.RegisterRequest, m Machine) {
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, req tailcfg.RegisterRequest, m Machine) {
resp := tailcfg.RegisterResponse{}
pak, err := h.checkKeyValidity(req.Auth.AuthKey)
if err != nil {
@@ -453,7 +449,7 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgcfg.Key,
m.AuthKeyID = uint(pak.ID)
m.IPAddress = ip.String()
m.NamespaceID = pak.NamespaceID
m.NodeKey = wgcfg.Key(req.NodeKey).HexString() // we update it just in case
m.NodeKey = wgkey.Key(req.NodeKey).HexString() // we update it just in case
m.Registered = true
m.RegisterMethod = "authKey"
db.Save(&m)

29
app.go
View File

@@ -12,8 +12,9 @@ import (
"github.com/gin-gonic/gin"
"golang.org/x/crypto/acme/autocert"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// Config contains the initial Headscale configuration
@@ -43,11 +44,15 @@ type Config struct {
// Headscale represents the base app of the service
type Headscale struct {
cfg Config
db *gorm.DB
dbString string
dbType string
dbDebug bool
publicKey *wgcfg.Key
privateKey *wgcfg.PrivateKey
publicKey *wgkey.Key
privateKey *wgkey.Private
aclPolicy *ACLPolicy
aclRules *[]tailcfg.FilterRule
pollMu sync.Mutex
clientsPolling map[uint64]chan []byte // this is by all means a hackity hack
@@ -59,7 +64,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
if err != nil {
return nil, err
}
privKey, err := wgcfg.ParsePrivateKey(string(content))
privKey, err := wgkey.ParsePrivate(string(content))
if err != nil {
return nil, err
}
@@ -73,7 +78,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
case "sqlite3":
dbString = cfg.DBpath
default:
return nil, errors.New("Unsupported DB")
return nil, errors.New("unsupported DB")
}
h := Headscale{
@@ -82,11 +87,14 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
dbString: dbString,
privateKey: privKey,
publicKey: &pubKey,
aclRules: &tailcfg.FilterAllowAll, // default allowall
}
err = h.initDB()
if err != nil {
return nil, err
}
h.clientsPolling = make(map[uint64]chan []byte)
return &h, nil
}
@@ -107,13 +115,6 @@ func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
}
func (h *Headscale) expireEphemeralNodesWorker() {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return
}
defer db.Close()
namespaces, err := h.ListNamespaces()
if err != nil {
log.Printf("Error listing namespaces: %s", err)
@@ -128,7 +129,7 @@ func (h *Headscale) expireEphemeralNodesWorker() {
for _, m := range *machines {
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
log.Printf("[%s] Ephemeral client removed from database\n", m.Name)
err = db.Unscoped().Delete(m).Error
err = h.db.Unscoped().Delete(m).Error
if err != nil {
log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err)
}
@@ -174,7 +175,7 @@ func (h *Headscale) Serve() error {
}()
err = s.ListenAndServeTLS("", "")
} else {
return errors.New("Unknown value for TLSLetsEncryptChallengeType")
return errors.New("unknown value for TLSLetsEncryptChallengeType")
}
} else if h.cfg.TLSCertPath == "" {
if !strings.HasPrefix(h.cfg.ServerURL, "http://") {

View File

@@ -5,8 +5,6 @@ import (
"os"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
"gopkg.in/check.v1"
)
@@ -49,4 +47,9 @@ func (s *Suite) ResetDB(c *check.C) {
if err != nil {
c.Fatal(err)
}
db, err := h.openDB()
if err != nil {
c.Fatal(err)
}
h.db = db
}

17
cli.go
View File

@@ -2,9 +2,9 @@ package headscale
import (
"errors"
"log"
"tailscale.com/wgengine/wgcfg"
"gorm.io/gorm"
"tailscale.com/types/wgkey"
)
// RegisterMachine is executed from the CLI to register a new Machine using its MachineKey
@@ -13,18 +13,13 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
if err != nil {
return nil, err
}
mKey, err := wgcfg.ParseHexKey(key)
mKey, err := wgkey.ParseHex(key)
if err != nil {
return nil, err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
m := Machine{}
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := h.db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New("Machine not found")
}
@@ -40,6 +35,6 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
m.NamespaceID = ns.ID
m.Registered = true
m.RegisterMethod = "cli"
db.Save(&m)
h.db.Save(&m)
return &m, nil
}

View File

@@ -8,12 +8,6 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,
MachineKey: "8ce002a935f8c394e55e78fbbb410576575ff8ec5cfa2e627e4b807f1be15b0e",
@@ -22,7 +16,7 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
Name: "testmachine",
NamespaceID: n.ID,
}
db.Save(&m)
h.db.Save(&m)
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.IsNil)

View File

@@ -9,7 +9,7 @@ import (
)
var NamespaceCmd = &cobra.Command{
Use: "namespace",
Use: "namespaces",
Short: "Manage the namespaces of Headscale",
}

View File

@@ -4,10 +4,16 @@ import (
"fmt"
"log"
"strings"
"time"
"github.com/spf13/cobra"
)
var NodeCmd = &cobra.Command{
Use: "nodes",
Short: "Manage the nodes of Headscale",
}
var RegisterCmd = &cobra.Command{
Use: "register machineID",
Short: "Registers a machine to your network",
@@ -71,13 +77,12 @@ var ListNodesCmd = &cobra.Command{
if m.AuthKey != nil && m.AuthKey.Ephemeral {
ephemeral = true
}
fmt.Printf("%s\t%s\t%t\n", m.Name, m.LastSeen.Format("2006-01-02 15:04:05"), ephemeral)
var lastSeen time.Time
if m.LastSeen != nil {
lastSeen = *m.LastSeen
}
fmt.Printf("%s\t%s\t%t\n", m.Name, lastSeen.Format("2006-01-02 15:04:05"), ephemeral)
}
},
}
var NodeCmd = &cobra.Command{
Use: "node",
Short: "Manage the nodes of Headscale",
}

View File

@@ -11,7 +11,7 @@ import (
)
var PreauthkeysCmd = &cobra.Command{
Use: "preauthkey",
Use: "preauthkeys",
Short: "Handle the preauthkeys in Headscale",
}
@@ -44,11 +44,19 @@ var ListPreAuthKeys = &cobra.Command{
if k.Expiration != nil {
expiration = k.Expiration.Format("2006-01-02 15:04:05")
}
var reusable string
if k.Ephemeral {
reusable = "N/A"
} else {
reusable = fmt.Sprintf("%v", k.Reusable)
}
fmt.Printf(
"key: %s, namespace: %s, reusable: %v, ephemeral: %v, expiration: %s, created_at: %s\n",
"key: %s, namespace: %s, reusable: %s, ephemeral: %v, expiration: %s, created_at: %s\n",
k.Key,
k.Namespace.Name,
k.Reusable,
reusable,
k.Ephemeral,
expiration,
k.CreatedAt.Format("2006-01-02 15:04:05"),

View File

@@ -48,7 +48,8 @@ func LoadConfig(path string) error {
}
if (viper.GetString("tls_letsencrypt_hostname") != "") && (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) {
errorText += "Fatal config error: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, listen_addr must end in :443\n"
// this is only a warning because there could be something sitting in front of headscale that redirects the traffic (e.g. an iptables rule)
log.Println("Warning: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, headscale must be reachable on port 443, i.e. listen_addr should probably end in :443")
}
if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") && (viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") {
@@ -119,6 +120,16 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
if err != nil {
return nil, err
}
// We are doing this here, as in the future could be cool to have it also hot-reload
if viper.GetString("acl_policy_path") != "" {
err = h.LoadACLPolicy(absPath(viper.GetString("acl_policy_path")))
if err != nil {
log.Printf("Could not load the ACL policy: %s", err)
}
}
return h, nil
}

View File

@@ -126,6 +126,5 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
configYaml = []byte("---\nserver_url: \"http://127.0.0.1:8000\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"")
writeConfig(c, tmpDir, configYaml)
err = cli.LoadConfig(tmpDir)
c.Assert(err, check.NotNil)
c.Assert(err, check.ErrorMatches, "Fatal config error: when using tls_letsencrypt_hostname with TLS-ALPN-01 as challenge type, listen_addr must end in :443.*")
c.Assert(err, check.IsNil)
}

View File

@@ -14,5 +14,6 @@
"tls_letsencrypt_cache_dir": ".cache",
"tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "",
"tls_key_path": ""
"tls_key_path": "",
"acl_policy_path": ""
}

View File

@@ -10,5 +10,6 @@
"tls_letsencrypt_cache_dir": ".cache",
"tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "",
"tls_key_path": ""
"tls_key_path": "",
"acl_policy_path": ""
}

81
db.go
View File

@@ -3,9 +3,10 @@ package headscale
import (
"errors"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/postgres" // sql driver
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const dbVersion = "1"
@@ -17,42 +18,70 @@ type KV struct {
}
func (h *Headscale) initDB() error {
db, err := gorm.Open(h.dbType, h.dbString)
db, err := h.openDB()
if err != nil {
return err
}
h.db = db
if h.dbType == "postgres" {
db.Exec("create extension if not exists \"uuid-ossp\";")
}
db.AutoMigrate(&Machine{})
db.AutoMigrate(&KV{})
db.AutoMigrate(&Namespace{})
db.AutoMigrate(&PreAuthKey{})
db.Close()
err = db.AutoMigrate(&Machine{})
if err != nil {
return err
}
err = db.AutoMigrate(&KV{})
if err != nil {
return err
}
err = db.AutoMigrate(&Namespace{})
if err != nil {
return err
}
err = db.AutoMigrate(&PreAuthKey{})
if err != nil {
return err
}
err = h.setValue("db_version", dbVersion)
return err
}
func (h *Headscale) db() (*gorm.DB, error) {
db, err := gorm.Open(h.dbType, h.dbString)
func (h *Headscale) openDB() (*gorm.DB, error) {
var db *gorm.DB
var err error
var log logger.Interface
if h.dbDebug {
log = logger.Default
} else {
log = logger.Default.LogMode(logger.Silent)
}
switch h.dbType {
case "sqlite3":
db, err = gorm.Open(sqlite.Open(h.dbString), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: log,
})
case "postgres":
db, err = gorm.Open(postgres.Open(h.dbString), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
Logger: log,
})
}
if err != nil {
return nil, err
}
if h.dbDebug {
db.LogMode(true)
}
return db, nil
}
func (h *Headscale) getValue(key string) (string, error) {
db, err := h.db()
if err != nil {
return "", err
}
defer db.Close()
var row KV
if db.First(&row, "key = ?", key).RecordNotFound() {
if result := h.db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return "", errors.New("not found")
}
return row.Value, nil
@@ -63,17 +92,13 @@ func (h *Headscale) setValue(key string, value string) error {
Key: key,
Value: value,
}
db, err := h.db()
if err != nil {
return err
}
defer db.Close()
_, err = h.getValue(key)
_, err := h.getValue(key)
if err == nil {
db.Model(&kv).Where("key = ?", key).Update("value", value)
h.db.Model(&kv).Where("key = ?", key).Update("value", value)
return nil
}
db.Create(kv)
h.db.Create(kv)
return nil
}

View File

@@ -1,5 +1,5 @@
# This file contains some of the official Tailscale DERP servers,
# shamelessly taken from https://github.com/tailscale/tailscale/blob/main/derp/derpmap/derpmap.go
# shamelessly taken from https://github.com/tailscale/tailscale/blob/main/net/dnsfallback/dns-fallback-servers.json
#
# If you plan to somehow use headscale, please deploy your own DERP infra
regions:
@@ -16,6 +16,14 @@ regions:
stunport: 0
stunonly: false
derptestport: 0
- name: 1b
regionid: 1
hostname: derp1b.tailscale.com
ipv4: 45.55.35.93
ipv6: "2604:a880:800:a1::f:2001"
stunport: 0
stunonly: false
derptestport: 0
2:
regionid: 2
regioncode: sfo
@@ -29,6 +37,14 @@ regions:
stunport: 0
stunonly: false
derptestport: 0
- name: 2b
regionid: 2
hostname: derp2b.tailscale.com
ipv4: 64.227.106.23
ipv6: "2604:a880:4:1d0::29:9000"
stunport: 0
stunonly: false
derptestport: 0
3:
regionid: 3
regioncode: sin
@@ -54,4 +70,77 @@ regions:
ipv6: "2a03:b0c0:3:e0::36e:900"
stunport: 0
stunonly: false
derptestport: 0
derptestport: 0
- name: 4b
regionid: 4
hostname: derp4b.tailscale.com
ipv4: 157.230.25.0
ipv6: "2a03:b0c0:3:e0::58f:3001"
stunport: 0
stunonly: false
derptestport: 0
5:
regionid: 5
regioncode: syd
regionname: Sydney
nodes:
- name: 5a
regionid: 5
hostname: derp5.tailscale.com
ipv4: 103.43.75.49
ipv6: "2001:19f0:5801:10b7:5400:2ff:feaa:284c"
stunport: 0
stunonly: false
derptestport: 0
6:
regionid: 6
regioncode: blr
regionname: Bangalore
nodes:
- name: 6a
regionid: 6
hostname: derp6.tailscale.com
ipv4: 68.183.90.120
ipv6: "2400:6180:100:d0::982:d001"
stunport: 0
stunonly: false
derptestport: 0
7:
regionid: 7
regioncode: tok
regionname: Tokyo
nodes:
- name: 7a
regionid: 7
hostname: derp7.tailscale.com
ipv4: 167.179.89.145
ipv6: "2401:c080:1000:467f:5400:2ff:feee:22aa"
stunport: 0
stunonly: false
derptestport: 0
8:
regionid: 8
regioncode: lhr
regionname: London
nodes:
- name: 8a
regionid: 8
hostname: derp8.tailscale.com
ipv4: 167.71.139.179
ipv6: "2a03:b0c0:1:e0::3cc:e001"
stunport: 0
stunonly: false
derptestport: 0
9:
regionid: 9
regioncode: sao
regionname: São Paulo
nodes:
- name: 9a
regionid: 9
hostname: derp9.tailscale.com
ipv4: 207.148.3.137
ipv6: "2001:19f0:6401:1d9c:5400:2ff:feef:bb82"
stunport: 0
stunonly: false
derptestport: 0

29
go.mod
View File

@@ -3,26 +3,21 @@ module github.com/juanfont/headscale
go 1.16
require (
github.com/fsnotify/fsnotify v1.4.9 // indirect
github.com/gin-gonic/gin v1.7.1
github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd
github.com/jinzhu/gorm v1.9.16
github.com/json-iterator/go v1.1.11 // indirect
github.com/klauspost/compress v1.12.2
github.com/kr/text v0.2.0 // indirect
github.com/lib/pq v1.10.1 // indirect
github.com/gin-gonic/gin v1.7.2
github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
github.com/klauspost/compress v1.13.1
github.com/lib/pq v1.10.2 // indirect
github.com/mattn/go-sqlite3 v1.14.7 // indirect
github.com/spf13/cobra v1.1.3
github.com/spf13/viper v1.7.1
github.com/stretchr/testify v1.7.0 // indirect
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a
golang.org/x/text v0.3.6 // indirect
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect
google.golang.org/appengine v1.6.7 // indirect
github.com/spf13/viper v1.8.1
github.com/tailscale/hujson v0.0.0-20200924210142-dde312d0d6a2
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.0.1
inet.af/netaddr v0.0.0-20210511181906-37180328850c
tailscale.com v1.6.0
gorm.io/driver/postgres v1.1.0
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.21.11
inet.af/netaddr v0.0.0-20210603230628-bf05d8b52dda
tailscale.com v1.10.0
)

909
go.sum

File diff suppressed because it is too large Load Diff

2
k8s/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/**/site
/**/secrets

99
k8s/README.md Normal file
View File

@@ -0,0 +1,99 @@
# Deploying Headscale on Kubernetes
This directory contains [Kustomize](https://kustomize.io) templates that deploy
Headscale in various configurations.
These templates currently support Rancher k3s. Other clusters may require
adaptation, especially around volume claims and ingress.
Commands below assume this directory is your current working directory.
# Generate secrets and site configuration
Run `./init.bash` to generate keys, passwords, and site configuration files.
Edit `base/site/public.env`, changing `public-hostname` to the public DNS name
that will be used for your headscale deployment.
Set `public-proto` to "https" if you're planning to use TLS & Let's Encrypt.
Configure DERP servers by editing `base/site/derp.yaml` if needed.
# Add the image to the registry
You'll somehow need to get `headscale:latest` into your cluster image registry.
An easy way to do this with k3s:
- Reconfigure k3s to use docker instead of containerd (`k3s server --docker`)
- `docker build -t headscale:latest ..` from here
# Create the namespace
If it doesn't already exist, `kubectl create ns headscale`.
# Deploy headscale
## sqlite
`kubectl -n headscale apply -k ./sqlite`
## postgres
`kubectl -n headscale apply -k ./postgres`
# TLS & Let's Encrypt
Test a staging certificate with your configured DNS name and Let's Encrypt.
`kubectl -n headscale apply -k ./staging-tls`
Replace with a production certificate.
`kubectl -n headscale apply -k ./production-tls`
## Static / custom TLS certificates
Only Let's Encrypt is supported. If you need other TLS settings, modify or patch the ingress.
# Administration
Use the wrapper script to remotely operate headscale to perform administrative
tasks like creating namespaces, authkeys, etc.
```
[c@nix-slate:~/Projects/headscale/k8s]$ ./headscale.bash
headscale is an open source implementation of the Tailscale control server
Juan Font Alonso <juanfontalonso@gmail.com> - 2021
https://gitlab.com/juanfont/headscale
Usage:
headscale [command]
Available Commands:
help Help about any command
namespace Manage the namespaces of Headscale
node Manage the nodes of Headscale
preauthkey Handle the preauthkeys in Headscale
routes Manage the routes of Headscale
serve Launches the headscale server
version Print the version.
Flags:
-h, --help help for headscale
-o, --output string Output format. Empty for human-readable, 'json' or 'json-line'
Use "headscale [command] --help" for more information about a command.
```
# TODO / Ideas
- Github action to publish the docker image
- Interpolate `email:` option to the ClusterIssuer from site configuration.
This probably needs to be done with a transformer, kustomize vars don't seem to work.
- Add kustomize examples for cloud-native ingress, load balancer
- CockroachDB for the backend
- DERP server deployment
- Tor hidden service

8
k8s/base/configmap.yaml Normal file
View File

@@ -0,0 +1,8 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: headscale-config
data:
server_url: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
listen_addr: "0.0.0.0:8080"
ephemeral_node_inactivity_timeout: "30m"

18
k8s/base/ingress.yaml Normal file
View File

@@ -0,0 +1,18 @@
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: headscale
annotations:
kubernetes.io/ingress.class: traefik
spec:
rules:
- host: $(PUBLIC_HOSTNAME)
http:
paths:
- backend:
service:
name: headscale
port:
number: 8080
path: /
pathType: Prefix

View File

@@ -0,0 +1,42 @@
namespace: headscale
resources:
- configmap.yaml
- ingress.yaml
- service.yaml
generatorOptions:
disableNameSuffixHash: true
configMapGenerator:
- name: headscale-site
files:
- derp.yaml=site/derp.yaml
envs:
- site/public.env
- name: headscale-etc
literals:
- config.json={}
secretGenerator:
- name: headscale
files:
- secrets/private-key
vars:
- name: PUBLIC_PROTO
objRef:
kind: ConfigMap
name: headscale-site
apiVersion: v1
fieldRef:
fieldPath: data.public-proto
- name: PUBLIC_HOSTNAME
objRef:
kind: ConfigMap
name: headscale-site
apiVersion: v1
fieldRef:
fieldPath: data.public-hostname
- name: CONTACT_EMAIL
objRef:
kind: ConfigMap
name: headscale-site
apiVersion: v1
fieldRef:
fieldPath: data.contact-email

13
k8s/base/service.yaml Normal file
View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: headscale
labels:
app: headscale
spec:
selector:
app: headscale
ports:
- name: http
targetPort: http
port: 8080

3
k8s/headscale.bash Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -eu
exec kubectl -n headscale exec -ti pod/headscale-0 -- /go/bin/headscale "$@"

22
k8s/init.bash Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -eux
cd $(dirname $0)
umask 022
mkdir -p base/site/
[ ! -e base/site/public.env ] && (
cat >base/site/public.env <<EOF
public-hostname=localhost
public-proto=http
contact-email=headscale@example.com
EOF
)
[ ! -e base/site/derp.yaml ] && cp ../derp.yaml base/site/derp.yaml
umask 077
mkdir -p base/secrets/
[ ! -e base/secrets/private-key ] && (
wg genkey > base/secrets/private-key
)
mkdir -p postgres/secrets/
[ ! -e postgres/secrets/password ] && (head -c 32 /dev/urandom | base64 -w0 > postgres/secrets/password)

3
k8s/install-cert-manager.bash Executable file
View File

@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -eux
kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/v1.4.0/cert-manager.yaml

View File

@@ -0,0 +1,78 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: headscale
spec:
replicas: 2
selector:
matchLabels:
app: headscale
template:
metadata:
labels:
app: headscale
spec:
containers:
- name: headscale
image: "headscale:latest"
imagePullPolicy: IfNotPresent
command: ["/go/bin/headscale", "serve"]
env:
- name: SERVER_URL
value: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
- name: LISTEN_ADDR
valueFrom:
configMapKeyRef:
name: headscale-config
key: listen_addr
- name: PRIVATE_KEY_PATH
value: /vol/secret/private-key
- name: DERP_MAP_PATH
value: /vol/config/derp.yaml
- name: EPHEMERAL_NODE_INACTIVITY_TIMEOUT
valueFrom:
configMapKeyRef:
name: headscale-config
key: ephemeral_node_inactivity_timeout
- name: DB_TYPE
value: postgres
- name: DB_HOST
value: postgres.headscale.svc.cluster.local
- name: DB_PORT
value: "5432"
- name: DB_USER
value: headscale
- name: DB_PASS
valueFrom:
secretKeyRef:
name: postgresql
key: password
- name: DB_NAME
value: headscale
ports:
- name: http
protocol: TCP
containerPort: 8080
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
timeoutSeconds: 5
periodSeconds: 15
volumeMounts:
- name: config
mountPath: /vol/config
- name: secret
mountPath: /vol/secret
- name: etc
mountPath: /etc/headscale
volumes:
- name: config
configMap:
name: headscale-site
- name: etc
configMap:
name: headscale-etc
- name: secret
secret:
secretName: headscale

View File

@@ -0,0 +1,13 @@
namespace: headscale
bases:
- ../base
resources:
- deployment.yaml
- postgres-service.yaml
- postgres-statefulset.yaml
generatorOptions:
disableNameSuffixHash: true
secretGenerator:
- name: postgresql
files:
- secrets/password

View File

@@ -0,0 +1,13 @@
apiVersion: v1
kind: Service
metadata:
name: postgres
labels:
app: postgres
spec:
selector:
app: postgres
ports:
- name: postgres
targetPort: postgres
port: 5432

View File

@@ -0,0 +1,49 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: postgres
spec:
serviceName: postgres
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: "postgres:13"
imagePullPolicy: IfNotPresent
env:
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: postgresql
key: password
- name: POSTGRES_USER
value: headscale
ports:
- name: postgres
protocol: TCP
containerPort: 5432
livenessProbe:
tcpSocket:
port: 5432
initialDelaySeconds: 30
timeoutSeconds: 5
periodSeconds: 15
volumeMounts:
- name: pgdata
mountPath: /var/lib/postgresql/data
volumeClaimTemplates:
- metadata:
name: pgdata
spec:
storageClassName: local-path
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi

View File

@@ -0,0 +1,11 @@
kind: Ingress
metadata:
name: headscale
annotations:
cert-manager.io/cluster-issuer: letsencrypt-production
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
tls:
- hosts:
- $(PUBLIC_HOSTNAME)
secretName: production-cert

View File

@@ -0,0 +1,9 @@
namespace: headscale
bases:
- ../base
resources:
- production-issuer.yaml
patches:
- path: ingress-patch.yaml
target:
kind: Ingress

View File

@@ -0,0 +1,16 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-production
spec:
acme:
# TODO: figure out how to get kustomize to interpolate this, or use a transformer
#email: $(CONTACT_EMAIL)
server: https://acme-v02.api.letsencrypt.org/directory
privateKeySecretRef:
# Secret resource used to store the account's private key.
name: letsencrypt-production-acc-key
solvers:
- http01:
ingress:
class: traefik

View File

@@ -0,0 +1,5 @@
namespace: headscale
bases:
- ../base
resources:
- statefulset.yaml

View File

@@ -0,0 +1,79 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: headscale
spec:
serviceName: headscale
replicas: 1
selector:
matchLabels:
app: headscale
template:
metadata:
labels:
app: headscale
spec:
containers:
- name: headscale
image: "headscale:latest"
imagePullPolicy: IfNotPresent
command: ["/go/bin/headscale", "serve"]
env:
- name: SERVER_URL
value: $(PUBLIC_PROTO)://$(PUBLIC_HOSTNAME)
- name: LISTEN_ADDR
valueFrom:
configMapKeyRef:
name: headscale-config
key: listen_addr
- name: PRIVATE_KEY_PATH
value: /vol/secret/private-key
- name: DERP_MAP_PATH
value: /vol/config/derp.yaml
- name: EPHEMERAL_NODE_INACTIVITY_TIMEOUT
valueFrom:
configMapKeyRef:
name: headscale-config
key: ephemeral_node_inactivity_timeout
- name: DB_TYPE
value: sqlite3
- name: DB_PATH
value: /vol/data/db.sqlite
ports:
- name: http
protocol: TCP
containerPort: 8080
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: 30
timeoutSeconds: 5
periodSeconds: 15
volumeMounts:
- name: config
mountPath: /vol/config
- name: data
mountPath: /vol/data
- name: secret
mountPath: /vol/secret
- name: etc
mountPath: /etc/headscale
volumes:
- name: config
configMap:
name: headscale-site
- name: etc
configMap:
name: headscale-etc
- name: secret
secret:
secretName: headscale
volumeClaimTemplates:
- metadata:
name: data
spec:
storageClassName: local-path
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 1Gi

View File

@@ -0,0 +1,11 @@
kind: Ingress
metadata:
name: headscale
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
tls:
- hosts:
- $(PUBLIC_HOSTNAME)
secretName: staging-cert

View File

@@ -0,0 +1,9 @@
namespace: headscale
bases:
- ../base
resources:
- staging-issuer.yaml
patches:
- path: ingress-patch.yaml
target:
kind: Ingress

View File

@@ -0,0 +1,16 @@
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
# TODO: figure out how to get kustomize to interpolate this, or use a transformer
#email: $(CONTACT_EMAIL)
server: https://acme-staging-v02.api.letsencrypt.org/directory
privateKeySecretRef:
# Secret resource used to store the account's private key.
name: letsencrypt-staging-acc-key
solvers:
- http01:
ingress:
class: traefik

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/datatypes"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// Machine is a Headscale client
@@ -23,7 +23,7 @@ type Machine struct {
IPAddress string
Name string
NamespaceID uint
Namespace Namespace
Namespace Namespace `gorm:"foreignKey:NamespaceID"`
Registered bool // temp
RegisterMethod string
@@ -48,18 +48,18 @@ func (m Machine) isAlreadyRegistered() bool {
}
func (m Machine) toNode() (*tailcfg.Node, error) {
nKey, err := wgcfg.ParseHexKey(m.NodeKey)
nKey, err := wgkey.ParseHex(m.NodeKey)
if err != nil {
return nil, err
}
mKey, err := wgcfg.ParseHexKey(m.MachineKey)
mKey, err := wgkey.ParseHex(m.MachineKey)
if err != nil {
return nil, err
}
var discoKey tailcfg.DiscoKey
if m.DiscoKey != "" {
dKey, err := wgcfg.ParseHexKey(m.DiscoKey)
dKey, err := wgkey.ParseHex(m.DiscoKey)
if err != nil {
return nil, err
}
@@ -154,15 +154,8 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
}
func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
machines := []Machine{}
if err = db.Where("namespace_id = ? AND machine_key <> ? AND registered",
if err := h.db.Where("namespace_id = ? AND machine_key <> ? AND registered",
m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil {
log.Printf("Error accessing db: %s", err)
return nil, err

View File

@@ -11,12 +11,6 @@ func (s *Suite) TestGetMachine(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)
@@ -31,7 +25,7 @@ func (s *Suite) TestGetMachine(c *check.C) {
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
db.Save(&m)
h.db.Save(&m)
m1, err := h.GetMachine("test", "testmachine")
c.Assert(err, check.IsNil)

View File

@@ -1,10 +1,11 @@
package headscale
import (
"errors"
"log"
"time"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -24,19 +25,12 @@ type Namespace struct {
// CreateNamespace creates a new Namespace. Returns error if could not be created
// or another namespace already exists
func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
n := Namespace{}
if err := db.Where("name = ?", name).First(&n).Error; err == nil {
if err := h.db.Where("name = ?", name).First(&n).Error; err == nil {
return nil, errorNamespaceExists
}
n.Name = name
if err := db.Create(&n).Error; err != nil {
if err := h.db.Create(&n).Error; err != nil {
log.Printf("Could not create row: %s", err)
return nil, err
}
@@ -46,13 +40,6 @@ func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
// DestroyNamespace destroys a Namespace. Returns error if the Namespace does
// not exist or if there are machines associated with it.
func (h *Headscale) DestroyNamespace(name string) error {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return err
}
defer db.Close()
n, err := h.GetNamespace(name)
if err != nil {
return errorNamespaceNotFound
@@ -66,8 +53,7 @@ func (h *Headscale) DestroyNamespace(name string) error {
return errorNamespaceNotEmpty
}
err = db.Unscoped().Delete(&n).Error
if err != nil {
if result := h.db.Unscoped().Delete(&n); result.Error != nil {
return err
}
@@ -76,15 +62,8 @@ func (h *Headscale) DestroyNamespace(name string) error {
// GetNamespace fetches a namespace by name
func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
n := Namespace{}
if db.First(&n, "name = ?", name).RecordNotFound() {
if result := h.db.First(&n, "name = ?", name); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errorNamespaceNotFound
}
return &n, nil
@@ -92,14 +71,8 @@ func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
// ListNamespaces gets all the existing namespaces
func (h *Headscale) ListNamespaces() (*[]Namespace, error) {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
namespaces := []Namespace{}
if err := db.Find(&namespaces).Error; err != nil {
if err := h.db.Find(&namespaces).Error; err != nil {
return nil, err
}
return &namespaces, nil
@@ -111,15 +84,9 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
if err != nil {
return nil, err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
machines := []Machine{}
if err := db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
if err := h.db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
return nil, err
}
return &machines, nil
@@ -131,26 +98,19 @@ func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error
if err != nil {
return err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return err
}
defer db.Close()
m.NamespaceID = n.ID
db.Save(&m)
h.db.Save(&m)
return nil
}
func (n *Namespace) toUser() *tailcfg.User {
u := tailcfg.User{
ID: tailcfg.UserID(n.ID),
LoginName: "",
LoginName: n.Name,
DisplayName: n.Name,
ProfilePicURL: "",
Domain: "",
Domain: "headscale.net",
Logins: []tailcfg.LoginID{},
Roles: []tailcfg.RoleID{},
Created: time.Time{},
}
return &u

View File

@@ -30,11 +30,6 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,
MachineKey: "foo",
@@ -46,7 +41,7 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
db.Save(&m)
h.db.Save(&m)
err = h.DestroyNamespace("test")
c.Assert(err, check.Equals, errorNamespaceNotEmpty)

View File

@@ -3,8 +3,10 @@ package headscale
import (
"crypto/rand"
"encoding/hex"
"log"
"errors"
"time"
"gorm.io/gorm"
)
const errorAuthKeyNotFound = Error("AuthKey not found")
@@ -31,13 +33,6 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
return nil, err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
now := time.Now().UTC()
kstr, err := h.generateKey()
if err != nil {
@@ -53,7 +48,7 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
CreatedAt: &now,
Expiration: expiration,
}
db.Save(&k)
h.db.Save(&k)
return &k, nil
}
@@ -64,15 +59,9 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
if err != nil {
return nil, err
}
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
keys := []PreAuthKey{}
if err := db.Preload("Namespace").Where(&PreAuthKey{NamespaceID: n.ID}).Find(&keys).Error; err != nil {
if err := h.db.Preload("Namespace").Where(&PreAuthKey{NamespaceID: n.ID}).Find(&keys).Error; err != nil {
return nil, err
}
return &keys, nil
@@ -81,14 +70,8 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
// checkKeyValidity does the heavy lifting for validation of the PreAuthKey coming from a node
// If returns no error and a PreAuthKey, it can be used
func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
db, err := h.db()
if err != nil {
return nil, err
}
defer db.Close()
pak := PreAuthKey{}
if db.Preload("Namespace").First(&pak, "key = ?", k).RecordNotFound() {
if result := h.db.Preload("Namespace").First(&pak, "key = ?", k); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errorAuthKeyNotFound
}
@@ -101,7 +84,7 @@ func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
}
machines := []Machine{}
if err := db.Preload("AuthKey").Where(&Machine{AuthKeyID: uint(pak.ID)}).Find(&machines).Error; err != nil {
if err := h.db.Preload("AuthKey").Where(&Machine{AuthKeyID: uint(pak.ID)}).Find(&machines).Error; err != nil {
return nil, err
}

View File

@@ -73,11 +73,6 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,
MachineKey: "foo",
@@ -89,7 +84,7 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
db.Save(&m)
h.db.Save(&m)
p, err := h.checkKeyValidity(pak.Key)
c.Assert(err, check.Equals, errorAuthKeyNotReusableAlreadyUsed)
@@ -103,11 +98,6 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, true, false, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 1,
MachineKey: "foo",
@@ -119,7 +109,7 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
db.Save(&m)
h.db.Save(&m)
p, err := h.checkKeyValidity(pak.Key)
c.Assert(err, check.IsNil)
@@ -145,11 +135,6 @@ func (*Suite) TestEphemeralKey(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, true, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
now := time.Now()
m := Machine{
ID: 0,
@@ -163,7 +148,7 @@ func (*Suite) TestEphemeralKey(c *check.C) {
LastSeen: &now,
AuthKeyID: uint(pak.ID),
}
db.Save(&m)
h.db.Save(&m)
_, err = h.checkKeyValidity(pak.Key)
// Ephemeral keys are by definition reusable

View File

@@ -3,7 +3,6 @@ package headscale
import (
"encoding/json"
"errors"
"log"
"gorm.io/datatypes"
"inet.af/netaddr"
@@ -42,16 +41,9 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
for _, rIP := range hi.RoutableIPs {
if rIP == route {
db, err := h.db()
if err != nil {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
routes, _ := json.Marshal([]string{routeStr}) // TODO: only one for the time being, so overwriting the rest
m.EnabledRoutes = datatypes.JSON(routes)
db.Save(&m)
db.Close()
h.db.Save(&m)
// THIS IS COMPLETELY USELESS.
// The peers map is stored in memory in the server process.

View File

@@ -16,12 +16,6 @@ func (s *Suite) TestGetRoutes(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)
@@ -46,7 +40,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
AuthKeyID: uint(pak.ID),
HostInfo: datatypes.JSON(hostinfo),
}
db.Save(&m)
h.db.Save(&m)
r, err := h.GetNodeRoutes("test", "testmachine")
c.Assert(err, check.IsNil)

View File

@@ -0,0 +1,127 @@
{
// Declare static groups of users beyond those in the identity service.
"Groups": {
"group:example": [
"user1@example.com",
"user2@example.com",
],
"group:example2": [
"user1@example.com",
"user2@example.com",
],
},
// Declare hostname aliases to use in place of IP addresses or subnets.
"Hosts": {
"example-host-1": "100.100.100.100",
"example-host-2": "100.100.101.100/24",
},
// Define who is allowed to use which tags.
"TagOwners": {
// Everyone in the montreal-admins or global-admins group are
// allowed to tag servers as montreal-webserver.
"tag:montreal-webserver": [
"group:example",
],
// Only a few admins are allowed to create API servers.
"tag:production": [
"group:example",
"president@example.com",
],
},
// Access control lists.
"ACLs": [
// Engineering users, plus the president, can access port 22 (ssh)
// and port 3389 (remote desktop protocol) on all servers, and all
// ports on git-server or ci-server.
{
"Action": "accept",
"Users": [
"group:example2",
"192.168.1.0/24"
],
"Ports": [
"*:22,3389",
"git-server:*",
"ci-server:*"
],
},
// Allow engineer users to access any port on a device tagged with
// tag:production.
{
"Action": "accept",
"Users": [
"group:example"
],
"Ports": [
"tag:production:*"
],
},
// Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts
// on both networks.
{
"Action": "accept",
"Users": [
"example-host-2",
],
"Ports": [
"example-host-1:*",
"192.168.1.0/24:*"
],
},
// Allow every user of your network to access anything on the network.
// Comment out this section if you want to define specific ACL
// restrictions above.
{
"Action": "accept",
"Users": [
"*"
],
"Ports": [
"*:*"
],
},
// All users in Montreal are allowed to access the Montreal web
// servers.
{
"Action": "accept",
"Users": [
"example-host-1"
],
"Ports": [
"tag:montreal-webserver:80,443"
],
},
// Montreal web servers are allowed to make outgoing connections to
// the API servers, but only on https port 443.
// In contrast, this doesn't grant API servers the right to initiate
// any connections.
{
"Action": "accept",
"Users": [
"tag:montreal-webserver"
],
"Ports": [
"tag:api-server:443"
],
},
],
// Declare tests to check functionality of ACL rules
"Tests": [
{
"User": "user1@example.com",
"Allow": [
"example-host-1:22",
"example-host-2:80"
],
"Deny": [
"exapmle-host-2:100"
],
},
{
"User": "user2@example.com",
"Allow": [
"100.60.3.4:22"
],
},
],
}

View File

@@ -0,0 +1,24 @@
// This ACL is a very basic example to validate the
// expansion of hosts
{
"Hosts": {
"host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24",
},
"ACLs": [
{
"Action": "accept",
"Users": [
"subnet-1",
"192.168.1.0/24"
],
"Ports": [
"*:22,3389",
"host-1:*",
],
},
],
}

View File

@@ -0,0 +1,26 @@
// This ACL is used to test group expansion
{
"Groups": {
"group:example": [
"testnamespace",
],
},
"Hosts": {
"host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24",
},
"ACLs": [
{
"Action": "accept",
"Users": [
"group:example",
],
"Ports": [
"host-1:*",
],
},
],
}

View File

@@ -0,0 +1,20 @@
// This ACL is used to test namespace expansion
{
"Hosts": {
"host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24",
},
"ACLs": [
{
"Action": "accept",
"Users": [
"testnamespace",
],
"Ports": [
"host-1:*",
],
},
],
}

View File

@@ -0,0 +1,20 @@
// This ACL is used to test the port range expansion
{
"Hosts": {
"host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24",
},
"ACLs": [
{
"Action": "accept",
"Users": [
"subnet-1",
],
"Ports": [
"host-1:5400-5500",
],
},
],
}

View File

@@ -0,0 +1,20 @@
// This ACL is used to test wildcards
{
"Hosts": {
"host-1": "100.100.100.100",
"subnet-1": "100.100.101.100/24",
},
"ACLs": [
{
"Action": "accept",
"Users": [
"*",
],
"Ports": [
"host-1:*",
],
},
],
}

View File

@@ -0,0 +1,125 @@
{
// Declare static groups of users beyond those in the identity service.
"Groups": {
"group:example": [
"user1@example.com",
"user2@example.com",
],
},
// Declare hostname aliases to use in place of IP addresses or subnets.
"Hosts": {
"example-host-1": "100.100.100.100",
"example-host-2": "100.100.101.100/24",
},
// Define who is allowed to use which tags.
"TagOwners": {
// Everyone in the montreal-admins or global-admins group are
// allowed to tag servers as montreal-webserver.
"tag:montreal-webserver": [
"group:montreal-admins",
"group:global-admins",
],
// Only a few admins are allowed to create API servers.
"tag:api-server": [
"group:global-admins",
"example-host-1",
],
},
// Access control lists.
"ACLs": [
// Engineering users, plus the president, can access port 22 (ssh)
// and port 3389 (remote desktop protocol) on all servers, and all
// ports on git-server or ci-server.
{
"Action": "accept",
"Users": [
"group:engineering",
"president@example.com"
],
"Ports": [
"*:22,3389",
"git-server:*",
"ci-server:*"
],
},
// Allow engineer users to access any port on a device tagged with
// tag:production.
{
"Action": "accept",
"Users": [
"group:engineers"
],
"Ports": [
"tag:production:*"
],
},
// Allow servers in the my-subnet host and 192.168.1.0/24 to access hosts
// on both networks.
{
"Action": "accept",
"Users": [
"my-subnet",
"192.168.1.0/24"
],
"Ports": [
"my-subnet:*",
"192.168.1.0/24:*"
],
},
// Allow every user of your network to access anything on the network.
// Comment out this section if you want to define specific ACL
// restrictions above.
{
"Action": "accept",
"Users": [
"*"
],
"Ports": [
"*:*"
],
},
// All users in Montreal are allowed to access the Montreal web
// servers.
{
"Action": "accept",
"Users": [
"group:montreal-users"
],
"Ports": [
"tag:montreal-webserver:80,443"
],
},
// Montreal web servers are allowed to make outgoing connections to
// the API servers, but only on https port 443.
// In contrast, this doesn't grant API servers the right to initiate
// any connections.
{
"Action": "accept",
"Users": [
"tag:montreal-webserver"
],
"Ports": [
"tag:api-server:443"
],
},
],
// Declare tests to check functionality of ACL rules
"Tests": [
{
"User": "user1@example.com",
"Allow": [
"example-host-1:22",
"example-host-2:80"
],
"Deny": [
"exapmle-host-2:100"
],
},
{
"User": "user2@example.com",
"Allow": [
"100.60.3.4:22"
],
},
],
}

1
tests/acls/broken.hujson Normal file
View File

@@ -0,0 +1 @@
{

View File

@@ -0,0 +1,4 @@
{
"valid_json": true,
"but_a_policy_though": false
}

View File

@@ -18,7 +18,8 @@ import (
mathrand "math/rand"
"golang.org/x/crypto/nacl/box"
"tailscale.com/wgengine/wgcfg"
"gorm.io/gorm"
"tailscale.com/types/wgkey"
)
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
@@ -26,11 +27,11 @@ type Error string
func (e Error) Error() string { return string(e) }
func decode(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) error {
func decode(msg []byte, v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) error {
return decodeMsg(msg, v, pubKey, privKey)
}
func decodeMsg(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) error {
func decodeMsg(msg []byte, v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) error {
decrypted, err := decryptMsg(msg, pubKey, privKey)
if err != nil {
return err
@@ -42,7 +43,7 @@ func decodeMsg(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.Priv
return nil
}
func decryptMsg(msg []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
func decryptMsg(msg []byte, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
var nonce [24]byte
if len(msg) < len(nonce)+1 {
return nil, fmt.Errorf("response missing nonce, len=%d", len(msg))
@@ -58,7 +59,7 @@ func decryptMsg(msg []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byt
return decrypted, nil
}
func encode(v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
func encode(v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
b, err := json.Marshal(v)
if err != nil {
return nil, err
@@ -66,7 +67,7 @@ func encode(v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte
return encodeMsg(b, pubKey, privKey)
}
func encodeMsg(b []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
func encodeMsg(b []byte, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
var nonce [24]byte
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
panic(err)
@@ -77,11 +78,6 @@ func encodeMsg(b []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte,
}
func (h *Headscale) getAvailableIP() (*net.IP, error) {
db, err := h.db()
if err != nil {
return nil, err
}
defer db.Close()
i := 0
for {
ip, err := getRandomIP()
@@ -89,7 +85,7 @@ func (h *Headscale) getAvailableIP() (*net.IP, error) {
return nil, err
}
m := Machine{}
if db.First(&m, "ip_address = ?", ip.String()).RecordNotFound() {
if result := h.db.First(&m, "ip_address = ?", ip.String()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return ip, nil
}
i++