diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90c48e92..f1773af9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -33,4 +33,4 @@ jobs: - uses: actions/upload-artifact@v2 with: name: headscale-linux - path: headscale \ No newline at end of file + path: headscale diff --git a/.github/workflows/contributors.yml b/.github/workflows/contributors.yml new file mode 100644 index 00000000..e27b6eda --- /dev/null +++ b/.github/workflows/contributors.yml @@ -0,0 +1,24 @@ +name: Contributors + +on: + push: + branches: + - main + +jobs: + add-contributors: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: BobAnkh/add-contributors@master + with: + CONTRIBUTOR: "## Contributors" + COLUMN_PER_ROW: "6" + ACCESS_TOKEN: ${{secrets.GITHUB_TOKEN}} + IMG_WIDTH: "100" + FONT_SIZE: "14" + PATH: "/README.md" + COMMIT_MESSAGE: "docs(README): update contributors" + AVATAR_SHAPE: "round" + BRANCH: "update-contributors" + PULL_REQUEST: "main" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7a3df6a0..c0605f10 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,20 +4,18 @@ name: release on: push: tags: - - "*" # triggers only if push new tag version + - "*" # triggers only if push new tag version workflow_dispatch: jobs: goreleaser: - runs-on: ubuntu-18.04 # due to CGO we need to user an older version + runs-on: ubuntu-18.04 # due to CGO we need to user an older version steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - - - name: Set up Go + - name: Set up Go uses: actions/setup-go@v2 with: go-version: 1.16 @@ -26,8 +24,7 @@ jobs: run: | sudo apt update sudo apt install -y gcc-aarch64-linux-gnu - - - name: Run GoReleaser + - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 with: distribution: goreleaser @@ -39,13 +36,11 @@ jobs: docker-release: runs-on: ubuntu-latest steps: - - - name: Checkout + - name: Checkout uses: actions/checkout@v2 with: fetch-depth: 0 - - - name: Docker meta + - name: Docker meta id: meta uses: docker/metadata-action@v3 with: @@ -58,21 +53,18 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=sha - - - name: Login to DockerHub + - name: Login to DockerHub uses: docker/login-action@v1 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Login to GHCR + - name: Login to GHCR uses: docker/login-action@v1 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push + - name: Build and push id: docker_build uses: docker/build-push-action@v2 with: diff --git a/.gitignore b/.gitignore index 95d758a7..610550b9 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ /headscale config.json +config.yaml *.key /db.sqlite *.sqlite3 diff --git a/.goreleaser.yml b/.goreleaser.yml index ad4fea70..d20aed6b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -6,7 +6,7 @@ before: builds: - id: darwin-amd64 main: ./cmd/headscale/headscale.go - mod_timestamp: '{{ .CommitTimestamp }}' + mod_timestamp: "{{ .CommitTimestamp }}" goos: - darwin goarch: @@ -23,7 +23,7 @@ builds: - id: linux-armhf main: ./cmd/headscale/headscale.go - mod_timestamp: '{{ .CommitTimestamp }}' + mod_timestamp: "{{ .CommitTimestamp }}" goos: - linux goarch: @@ -42,7 +42,6 @@ builds: ldflags: - -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}} - - id: linux-amd64 env: - CGO_ENABLED=1 @@ -51,7 +50,7 @@ builds: goarch: - amd64 main: ./cmd/headscale/headscale.go - mod_timestamp: '{{ .CommitTimestamp }}' + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}} @@ -64,7 +63,7 @@ builds: - CGO_ENABLED=1 - CC=aarch64-linux-gnu-gcc main: ./cmd/headscale/headscale.go - mod_timestamp: '{{ .CommitTimestamp }}' + mod_timestamp: "{{ .CommitTimestamp }}" ldflags: - -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}} @@ -79,12 +78,12 @@ archives: format: binary checksum: - name_template: 'checksums.txt' + name_template: "checksums.txt" snapshot: name_template: "{{ .Tag }}-next" changelog: sort: asc filters: exclude: - - '^docs:' - - '^test:' + - "^docs:" + - "^test:" diff --git a/Dockerfile b/Dockerfile index 20bb7dae..6e216aad 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,6 +12,11 @@ RUN test -e /go/bin/headscale FROM ubuntu:20.04 +RUN apt-get update \ + && apt-get install -y ca-certificates \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + COPY --from=build /go/bin/headscale /usr/local/bin/headscale ENV TZ UTC diff --git a/README.md b/README.md index b948a40f..a3c09396 100644 --- a/README.md +++ b/README.md @@ -14,14 +14,14 @@ Everything in Tailscale is Open Source, except the GUI clients for proprietary O 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. +headscale implements this coordination server. ## Status - [x] Base functionality (nodes can communicate with each other) - [x] Node registration through the web flow - [x] Network changes are relayed to the nodes -- [x] Namespace support (~equivalent to multi-user in Tailscale.com) +- [x] Namespaces support (~tailnets in Tailscale.com naming) - [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 @@ -29,7 +29,7 @@ Headscale implements this coordination server. - [x] Taildrop (File Sharing) - [x] Support for alternative IP ranges in the tailnets (default Tailscale's 100.64.0.0/10) - [x] DNS (passing DNS servers to nodes) -- [x] Share nodes between ~~users~~ namespaces +- [x] Share nodes between namespaces - [x] MagicDNS (see `docs/`) ## Client OS support @@ -47,227 +47,176 @@ Headscale implements this coordination server. Suggestions/PRs welcomed! -## Running it -1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH or use the docker container +## Running headscale - ```shell - docker pull headscale/headscale:x.x.x - ``` +Please have a look at the documentation under [`docs/`](docs/). - - -2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running - - ```shell - docker run --name headscale -e POSTGRES_DB=headscale -e \ - POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -p 5432:5432 -d postgres - ``` - -3. Set some stuff up (headscale Wireguard keys & the config.json file) - - ```shell - wg genkey > private.key - wg pubkey < private.key > public.key # not needed - - # Postgres - cp config.json.postgres.example config.json - # or - # SQLite - cp config.json.sqlite.example config.json - ``` - -4. Create a namespace (a namespace is a 'tailnet', a group of Tailscale nodes that can talk to each other) - - ```shell - headscale namespaces create myfirstnamespace - ``` - - or docker: - - the db.sqlite mount is only needed if you use sqlite - - ```shell - touch db.sqlite - docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml -v $(pwd)/db.sqlite:/db.sqlite -p 127.0.0.1:8080:8080 headscale/headscale:x.x.x headscale namespaces create myfirstnamespace - ``` - - or if your server is already running in docker: - - ```shell - docker exec headscale create myfirstnamespace - ``` - -5. Run the server - - ```shell - headscale serve - ``` - - or docker: - - the db.sqlite mount is only needed if you use sqlite - - ```shell - docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml -v $(pwd)/db.sqlite:/db.sqlite -p 127.0.0.1:8080:8080 headscale/headscale:x.x.x headscale serve - ``` - -6. If you used tailscale.com before in your nodes, make sure you clear the tailscald 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 - ``` - -8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key. - -9. In the server, register your machine to a namespace with the CLI - ```shell - headscale -n myfirstnamespace nodes register YOURMACHINEKEY - ``` - or docker: - ```shell - docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v $(pwd)/derp.yaml:/derp.yaml headscale/headscale:x.x.x headscale -n myfirstnamespace nodes register YOURMACHINEKEY - ``` - or if your server is already running in docker: - ```shell - docker exec headscale -n myfirstnamespace nodes register YOURMACHINEKEY - ``` - -Alternatively, you can use Auth Keys to register your machines: - -1. Create an authkey - - ```shell - headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h - ``` - - or docker: - - ```shell - docker run -v $(pwd)/private.key:/private.key -v $(pwd)/config.json:/config.json -v$(pwd)/derp.yaml:/derp.yaml -v $(pwd)/db.sqlite:/db.sqlite headscale/headscale:x.x.x headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h - ``` - - or if your server is already running in docker: - - ```shell - docker exec headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h - ``` - -2. Use the authkey from your machine to register it - ```shell - tailscale up --login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY - ``` - -If you create an authkey with the `--ephemeral` flag, that key will create ephemeral nodes. This implies that `--reusable` is true. - -Please bear in mind that all the commands from headscale support adding `-o json` or `-o json-line` to get a nicely JSON-formatted output. - -## Configuration reference - -Headscale's configuration file is named `config.json` or `config.yaml`. Headscale will look for it in `/etc/headscale`, `~/.headscale` and finally the directory from where the Headscale binary is executed. - -``` - "server_url": "http://192.168.1.12:8080", - "listen_addr": "0.0.0.0:8080", - "ip_prefix": "100.64.0.0/10" -``` - -`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on. `ip_prefix` is the IP prefix (range) in which IP addresses for nodes will be allocated (default 100.64.0.0/10, e.g., 192.168.4.0/24, 10.0.0.0/8) - -``` - "log_level": "debug" -``` - -`log_level` can be used to set the Log level for Headscale, it defaults to `debug`, and the available levels are: `trace`, `debug`, `info`, `warn` and `error`. - -``` - "private_key_path": "private.key", -``` - -`private_key_path` is the path to the Wireguard private key. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. - -``` - "derp_map_path": "derp.yaml", -``` - -`derp_map_path` is the path to the [DERP](https://pkg.go.dev/tailscale.com/derp) map file. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. - -``` - "ephemeral_node_inactivity_timeout": "30m", -``` - -`ephemeral_node_inactivity_timeout` is the timeout after which inactive ephemeral node records will be deleted from the database. The default is 30 minutes. This value must be higher than 65 seconds (the keepalive timeout for the HTTP long poll is 60 seconds, plus a few seconds to avoid race conditions). - -``` - "db_host": "localhost", - "db_port": 5432, - "db_name": "headscale", - "db_user": "foo", - "db_pass": "bar", -``` - -The fields starting with `db_` are used for the PostgreSQL connection information. - -### Running the service via TLS (optional) - -``` - "tls_cert_path": "" - "tls_key_path": "" -``` - -Headscale can be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. - -``` - "tls_letsencrypt_hostname": "", - "tls_letsencrypt_listen": ":http", - "tls_letsencrypt_cache_dir": ".cache", - "tls_letsencrypt_challenge_type": "HTTP-01", -``` - -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. - -#### Challenge type HTTP-01 - -The default challenge type `HTTP-01` requires that Headscale is reachable on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. By default, Headscale listens on port 80 on all local IPs for Let's Encrypt automated validation. - -If you need to change the ip and/or port used by Headscale for the Let's Encrypt validation process, set `tls_letsencrypt_listen` to the appropriate value. This can be handy if you are running Headscale as a non-root user (or can't run `setcap`). Keep in mind, however, that Let's Encrypt will _only_ connect to port 80 for the validation callback, so if you change `tls_letsencrypt_listen` you will also need to configure something else (e.g. a firewall rule) to forward the traffic from port 80 to the ip:port combination specified in `tls_letsencrypt_listen`. - -#### Challenge type TLS-ALPN-01 - -Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, Headscale listens on the ip:port combination defined in `listen_addr`. Let's Encrypt will _only_ connect to port 443 for the validation callback, so if `listen_addr` is not set to port 443, something else (e.g. a firewall rule) will be required to forward the traffic from port 443 to the ip:port combination specified in `listen_addr`. - -### 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. - -### Apple devices - -An endpoint with information on how to connect your Apple devices (currently macOS only) is available at `/apple` on your running instance. ## Disclaimer 1. We have nothing to do with Tailscale, or Tailscale Inc. 2. The purpose of writing this was to learn how Tailscale works. -## More on Tailscale -- https://tailscale.com/blog/how-tailscale-works/ -- https://tailscale.com/blog/tailscale-key-management/ -- https://tailscale.com/blog/an-unlikely-database-migration/ +## Contributors + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Juan +
+ Juan Font +
+
+ + Kristoffer +
+ Kristoffer Dalby +
+
+ + Ward +
+ Ward Vandewege +
+
+ + ohdearaugustin/ +
+ ohdearaugustin +
+
+ + Aaron +
+ Aaron Bieber +
+
+ + Paul +
+ Paul Tötterman +
+
+ + Casey +
+ Casey Marshall +
+
+ + Silver +
+ Silver Bullet +
+
+ + thomas/ +
+ thomas +
+
+ + Arthur +
+ Arthur Woimbée +
+
+ + Felix +
+ Felix Kronlage-Dammers +
+
+ + Felix +
+ Felix Yan +
+
+ + Shaanan +
+ Shaanan Cohney +
+
+ + Teteros/ +
+ Teteros +
+
+ + The +
+ The Gitter Badger +
+
+ + Tianon +
+ Tianon Gravi +
+
+ + Tjerk +
+ Tjerk Woudsma +
+
+ + Zakhar +
+ Zakhar Bessarab +
+
+ + derelm/ +
+ derelm +
+
+ + ignoramous/ +
+ ignoramous +
+
+ + zy/ +
+ zy +
+
+ + diff --git a/api.go b/api.go index 6e30cb3a..a31cf529 100644 --- a/api.go +++ b/api.go @@ -82,7 +82,10 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) { now := time.Now().UTC() var m Machine - if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) { + if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey.HexString()); errors.Is( + result.Error, + gorm.ErrRecordNotFound, + ) { log.Info().Str("machine", req.Hostinfo.Hostname).Msg("New machine") m = Machine{ Expiry: &req.Expiry, @@ -270,7 +273,7 @@ func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Ma DNSConfig: dnsConfig, Domain: h.cfg.BaseDomain, PacketFilter: *h.aclRules, - DERPMap: h.cfg.DerpMap, + DERPMap: h.DERPMap, UserProfiles: profiles, } @@ -329,7 +332,13 @@ func (h *Headscale) getMapKeepAliveResponse(mKey wgkey.Key, req tailcfg.MapReque return data, nil } -func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, req tailcfg.RegisterRequest, m Machine) { +func (h *Headscale) handleAuthKey( + c *gin.Context, + db *gorm.DB, + idKey wgkey.Key, + req tailcfg.RegisterRequest, + m Machine, +) { log.Debug(). Str("func", "handleAuthKey"). Str("machine", req.Hostinfo.Hostname). diff --git a/app.go b/app.go index 66e2a306..546eb866 100644 --- a/app.go +++ b/app.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "net/url" "os" "sort" "strings" @@ -28,11 +29,12 @@ type Config struct { ServerURL string Addr string PrivateKeyPath string - DerpMap *tailcfg.DERPMap EphemeralNodeInactivityTimeout time.Duration IPPrefix netaddr.IPPrefix BaseDomain string + DERP DERPConfig + DBtype string DBpath string DBhost string @@ -55,6 +57,13 @@ type Config struct { DNSConfig *tailcfg.DNSConfig } +type DERPConfig struct { + URLs []url.URL + Paths []string + AutoUpdate bool + UpdateFrequency time.Duration +} + // Headscale represents the base app of the service type Headscale struct { cfg Config @@ -65,6 +74,8 @@ type Headscale struct { publicKey *wgkey.Key privateKey *wgkey.Private + DERPMap *tailcfg.DERPMap + aclPolicy *ACLPolicy aclRules *[]tailcfg.FilterRule @@ -114,7 +125,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) { return nil, err } // we might have routes already from Split DNS - if h.cfg.DNSConfig.Routes == nil { + if h.cfg.DNSConfig.Routes == nil { h.cfg.DNSConfig.Routes = make(map[string][]dnstype.Resolver) } for _, d := range magicDNSDomains { @@ -153,11 +164,15 @@ func (h *Headscale) expireEphemeralNodesWorker() { return } for _, m := range *machines { - if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) { + if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && + time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) { log.Info().Str("machine", m.Name).Msg("Ephemeral client removed from database") err = h.db.Unscoped().Delete(m).Error if err != nil { - log.Error().Err(err).Str("machine", m.Name).Msg("🤮 Cannot delete ephemeral machine from the database") + log.Error(). + Err(err). + Str("machine", m.Name). + Msg("🤮 Cannot delete ephemeral machine from the database") } } } @@ -198,6 +213,15 @@ func (h *Headscale) Serve() error { go h.watchForKVUpdates(5000) go h.expireEphemeralNodes(5000) + // Fetch an initial DERP Map before we start serving + h.DERPMap = GetDERPMap(h.cfg.DERP) + + if h.cfg.DERP.AutoUpdate { + derpMapCancelChannel := make(chan struct{}) + defer func() { derpMapCancelChannel <- struct{}{} }() + go h.scheduledDERPMapUpdateWorker(derpMapCancelChannel) + } + s := &http.Server{ Addr: h.cfg.Addr, Handler: r, @@ -273,7 +297,6 @@ func (h *Headscale) getLastStateChange(namespaces ...string) time.Time { times = append(times, lastChange) } - } sort.Slice(times, func(i, j int) bool { @@ -284,7 +307,6 @@ func (h *Headscale) getLastStateChange(namespaces ...string) time.Time { if len(times) == 0 { return time.Now().UTC() - } else { return times[0] } diff --git a/cmd/headscale/cli/utils.go b/cmd/headscale/cli/utils.go index 52c8d043..0768e1eb 100644 --- a/cmd/headscale/cli/utils.go +++ b/cmd/headscale/cli/utils.go @@ -4,7 +4,7 @@ import ( "encoding/json" "errors" "fmt" - "io" + "net/url" "os" "path/filepath" "strings" @@ -13,7 +13,6 @@ import ( "github.com/juanfont/headscale" "github.com/rs/zerolog/log" "github.com/spf13/viper" - "gopkg.in/yaml.v2" "inet.af/netaddr" "tailscale.com/tailcfg" "tailscale.com/types/dnstype" @@ -51,21 +50,26 @@ func LoadConfig(path string) error { // Collect any validation errors and return them all at once var errorText string - if (viper.GetString("tls_letsencrypt_hostname") != "") && ((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) { + if (viper.GetString("tls_letsencrypt_hostname") != "") && + ((viper.GetString("tls_cert_path") != "") || (viper.GetString("tls_key_path") != "")) { errorText += "Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both\n" } - if (viper.GetString("tls_letsencrypt_hostname") != "") && (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) { + if (viper.GetString("tls_letsencrypt_hostname") != "") && + (viper.GetString("tls_letsencrypt_challenge_type") == "TLS-ALPN-01") && + (!strings.HasSuffix(viper.GetString("listen_addr"), ":443")) { // 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.Warn(). Msg("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") { + if (viper.GetString("tls_letsencrypt_challenge_type") != "HTTP-01") && + (viper.GetString("tls_letsencrypt_challenge_type") != "TLS-ALPN-01") { errorText += "Fatal config error: the only supported values for tls_letsencrypt_challenge_type are HTTP-01 and TLS-ALPN-01\n" } - if !strings.HasPrefix(viper.GetString("server_url"), "http://") && !strings.HasPrefix(viper.GetString("server_url"), "https://") { + if !strings.HasPrefix(viper.GetString("server_url"), "http://") && + !strings.HasPrefix(viper.GetString("server_url"), "https://") { errorText += "Fatal config error: server_url must start with https:// or http://\n" } if errorText != "" { @@ -73,7 +77,35 @@ func LoadConfig(path string) error { } else { return nil } +} +func GetDERPConfig() headscale.DERPConfig { + urlStrs := viper.GetStringSlice("derp.urls") + + urls := make([]url.URL, len(urlStrs)) + for index, urlStr := range urlStrs { + urlAddr, err := url.Parse(urlStr) + if err != nil { + log.Error(). + Str("url", urlStr). + Err(err). + Msg("Failed to parse url, ignoring...") + } + + urls[index] = *urlAddr + } + + paths := viper.GetStringSlice("derp.paths") + + autoUpdate := viper.GetBool("derp.auto_update_enabled") + updateFrequency := viper.GetDuration("derp.update_frequency") + + return headscale.DERPConfig{ + URLs: urls, + Paths: paths, + AutoUpdate: autoUpdate, + UpdateFrequency: updateFrequency, + } } func GetDNSConfig() (*tailcfg.DNSConfig, string) { @@ -171,33 +203,30 @@ func absPath(path string) string { } func getHeadscaleApp() (*headscale.Headscale, error) { - derpPath := absPath(viper.GetString("derp_map_path")) - derpMap, err := loadDerpMap(derpPath) - if err != nil { - log.Error(). - Str("path", derpPath). - Err(err). - Msg("Could not load DERP servers map file") - } - // Minimum inactivity time out is keepalive timeout (60s) plus a few seconds // to avoid races minInactivityTimeout, _ := time.ParseDuration("65s") if viper.GetDuration("ephemeral_node_inactivity_timeout") <= minInactivityTimeout { - err = fmt.Errorf("ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s\n", viper.GetString("ephemeral_node_inactivity_timeout"), minInactivityTimeout) + err := fmt.Errorf( + "ephemeral_node_inactivity_timeout (%s) is set too low, must be more than %s\n", + viper.GetString("ephemeral_node_inactivity_timeout"), + minInactivityTimeout, + ) return nil, err } dnsConfig, baseDomain := GetDNSConfig() + derpConfig := GetDERPConfig() cfg := headscale.Config{ ServerURL: viper.GetString("server_url"), Addr: viper.GetString("listen_addr"), PrivateKeyPath: absPath(viper.GetString("private_key_path")), - DerpMap: derpMap, IPPrefix: netaddr.MustParseIPPrefix(viper.GetString("ip_prefix")), BaseDomain: baseDomain, + DERP: derpConfig, + EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"), DBtype: viper.GetString("db_type"), @@ -243,21 +272,6 @@ func getHeadscaleApp() (*headscale.Headscale, error) { return h, nil } -func loadDerpMap(path string) (*tailcfg.DERPMap, error) { - derpFile, err := os.Open(path) - if err != nil { - return nil, err - } - defer derpFile.Close() - var derpMap tailcfg.DERPMap - b, err := io.ReadAll(derpFile) - if err != nil { - return nil, err - } - err = yaml.Unmarshal(b, &derpMap) - return &derpMap, err -} - func JsonOutput(result interface{}, errResult error, outputFormat string) { var j []byte var err error diff --git a/cmd/headscale/headscale_test.go b/cmd/headscale/headscale_test.go index bddea94c..e3a5713f 100644 --- a/cmd/headscale/headscale_test.go +++ b/cmd/headscale/headscale_test.go @@ -25,10 +25,9 @@ func (s *Suite) SetUpSuite(c *check.C) { } func (s *Suite) TearDownSuite(c *check.C) { - } -func (*Suite) TestPostgresConfigLoading(c *check.C) { +func (*Suite) TestConfigLoading(c *check.C) { tmpDir, err := ioutil.TempDir("", "headscale") if err != nil { c.Fatal(err) @@ -41,7 +40,7 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) { } // Symlink the example config file - err = os.Symlink(filepath.Clean(path+"/../../config.json.postgres.example"), filepath.Join(tmpDir, "config.json")) + err = os.Symlink(filepath.Clean(path+"/../../config-example.yaml"), filepath.Join(tmpDir, "config.yaml")) if err != nil { c.Fatal(err) } @@ -53,40 +52,7 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) { // Test that config file was interpreted correctly c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080") c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8080") - c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml") - c.Assert(viper.GetString("db_type"), check.Equals, "postgres") - c.Assert(viper.GetString("db_port"), check.Equals, "5432") - c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") - c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http") - c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1") -} - -func (*Suite) TestSqliteConfigLoading(c *check.C) { - tmpDir, err := ioutil.TempDir("", "headscale") - if err != nil { - c.Fatal(err) - } - defer os.RemoveAll(tmpDir) - - path, err := os.Getwd() - if err != nil { - c.Fatal(err) - } - - // Symlink the example config file - err = os.Symlink(filepath.Clean(path+"/../../config.json.sqlite.example"), filepath.Join(tmpDir, "config.json")) - if err != nil { - c.Fatal(err) - } - - // Load example config, it should load without validation errors - err = cli.LoadConfig(tmpDir) - c.Assert(err, check.IsNil) - - // Test that config file was interpreted correctly - c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8080") - c.Assert(viper.GetString("listen_addr"), check.Equals, "0.0.0.0:8080") - c.Assert(viper.GetString("derp_map_path"), check.Equals, "derp.yaml") + c.Assert(viper.GetStringSlice("derp.paths")[0], check.Equals, "derp-example.yaml") c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3") c.Assert(viper.GetString("db_path"), check.Equals, "db.sqlite") c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") @@ -108,7 +74,7 @@ func (*Suite) TestDNSConfigLoading(c *check.C) { } // Symlink the example config file - err = os.Symlink(filepath.Clean(path+"/../../config.json.sqlite.example"), filepath.Join(tmpDir, "config.json")) + err = os.Symlink(filepath.Clean(path+"/../../config-example.yaml"), filepath.Join(tmpDir, "config.yaml")) if err != nil { c.Fatal(err) } @@ -128,7 +94,7 @@ func (*Suite) TestDNSConfigLoading(c *check.C) { func writeConfig(c *check.C, tmpDir string, configYaml []byte) { // Populate a custom config file configFile := filepath.Join(tmpDir, "config.yaml") - err := ioutil.WriteFile(configFile, configYaml, 0644) + err := ioutil.WriteFile(configFile, configYaml, 0o644) if err != nil { c.Fatalf("Couldn't write file %s", configFile) } @@ -139,10 +105,12 @@ func (*Suite) TestTLSConfigValidation(c *check.C) { if err != nil { c.Fatal(err) } - //defer os.RemoveAll(tmpDir) + // defer os.RemoveAll(tmpDir) fmt.Println(tmpDir) - configYaml := []byte("---\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"\"\ntls_cert_path: \"abc.pem\"") + configYaml := []byte( + "---\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"\"\ntls_cert_path: \"abc.pem\"", + ) writeConfig(c, tmpDir, configYaml) // Check configuration validation errors (1) @@ -150,13 +118,23 @@ func (*Suite) TestTLSConfigValidation(c *check.C) { c.Assert(err, check.NotNil) // check.Matches can not handle multiline strings tmp := strings.ReplaceAll(err.Error(), "\n", "***") - c.Assert(tmp, check.Matches, ".*Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both.*") - c.Assert(tmp, check.Matches, ".*Fatal config error: the only supported values for tls_letsencrypt_challenge_type are.*") + c.Assert( + tmp, + check.Matches, + ".*Fatal config error: set either tls_letsencrypt_hostname or tls_cert_path/tls_key_path, not both.*", + ) + c.Assert( + tmp, + check.Matches, + ".*Fatal config error: the only supported values for tls_letsencrypt_challenge_type are.*", + ) c.Assert(tmp, check.Matches, ".*Fatal config error: server_url must start with https:// or http://.*") fmt.Println(tmp) // Check configuration validation errors (2) - configYaml = []byte("---\nserver_url: \"http://127.0.0.1:8080\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"") + configYaml = []byte( + "---\nserver_url: \"http://127.0.0.1:8080\"\ntls_letsencrypt_hostname: \"example.com\"\ntls_letsencrypt_challenge_type: \"TLS-ALPN-01\"", + ) writeConfig(c, tmpDir, configYaml) err = cli.LoadConfig(tmpDir) c.Assert(err, check.IsNil) diff --git a/config-example.yaml b/config-example.yaml new file mode 100644 index 00000000..59370eb5 --- /dev/null +++ b/config-example.yaml @@ -0,0 +1,66 @@ +--- +# The url clients will connect to. +# Typically this will be a domain. +server_url: http://127.0.0.1:8080 + +# Address to listen to / bind to on the server +listen_addr: 0.0.0.0:8080 + +# Path to WireGuard private key file +private_key_path: private.key + +derp: + # List of externally available DERP maps encoded in JSON + urls: + - https://controlplane.tailscale.com/derpmap/default + + # Locally available DERP map files encoded in YAML + paths: + - derp-example.yaml + + # If enabled, a worker will be set up to periodically + # refresh the given sources and update the derpmap + # will be set up. + auto_update_enabled: true + + # How often should we check for updates? + update_frequency: 24h + +# Disables the automatic check for updates on startup +disable_check_updates: false +ephemeral_node_inactivity_timeout: 30m + +# SQLite config +db_type: sqlite3 +db_path: db.sqlite + +# # Postgres config +# db_type: postgres +# db_host: localhost +# db_port: 5432 +# db_name: headscale +# db_user: foo +# db_pass: bar + +acme_url: https://acme-v02.api.letsencrypt.org/directory +acme_email: "" + +tls_letsencrypt_hostname: "" +tls_letsencrypt_listen: ":http" +tls_letsencrypt_cache_dir: ".cache" +tls_letsencrypt_challenge_type: HTTP-01 + +tls_cert_path: "" +tls_key_path: "" + +# Path to a file containg ACL policies. +acl_policy_path: "" + +dns_config: + # Upstream DNS servers + nameservers: + - 1.1.1.1 + domains: [] + + magic_dns: true + base_domain: example.com diff --git a/config.json.postgres.example b/config.json.postgres.example deleted file mode 100644 index 9b6f737f..00000000 --- a/config.json.postgres.example +++ /dev/null @@ -1,30 +0,0 @@ -{ - "server_url": "http://127.0.0.1:8080", - "listen_addr": "0.0.0.0:8080", - "private_key_path": "private.key", - "derp_map_path": "derp.yaml", - "ephemeral_node_inactivity_timeout": "30m", - "db_type": "postgres", - "db_host": "localhost", - "db_port": 5432, - "db_name": "headscale", - "db_user": "foo", - "db_pass": "bar", - "acme_url": "https://acme-v02.api.letsencrypt.org/directory", - "acme_email": "", - "tls_letsencrypt_hostname": "", - "tls_letsencrypt_listen": ":http", - "tls_letsencrypt_cache_dir": ".cache", - "tls_letsencrypt_challenge_type": "HTTP-01", - "tls_cert_path": "", - "tls_key_path": "", - "acl_policy_path": "", - "dns_config": { - "nameservers": [ - "1.1.1.1" - ], - "domains": [], - "magic_dns": true, - "base_domain": "example.com" - } -} diff --git a/config.json.sqlite.example b/config.json.sqlite.example deleted file mode 100644 index 74e15902..00000000 --- a/config.json.sqlite.example +++ /dev/null @@ -1,26 +0,0 @@ -{ - "server_url": "http://127.0.0.1:8080", - "listen_addr": "0.0.0.0:8080", - "private_key_path": "private.key", - "derp_map_path": "derp.yaml", - "ephemeral_node_inactivity_timeout": "30m", - "db_type": "sqlite3", - "db_path": "db.sqlite", - "acme_url": "https://acme-v02.api.letsencrypt.org/directory", - "acme_email": "", - "tls_letsencrypt_hostname": "", - "tls_letsencrypt_listen": ":http", - "tls_letsencrypt_cache_dir": ".cache", - "tls_letsencrypt_challenge_type": "HTTP-01", - "tls_cert_path": "", - "tls_key_path": "", - "acl_policy_path": "", - "dns_config": { - "nameservers": [ - "1.1.1.1" - ], - "domains": [], - "magic_dns": true, - "base_domain": "example.com" - } -} diff --git a/derp-example.yaml b/derp-example.yaml new file mode 100644 index 00000000..bbf7cc8d --- /dev/null +++ b/derp-example.yaml @@ -0,0 +1,15 @@ +# If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/ +regions: + 900: + regionid: 900 + regioncode: custom + regionname: My Region + nodes: + - name: 1a + regionid: 1 + hostname: myderp.mydomain.no + ipv4: 123.123.123.123 + ipv6: "2604:a880:400:d1::828:b001" + stunport: 0 + stunonly: false + derptestport: 0 diff --git a/derp.go b/derp.go new file mode 100644 index 00000000..39e63210 --- /dev/null +++ b/derp.go @@ -0,0 +1,152 @@ +package headscale + +import ( + "encoding/json" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "time" + + "github.com/rs/zerolog/log" + + "gopkg.in/yaml.v2" + + "tailscale.com/tailcfg" +) + +func loadDERPMapFromPath(path string) (*tailcfg.DERPMap, error) { + derpFile, err := os.Open(path) + if err != nil { + return nil, err + } + defer derpFile.Close() + var derpMap tailcfg.DERPMap + b, err := io.ReadAll(derpFile) + if err != nil { + return nil, err + } + err = yaml.Unmarshal(b, &derpMap) + return &derpMap, err +} + +func loadDERPMapFromURL(addr url.URL) (*tailcfg.DERPMap, error) { + client := http.Client{ + Timeout: 10 * time.Second, + } + resp, err := client.Get(addr.String()) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var derpMap tailcfg.DERPMap + err = json.Unmarshal(body, &derpMap) + return &derpMap, err +} + +// mergeDERPMaps naively merges a list of DERPMaps into a single +// DERPMap, it will _only_ look at the Regions, an integer. +// If a region exists in two of the given DERPMaps, the region +// form the _last_ DERPMap will be preserved. +// An empty DERPMap list will result in a DERPMap with no regions +func mergeDERPMaps(derpMaps []*tailcfg.DERPMap) *tailcfg.DERPMap { + result := tailcfg.DERPMap{ + OmitDefaultRegions: false, + Regions: map[int]*tailcfg.DERPRegion{}, + } + + for _, derpMap := range derpMaps { + for id, region := range derpMap.Regions { + result.Regions[id] = region + } + } + + return &result +} + +func GetDERPMap(cfg DERPConfig) *tailcfg.DERPMap { + derpMaps := make([]*tailcfg.DERPMap, 0) + + for _, path := range cfg.Paths { + log.Debug(). + Str("func", "GetDERPMap"). + Str("path", path). + Msg("Loading DERPMap from path") + derpMap, err := loadDERPMapFromPath(path) + if err != nil { + log.Error(). + Str("func", "GetDERPMap"). + Str("path", path). + Err(err). + Msg("Could not load DERP map from path") + break + } + + derpMaps = append(derpMaps, derpMap) + } + + for _, addr := range cfg.URLs { + derpMap, err := loadDERPMapFromURL(addr) + log.Debug(). + Str("func", "GetDERPMap"). + Str("url", addr.String()). + Msg("Loading DERPMap from path") + if err != nil { + log.Error(). + Str("func", "GetDERPMap"). + Str("url", addr.String()). + Err(err). + Msg("Could not load DERP map from path") + break + } + + derpMaps = append(derpMaps, derpMap) + } + + derpMap := mergeDERPMaps(derpMaps) + + log.Trace().Interface("derpMap", derpMap).Msg("DERPMap loaded") + + if len(derpMap.Regions) == 0 { + log.Warn(). + Msg("DERP map is empty, not a single DERP map datasource was loaded correctly or contained a region") + } + + return derpMap +} + +func (h *Headscale) scheduledDERPMapUpdateWorker(cancelChan <-chan struct{}) { + log.Info(). + Dur("frequency", h.cfg.DERP.UpdateFrequency). + Msg("Setting up a DERPMap update worker") + ticker := time.NewTicker(h.cfg.DERP.UpdateFrequency) + + for { + select { + case <-cancelChan: + return + + case <-ticker.C: + log.Info().Msg("Fetching DERPMap updates") + h.DERPMap = GetDERPMap(h.cfg.DERP) + + namespaces, err := h.ListNamespaces() + if err != nil { + log.Error(). + Err(err). + Msg("Failed to fetch namespaces") + } + + for _, namespace := range *namespaces { + h.setLastStateChangeToNow(namespace.Name) + } + } + } +} diff --git a/derp.yaml b/derp.yaml deleted file mode 100644 index 9434e712..00000000 --- a/derp.yaml +++ /dev/null @@ -1,146 +0,0 @@ -# This file contains some of the official Tailscale DERP servers, -# 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: https://tailscale.com/kb/1118/custom-derp-servers/ -regions: - 1: - regionid: 1 - regioncode: nyc - regionname: New York City - nodes: - - name: 1a - regionid: 1 - hostname: derp1.tailscale.com - ipv4: 159.89.225.99 - ipv6: "2604:a880:400:d1::828:b001" - 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 - regionname: San Francisco - nodes: - - name: 2a - regionid: 2 - hostname: derp2.tailscale.com - ipv4: 167.172.206.31 - ipv6: "2604:a880:2:d1::c5:7001" - 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 - regionname: Singapore - nodes: - - name: 3a - regionid: 3 - hostname: derp3.tailscale.com - ipv4: 68.183.179.66 - ipv6: "2400:6180:0:d1::67d:8001" - stunport: 0 - stunonly: false - derptestport: 0 - 4: - regionid: 4 - regioncode: fra - regionname: Frankfurt - nodes: - - name: 4a - regionid: 4 - hostname: derp4.tailscale.com - ipv4: 167.172.182.26 - ipv6: "2a03:b0c0:3:e0::36e:900" - stunport: 0 - stunonly: false - 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 diff --git a/docs/Configuration.md b/docs/Configuration.md new file mode 100644 index 00000000..fa766428 --- /dev/null +++ b/docs/Configuration.md @@ -0,0 +1,80 @@ +# Configuration reference + +Headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order: + +- `/etc/headscale` +- `~/.headscale` +- current working directory + +```yaml +server_url: http://headscale.mydomain.net +listen_addr: 0.0.0.0:8080 +ip_prefix: 100.64.0.0/10 +disable_check_updates: false +``` + +`server_url` is the external URL via which Headscale is reachable. `listen_addr` is the IP address and port the Headscale program should listen on. `ip_prefix` is the IP prefix (range) in which IP addresses for nodes will be allocated (default 100.64.0.0/10, e.g., 192.168.4.0/24, 10.0.0.0/8). `disable_check_updates` disables the automatic check for updates. + +```yaml +log_level: debug +``` + +`log_level` can be used to set the Log level for Headscale, it defaults to `debug`, and the available levels are: `trace`, `debug`, `info`, `warn` and `error`. + +```yaml +private_key_path: private.key +``` + +`private_key_path` is the path to the Wireguard private key. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +```yaml +derp_map_path: derp.yaml +``` + +`derp_map_path` is the path to the [DERP](https://pkg.go.dev/tailscale.com/derp) map file. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +```yaml +ephemeral_node_inactivity_timeout": "30m" +``` + +`ephemeral_node_inactivity_timeout` is the timeout after which inactive ephemeral node records will be deleted from the database. The default is 30 minutes. This value must be higher than 65 seconds (the keepalive timeout for the HTTP long poll is 60 seconds, plus a few seconds to avoid race conditions). + +PostgresSQL + +```yaml +db_host: localhost +db_port: 5432 +db_name: headscale +db_user: foo +db_pass: bar +``` + +SQLite + +```yaml +db_type: sqlite3 +db_path: db.sqlite +``` + +The fields starting with `db_` are used for the DB connection information. + +### TLS configuration + +Please check [`TLS.md`](TLS.md). + +### DNS configuration + +Please refer to [`DNS.md`](DNS.md). + +### 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. + +### Apple devices + +An endpoint with information on how to connect your Apple devices (currently macOS only) is available at `/apple` on your running instance. diff --git a/docs/DNS.md b/docs/DNS.md index 10f99b79..e51feaf6 100644 --- a/docs/DNS.md +++ b/docs/DNS.md @@ -1,17 +1,16 @@ -# DNS in Headscale +# DNS in headscale -Headscale supports Tailscale's DNS configuration and MagicDNS. Please have a look to their KB to better understand what this means: +headscale supports Tailscale's DNS configuration and MagicDNS. Please have a look to their KB to better understand what this means: - https://tailscale.com/kb/1054/dns/ - https://tailscale.com/kb/1081/magicdns/ - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ -Long story short, you can define the DNS servers you want to use in your tailnets, activate MagicDNS (so you don't have to remember the IP addresses of your nodes), define search domains, as well as predefined hosts. Headscale will inject that settings into your nodes. - +Long story short, you can define the DNS servers you want to use in your tailnets, activate MagicDNS (so you don't have to remember the IP addresses of your nodes), define search domains, as well as predefined hosts. headscale will inject that settings into your nodes. ## Configuration reference -The setup is done via the `config.yaml` file, under the `dns_config` key. +The setup is done via the `config.yaml` file, under the `dns_config` key. ```yaml server_url: http://127.0.0.1:8001 @@ -19,21 +18,21 @@ listen_addr: 0.0.0.0:8001 private_key_path: private.key dns_config: nameservers: - - 1.1.1.1 - - 8.8.8.8 - restricted_nameservers: - foo.bar.com: - - 1.1.1.1 - darp.headscale.net: - 1.1.1.1 - 8.8.8.8 + restricted_nameservers: + foo.bar.com: + - 1.1.1.1 + darp.headscale.net: + - 1.1.1.1 + - 8.8.8.8 domains: [] magic_dns: true base_domain: example.com ``` -- `nameservers`: The list of DNS servers to use. +- `nameservers`: The list of DNS servers to use. - `domains`: Search domains to inject. - `magic_dns`: Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/). Only works if there is at least a nameserver defined. - `base_domain`: Defines the base domain to create the hostnames for MagicDNS. `base_domain` must be a FQDNs, without the trailing dot. The FQDN of the hosts will be `hostname.namespace.base_domain` (e.g., _myhost.mynamespace.example.com_). -- `restricted_nameservers`: Split DNS (see https://tailscale.com/kb/1054/dns/), list of search domains and the DNS to query for each one. \ No newline at end of file +- `restricted_nameservers`: Split DNS (see https://tailscale.com/kb/1054/dns/), list of search domains and the DNS to query for each one. diff --git a/docs/Glossary.md b/docs/Glossary.md new file mode 100644 index 00000000..aa3e2e35 --- /dev/null +++ b/docs/Glossary.md @@ -0,0 +1,3 @@ +# Glossary + +- Namespace: Collection of Tailscale nodes that can see each other. In Tailscale.com this is called Tailnet. diff --git a/docs/Running.md b/docs/Running.md new file mode 100644 index 00000000..08373653 --- /dev/null +++ b/docs/Running.md @@ -0,0 +1,149 @@ +# Running headscale + +1. Download the headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your $PATH or use the docker container + + ```shell + docker pull headscale/headscale:x.x.x + ``` + + + +2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running + + ```shell + docker run --name headscale \ + -e POSTGRES_DB=headscale + -e POSTGRES_USER=foo \ + -e POSTGRES_PASSWORD=bar \ + -p 5432:5432 \ + -d postgres + ``` + +3. Create a WireGuard private key and headscale configuration + + ```shell + wg genkey > private.key + + cp config.yaml.example config.yaml + ``` + +4. Create a namespace + + ```shell + headscale namespaces create myfirstnamespace + ``` + + or docker: + + the db.sqlite mount is only needed if you use sqlite + + ```shell + touch db.sqlite + docker run \ + -v $(pwd)/private.key:/private.key \ + -v $(pwd)/config.json:/config.json \ + -v $(pwd)/derp.yaml:/derp.yaml \ + -v $(pwd)/db.sqlite:/db.sqlite \ + -p 127.0.0.1:8080:8080 \ + headscale/headscale:x.x.x \ + headscale namespaces create myfirstnamespace + ``` + + or if your server is already running in docker: + + ```shell + docker exec headscale create myfirstnamespace + ``` + +5. Run the server + + ```shell + headscale serve + ``` + + or docker: + + the db.sqlite mount is only needed if you use sqlite + + ```shell + docker run \ + -v $(pwd)/private.key:/private.key \ + -v $(pwd)/config.json:/config.json \ + -v $(pwd)/derp.yaml:/derp.yaml \ + -v $(pwd)/db.sqlite:/db.sqlite \ + -p 127.0.0.1:8080:8080 \ + headscale/headscale:x.x.x headscale serve + ``` + +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 + ``` + +8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key. + +9. In the server, register your machine to a namespace with the CLI + ```shell + headscale -n myfirstnamespace nodes register YOURMACHINEKEY + ``` + or docker: + ```shell + docker run \ + -v $(pwd)/private.key:/private.key \ + -v $(pwd)/config.json:/config.json \ + -v $(pwd)/derp.yaml:/derp.yaml \ + headscale/headscale:x.x.x \ + headscale -n myfirstnamespace nodes register YOURMACHINEKEY + ``` + or if your server is already running in docker: + ```shell + docker exec headscale -n myfirstnamespace nodes register YOURMACHINEKEY + ``` + +Alternatively, you can use Auth Keys to register your machines: + +1. Create an authkey + + ```shell + headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h + ``` + + or docker: + + ```shell + docker run \ + -v $(pwd)/private.key:/private.key \ + -v $(pwd)/config.json:/config.json \ + -v$(pwd)/derp.yaml:/derp.yaml \ + -v $(pwd)/db.sqlite:/db.sqlite \ + headscale/headscale:x.x.x \ + headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h + ``` + + or if your server is already running in docker: + + ```shell + docker exec headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h + ``` + +2. Use the authkey from your machine to register it + ```shell + tailscale up --login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY + ``` + +If you create an authkey with the `--ephemeral` flag, that key will create ephemeral nodes. This implies that `--reusable` is true. + +Please bear in mind that all headscale commands support adding `-o json` or `-o json-line` to get nicely JSON-formatted output. diff --git a/docs/TLS.md b/docs/TLS.md new file mode 100644 index 00000000..47de1cd7 --- /dev/null +++ b/docs/TLS.md @@ -0,0 +1,27 @@ +# Running the service via TLS (optional) + +```yaml +tls_letsencrypt_hostname: "" +tls_letsencrypt_listen: ":http" +tls_letsencrypt_cache_dir: ".cache" +tls_letsencrypt_challenge_type: HTTP-01 +``` + +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. + +```yaml +tls_cert_path: "" +tls_key_path: "" +``` + +headscale can also be configured to expose its web service via TLS. To configure the certificate and key file manually, set the `tls_cert_path` and `tls_cert_path` configuration parameters. If the path is relative, it will be interpreted as relative to the directory the configuration file was read from. + +## Challenge type HTTP-01 + +The default challenge type `HTTP-01` requires that headscale is reachable on port 80 for the Let's Encrypt automated validation, in addition to whatever port is configured in `listen_addr`. By default, headscale listens on port 80 on all local IPs for Let's Encrypt automated validation. + +If you need to change the ip and/or port used by headscale for the Let's Encrypt validation process, set `tls_letsencrypt_listen` to the appropriate value. This can be handy if you are running headscale as a non-root user (or can't run `setcap`). Keep in mind, however, that Let's Encrypt will _only_ connect to port 80 for the validation callback, so if you change `tls_letsencrypt_listen` you will also need to configure something else (e.g. a firewall rule) to forward the traffic from port 80 to the ip:port combination specified in `tls_letsencrypt_listen`. + +## Challenge type TLS-ALPN-01 + +Alternatively, `tls_letsencrypt_challenge_type` can be set to `TLS-ALPN-01`. In this configuration, headscale listens on the ip:port combination defined in `listen_addr`. Let's Encrypt will _only_ connect to port 443 for the validation callback, so if `listen_addr` is not set to port 443, something else (e.g. a firewall rule) will be required to forward the traffic from port 443 to the ip:port combination specified in `listen_addr`. diff --git a/integration_test.go b/integration_test.go index 3c51215d..53092423 100644 --- a/integration_test.go +++ b/integration_test.go @@ -230,7 +230,6 @@ func (s *IntegrationTestSuite) SetupSuite() { Name: "headscale", Mounts: []string{ fmt.Sprintf("%s/integration_test/etc:/etc/headscale", currentPath), - fmt.Sprintf("%s/derp.yaml:/etc/headscale/derp.yaml", currentPath), }, Networks: []*dockertest.Network{&network}, Cmd: []string{"headscale", "serve"}, @@ -289,7 +288,16 @@ func (s *IntegrationTestSuite) SetupSuite() { fmt.Printf("Creating pre auth key for %s\n", namespace) authKey, err := executeCommand( &headscale, - []string{"headscale", "--namespace", namespace, "preauthkeys", "create", "--reusable", "--expiration", "24h"}, + []string{ + "headscale", + "--namespace", + namespace, + "preauthkeys", + "create", + "--reusable", + "--expiration", + "24h", + }, []string{}, ) assert.Nil(s.T(), err) @@ -298,7 +306,16 @@ func (s *IntegrationTestSuite) SetupSuite() { fmt.Printf("Joining tailscale containers to headscale at %s\n", headscaleEndpoint) for hostname, tailscale := range scales.tailscales { - command := []string{"tailscale", "up", "-login-server", headscaleEndpoint, "--authkey", strings.TrimSuffix(authKey, "\n"), "--hostname", hostname} + command := []string{ + "tailscale", + "up", + "-login-server", + headscaleEndpoint, + "--authkey", + strings.TrimSuffix(authKey, "\n"), + "--hostname", + hostname, + } fmt.Println("Join command:", command) fmt.Printf("Running join command for %s\n", hostname) @@ -661,7 +678,13 @@ func (s *IntegrationTestSuite) TestMagicDNS() { fmt.Sprintf("%s.%s.headscale.net", peername, namespace), } - fmt.Printf("Pinging using Hostname (magicdns) from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip) + fmt.Printf( + "Pinging using Hostname (magicdns) from %s (%s) to %s (%s)\n", + hostname, + ips[hostname], + peername, + ip, + ) result, err := executeCommand( &tailscale, command, diff --git a/integration_test/etc/config.json b/integration_test/etc/config.json deleted file mode 100644 index dc23652d..00000000 --- a/integration_test/etc/config.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "server_url": "http://headscale:8080", - "listen_addr": "0.0.0.0:8080", - "private_key_path": "private.key", - "derp_map_path": "derp.yaml", - "ephemeral_node_inactivity_timeout": "30m", - "db_type": "sqlite3", - "db_path": "/tmp/integration_test_db.sqlite3", - "acl_policy_path": "", - "log_level": "trace", - "dns_config": { - "nameservers": [ - "1.1.1.1" - ], - "domains": [], - "magic_dns": true, - "base_domain": "headscale.net" - } -} \ No newline at end of file diff --git a/integration_test/etc/config.yaml b/integration_test/etc/config.yaml new file mode 100644 index 00000000..6f68f304 --- /dev/null +++ b/integration_test/etc/config.yaml @@ -0,0 +1,20 @@ +log_level: trace +acl_policy_path: "" +db_type: sqlite3 +ephemeral_node_inactivity_timeout: 30m +dns_config: + base_domain: headscale.net + magic_dns: true + domains: [] + nameservers: + - 1.1.1.1 +db_path: /tmp/integration_test_db.sqlite3 +private_key_path: private.key +listen_addr: 0.0.0.0:8080 +server_url: http://headscale:8080 + +derp: + urls: + - https://controlplane.tailscale.com/derpmap/default + auto_update_enabled: false + update_frequency: 1m diff --git a/k8s/README.md b/k8s/README.md index 2f187abb..45574b48 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -1,7 +1,7 @@ -# Deploying Headscale on Kubernetes +# Deploying headscale on Kubernetes This directory contains [Kustomize](https://kustomize.io) templates that deploy -Headscale in various configurations. +headscale in various configurations. These templates currently support Rancher k3s. Other clusters may require adaptation, especially around volume claims and ingress. @@ -72,10 +72,10 @@ Usage: 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 + 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.