Compare commits

..

441 Commits

Author SHA1 Message Date
Juan Font
03d97c3872 Merge pull request #183 from juanfont/split-dns
Add support for Split DNS (Restricted Nameservers)
2021-10-20 10:53:52 +02:00
Juan Font
41c5a0ddf5 Apply suggestions from code review
Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-10-20 09:35:56 +02:00
Juan Font
19165a40d2 Merge branch 'main' into split-dns 2021-10-20 00:19:34 +02:00
Juan Font Alonso
18b00b5d8d Add support for Split DNS (implements #179) 2021-10-19 20:51:43 +02:00
Juan Font
d2a162e3ee Merge pull request #178 from cure/refactor-sharing-tests
Apply some DRY to the sharing tests.
2021-10-19 18:45:37 +02:00
Ward Vandewege
d35f5fe498 Apply some DRY to the sharing tests. 2021-10-18 17:52:38 -04:00
Juan Font
9e1253ada1 Merge pull request #177 from cure/cli-unshare-node
Cli unshare node
2021-10-18 12:51:53 +02:00
Juan Font
244e79f575 Merge branch 'main' into cli-unshare-node 2021-10-18 12:34:13 +02:00
Juan Font
b4e6a32b4b Merge pull request #176 from cure/fix-sharing-check
Bugfix: the check to see if a node was already shared into a namespace
2021-10-18 12:34:04 +02:00
Juan Font
023cd8f4cd Merge branch 'main' into fix-sharing-check 2021-10-18 12:20:43 +02:00
Juan Font
10d24e64cd Merge pull request #174 from juanfont/fix-magic-dns-base-domain
Fix MagicDNS base domain
2021-10-18 12:16:07 +02:00
Juan Font Alonso
37e191a75d Solved merge 2021-10-17 23:59:44 +02:00
Juan Font Alonso
01a5fe3c51 Added tests, solved some bugs, and code reorg 2021-10-17 23:58:09 +02:00
Ward Vandewege
9e3339b4f1 Add cli support for unsharing a node from a namespace. 2021-10-17 16:29:46 -04:00
Ward Vandewege
b06e34f144 Bugfix: the check to see if a node was already shared into a namespace
was incorrect.
2021-10-17 15:53:39 -04:00
Kristoffer Dalby
ddf042cab1 Merge branch 'main' into fix-magic-dns-base-domain 2021-10-17 13:23:21 +01:00
Juan Font Alonso
687e8d12be Do not use the full application for getMapResponseDNSConfig 2021-10-17 12:10:03 +02:00
Juan Font Alonso
01f755ecf9 Send UserProfile info for the peers' namespaces 2021-10-17 12:07:01 +02:00
Juan Font Alonso
8094e6fdef Preload the Namespace from SharedMachines 2021-10-17 11:59:08 +02:00
Juan Font Alonso
061efa1822 Do not include BaseDomain as full route in DNSConfig + code reorg 2021-10-17 11:57:53 +02:00
Juan Font
9a7472218e Merge pull request #172 from cure/rename-namespaces
Rename namespaces
2021-10-17 00:30:36 +02:00
Ward Vandewege
7dcf4a5147 Add support for renaming namespaces. 2021-10-16 11:20:06 -04:00
Ward Vandewege
306a80cf57 Bugfix: when namespace destruction causes a database error, return the
error, not nil.
2021-10-16 11:14:37 -04:00
Juan Font
a9a1a8fb3c Merge pull request #171 from juanfont/force-flag
Added --force flag on node delete
2021-10-16 14:21:35 +02:00
Juan Font
85ddc0db33 Merge branch 'main' into force-flag 2021-10-16 14:09:59 +02:00
Juan Font
fddc2aa8fa Merge pull request #150 from juanfont/fix-shared-nodes
Fix shared nodes
2021-10-16 14:09:23 +02:00
Juan Font Alonso
be3a379d10 Added --force flag on node delete (fixes #164) 2021-10-16 12:30:52 +02:00
Juan Font Alonso
d0daff180e Added TODO in waiting 2021-10-16 11:36:16 +02:00
Juan Font Alonso
be36480a64 Reverted back values in integration tests 2021-10-16 11:06:33 +02:00
Juan Font
9f52a64a6a Merge branch 'main' into fix-shared-nodes 2021-10-16 10:22:42 +02:00
Juan Font
52511af8e4 Merge pull request #169 from juanfont/arm64-binaries
Generate arm64 binaries in goreleaser
2021-10-16 10:22:24 +02:00
Juan Font
ddb6bd795c Merge branch 'main' into arm64-binaries 2021-10-16 10:10:54 +02:00
Juan Font
271660a284 Merge pull request #167 from juanfont/authkey-namespace-preload
Preload AuthKey Namespace on list nodes
2021-10-16 10:10:42 +02:00
Juan Font
0b0f7db534 Merge branch 'main' into authkey-namespace-preload 2021-10-16 09:59:34 +02:00
Juan Font
5a7b377f6f Merge pull request #166 from juanfont/complete-expire-command
Improve help message to expire key
2021-10-16 09:59:15 +02:00
Juan Font
654d2b9910 Merge branch 'main' into complete-expire-command 2021-10-16 09:41:14 +02:00
Juan Font
829a8c4381 Merge pull request #165 from juanfont/expire-json
Show JSON on error when expiring key
2021-10-16 09:40:48 +02:00
Juan Font Alonso
5807562b56 Add arm64 binaries 2021-10-15 17:00:04 +02:00
Juan Font Alonso
985c6e7cc9 Preload AuthKey Namespace on list nodes (fixes #163) 2021-10-15 00:04:04 +02:00
Juan Font Alonso
0d13e16fed Improve help message to expire key (fixes #161) 2021-10-14 23:58:15 +02:00
Juan Font Alonso
91d135e069 Show JSON when error on expire key (fixes #162) 2021-10-14 23:54:07 +02:00
Juan Font Alonso
3e1e07e8c1 Fixed integration tests for shared nodes 2021-10-14 22:37:44 +02:00
Juan Font
6c4c761408 Merge branch 'main' into fix-shared-nodes 2021-10-13 23:54:30 +02:00
Juan Font
abfb1791f1 Merge pull request #159 from juanfont/better-pak-management
Add field AlreadyUsed to AuthKeys
2021-10-13 23:45:10 +02:00
Juan Font Alonso
7ce8c4c649 Fixed merge 2021-10-13 23:28:47 +02:00
Juan Font Alonso
2ddca366f2 Merge branch 'main' of https://github.com/juanfont/headscale into main 2021-10-13 23:23:12 +02:00
Juan Font Alonso
9a6ac6e3e6 Reword errSingleUseAuthKeyHasBeenUsed 2021-10-13 23:23:07 +02:00
Juan Font
cc3e8705bd Merge branch 'main' into better-pak-management 2021-10-13 23:04:00 +02:00
Juan Font
809a5b84e7 Merge pull request #156 from juanfont/disable-version-check-on-json
Disable version checker when using JSON output
2021-10-13 23:00:51 +02:00
Juan Font
06ae2a6c50 Merge branch 'main' into better-pak-management 2021-10-13 23:00:38 +02:00
Juan Font
93517aa6f8 Apply suggestions from code review
Renamed AlreadyUsed to Used

Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-10-13 22:51:55 +02:00
Juan Font
5f0f3705c0 Merge branch 'main' into disable-version-check-on-json 2021-10-13 22:44:18 +02:00
Juan Font
70ae18c3a8 Merge pull request #155 from juanfont/fix-json-delete-node
Add JSON output when deleting node
2021-10-13 22:44:00 +02:00
Juan Font Alonso
6aa763a1ae Expanded unit tests to better cover sharing nodes 2021-10-13 20:56:32 +02:00
Juan Font Alonso
ebfb8c8c5e Fix tests, as IDs of Machines where wrongly starting in 0 2021-10-13 20:48:50 +02:00
Juan Font Alonso
30788e1a70 Add AlreadyUsed field to Auth Keys (fixes #157 and #158) 2021-10-13 18:13:26 +02:00
Juan Font Alonso
27947c6746 This commit disables the version checker when JSON output (#153) 2021-10-13 00:18:55 +02:00
Juan Font Alonso
6924b7bf4c Output json when deleting node (fixes #152) 2021-10-12 23:48:08 +02:00
Juan Font Alonso
fa8cd96108 Get peers from namespaces where shared nodes are shared to
This is rather shameful. Shared nodes should have never worked without this.
2021-10-12 17:20:14 +02:00
Juan Font
dd1e425d02 Merge pull request #147 from juanfont/fix-delete-shared-nodes
Fix error 500 when deleting a shared node
2021-10-11 18:19:36 +02:00
Juan Font Alonso
7f2027d7f2 Added unit tests 2021-10-10 23:55:18 +02:00
Juan Font Alonso
48f5a9a18c Fix error 500 when deleting shared node (fixes #133) 2021-10-10 23:55:03 +02:00
Kristoffer Dalby
087c461762 Merge pull request #145 from juanfont/discord
Remove gitter for discord
2021-10-10 12:24:50 +01:00
Juan Font
d579c1718c Merge branch 'main' into discord 2021-10-10 13:05:33 +02:00
Juan Font
4c5f667504 Merge pull request #129 from juanfont/magic-dns-support
Add support for MagicDNS
2021-10-10 13:05:16 +02:00
Kristoffer Dalby
4c4c95198b Remove gitter for discord 2021-10-10 12:00:45 +01:00
Juan Font Alonso
5ce1526a06 Do not return a pointer 2021-10-10 12:43:41 +02:00
Juan Font Alonso
d70c3d6189 Added more comments, plus renamed vars with better names 2021-10-10 12:34:55 +02:00
Juan Font Alonso
9a0c9768ad Merge branch 'magic-dns-support' of https://github.com/juanfont/headscale into magic-dns-support 2021-10-10 00:40:35 +02:00
Juan Font Alonso
6884798404 Added some comments 2021-10-10 00:40:25 +02:00
Juan Font
c4487b73c4 Merge branch 'main' into magic-dns-support 2021-10-09 12:24:07 +02:00
Juan Font Alonso
32c3f09eb4 Fixed conflict 2021-10-09 12:23:05 +02:00
Juan Font Alonso
d4dc133e20 Added unit tests 2021-10-09 12:22:21 +02:00
Juan Font Alonso
fc5153af3e Generate MagicDNS search domains for any tailnet range 2021-10-09 12:22:13 +02:00
Kristoffer Dalby
fd8d888ddb Merge pull request #142 from kradalby/loopy-loop 2021-10-07 16:08:33 +01:00
Kristoffer Dalby
06f56411dd Update machine.go 2021-10-07 15:45:45 +01:00
Kristoffer Dalby
e4f197b709 Merge branch 'main' into loopy-loop 2021-10-07 15:38:17 +01:00
Kristoffer Dalby
13406175c1 Merge pull request #144 from zekker6/main 2021-10-07 14:22:35 +01:00
Zakhar Bessarab
20117c51a2 Add CI builds with artifacts for PRs and main 2021-10-07 11:50:47 +03:00
Kristoffer Dalby
f0c54490ed Allow multiple namespaces to be checked for state at the same time 2021-10-06 22:06:07 +00:00
Kristoffer Dalby
95f726fb31 Fix logic 2021-10-06 19:56:14 +00:00
Kristoffer Dalby
ba391bc2ed Account for updates in shared namespaces 2021-10-06 19:32:15 +00:00
Kristoffer Dalby
c582c8d206 Update metrics for new code 2021-10-05 21:59:15 +00:00
Juan Font
1a0f6f6e39 Added note on TODO 2021-10-05 19:01:56 +02:00
Juan Font
6981543db6 Only search domain from current namespace in MapResponse 2021-10-05 19:00:40 +02:00
Kristoffer Dalby
722084fbd3 Comment out aggressive logging 2021-10-05 16:51:42 +00:00
Kristoffer Dalby
a01a0d1039 Remove unstable update channel, replace with state updates 2021-10-05 16:24:46 +00:00
Kristoffer Dalby
8abc7575cd Tear out all the complicated update logic
There is some weird behaviour that seem to storm the update channel. And
our solution with a central map of update channels isnt particularly
elegant.

For now, replace all the complicated stuff with a simple channel that
checks roughly every 10s if the node is up to date. Only generate and
update if there has been changes.
2021-10-05 16:17:18 +00:00
Juan Font
c9a411e341 Preload namespace 2021-10-05 17:47:21 +02:00
Juan Font Alonso
b02a9f9769 Go mod updates 2021-10-04 23:50:26 +02:00
Juan Font Alonso
a0fa652449 MagicDNS changes merged back 2021-10-04 23:49:16 +02:00
Juan Font Alonso
2eef535b4b Merged main 2021-10-04 23:43:42 +02:00
Juan Font Alonso
61870a275f WIP preparation for merge 2021-10-04 22:51:05 +02:00
Juan Font Alonso
088e8248d3 Improved doc 2021-10-04 22:50:33 +02:00
Juan Font Alonso
da4a9dadd5 Warn users when MagicDNS is set with no DNS servers 2021-10-04 22:16:53 +02:00
Juan Font
02bc7314f4 Update dns.go
Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-10-04 21:47:09 +02:00
Kristoffer Dalby
6fb8d67825 Merge pull request #136 from kradalby/db-cleaning
Code, pointer, variable cleanups. And metrics!
2021-10-04 20:44:54 +01:00
Juan Font Alonso
1a41a9f2c7 Updated readme 2021-10-04 20:27:45 +02:00
Juan Font
040a18e6f8 Merge branch 'main' into magic-dns-support 2021-10-04 19:45:12 +02:00
Juan Font Alonso
ec911981c2 Do not allow magicdns if not nameservers set up 2021-10-04 19:43:58 +02:00
Kristoffer Dalby
f6a7564ec8 Add more test cases to prove that peers and shared peers work properly 2021-10-04 17:40:21 +00:00
Kristoffer Dalby
2eb57e6288 Clean up pointer usage consistency.
This tries to make the same functions emit and consume the same type of
data all over the application.

If a function transform data, it should emit new data, not a pointer.
2021-10-04 17:39:01 +00:00
Kristoffer Dalby
94ba5181fc Resolve merge conflict 2021-10-04 16:38:52 +00:00
Kristoffer Dalby
1d5b090579 Initial work on Prometheus metrics
This commit adds some Prometheus metrics to /metrics in headscale.

It will add the standard go metrics, some automatic gin metrics and some
initial headscale specific ones.

Some of them has been added to aid debugging #97 (loop bug)

In the future, we can use the metrics to get rid of the sleep in the
integration tests by checking that our expected number of nodes has been
registered:

```
headscale_machine_registrations_total
```
2021-10-04 16:28:07 +00:00
Juan Font Alonso
ef0f7c0c09 Integration tests for MagicDNS working 2021-10-04 18:04:08 +02:00
Juan Font Alonso
e60ceefea9 Fixing nil issue 2021-10-04 18:03:44 +02:00
Kristoffer Dalby
ed6b5bc279 Merge pull request #141 from ptman/patch-1 2021-10-04 15:40:29 +01:00
Kristoffer Dalby
d3ef39a58f Correctly use the internal docker dns and port for headscale joining 2021-10-04 14:39:52 +00:00
Kristoffer Dalby
07e32be5ce Remove host port, we only need internal ports 2021-10-04 14:39:28 +00:00
Paul Tötterman
ed0b31d072 Update README.md
Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-10-04 17:23:38 +03:00
Paul Tötterman
fcc6991d62 Update README.md
Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-10-04 17:23:31 +03:00
Kristoffer Dalby
c09428acca Revert "Remove docker network, it wasnt used, comment out portmapping to host"
This reverts commit 2090a13dcd.
2021-10-04 14:09:21 +00:00
Kristoffer Dalby
931ef9482b Add checks to see if we can fetch the ip from map, remove possible null assignment 2021-10-04 14:17:05 +01:00
Kristoffer Dalby
772541afab add comment about poor error handling when headscale isnt becoming available 2021-10-04 14:16:37 +01:00
Kristoffer Dalby
2090a13dcd Remove docker network, it wasnt used, comment out portmapping to host 2021-10-04 14:15:20 +01:00
Kristoffer Dalby
31b4f03f96 Set integration logging to trace 2021-10-04 14:14:28 +01:00
Kristoffer Dalby
7793012409 Add error if peer api is empty 2021-10-04 14:14:12 +01:00
Paul Tötterman
566c2bc1fb Document client OS support in a table 2021-10-04 14:58:36 +03:00
Juan Font
99efeb98f8 Merge pull request #139 from cure/fix-goreleaser-version
Make sure that goreleaser uses the appropriate version string when
2021-10-04 00:17:13 +02:00
Juan Font
836ee74e57 Merge branch 'main' into fix-goreleaser-version 2021-10-04 00:01:58 +02:00
Juan Font
06689ed726 Merge pull request #140 from qbit/buypass
Add the ability to specify the directory URL used for ACME.
2021-10-04 00:01:29 +02:00
Aaron Bieber
817cc1e567 these are not files! 2021-10-03 14:02:44 -06:00
Aaron Bieber
8fa0fe65ba Add the ability to specify registration ACME email and ACME URL. 2021-10-03 12:26:38 -06:00
Ward Vandewege
1d81333685 Make sure that goreleaser uses the appropriate version string when
building the headscale executable.
2021-10-03 14:00:08 -04:00
Kristoffer Dalby
1bddf1147b Resolve merge conflict 2021-10-03 11:01:13 +01:00
Kristoffer Dalby
63fa475913 Merge pull request #134 from kradalby/loop-97 2021-10-02 23:13:41 +01:00
Kristoffer Dalby
d637a9c302 Change ping count 2021-10-02 22:56:48 +01:00
Kristoffer Dalby
3c3189caa6 Move toNode, add type helpers, split peers and shared
This commit moves toNode to the bottom of the file, and adds a helper
function for lists of Machines to be converted.

It also adds string helpers for Machines and lists of machines.

Lastly it splits getPeers into getDirectPeers, which exist in the same
namespace, and getShared, which is nodes shared with the namespace.

getPeers is kept as a function putting together the two lists for
convenience.
2021-10-02 22:03:34 +01:00
Kristoffer Dalby
0d4a006536 Consitently use Machine pointers
This commit rewrites a bunch of the code to always use *Machine instead
of a mix of both, and a mix of tailcfg.Node and Machine.

Now we use *Machine, and if tailcfg.Node is needed, it is converted just
before needed.
2021-10-02 22:00:09 +01:00
Kristoffer Dalby
0475eb6ef7 Move DB call of pollmap to Machine inside a function 2021-10-02 21:58:28 +01:00
Kristoffer Dalby
0d1b60ad63 Merge branch 'loop-97' of github.com:kradalby/headscale into loop-97 2021-10-02 18:39:18 +01:00
Kristoffer Dalby
78a0f3ca37 Up ping timeout 2021-10-02 18:39:09 +01:00
Kristoffer Dalby
2c83eac36f Merge branch 'main' into loop-97 2021-10-02 18:37:21 +01:00
Kristoffer Dalby
42913e2c37 Merge pull request #135 from cure/fix-README-typos
Fix a few typos in the tailscale command line arguments.
2021-10-02 17:46:25 +01:00
Kristoffer Dalby
54daa0da23 Fix spelling error 2021-10-02 17:35:39 +01:00
Ward Vandewege
0435089eba Fix a few typos in the tailscale command line arguments. 2021-10-02 10:44:52 -04:00
Kristoffer Dalby
39abc4e973 Clarify error messages for nodes that are not connected
If a node does not have an update channel, it is probably not connected,
clarify the log messages and make sure we dont print that it was updated
successfully (continue, not return)
2021-10-02 15:38:53 +01:00
Kristoffer Dalby
cefe2d5bcc Improve and clarify log entry 2021-10-02 15:30:41 +01:00
Kristoffer Dalby
ed728f57e0 Remove WriteTimeout from HTTP
Golangs built in HTTP server does not allow different HTTP timeout for
different types of handlers, so we cannot have a write timeout as we
attempt to do long polling (my bad).

See linked article.

Also removed redundant server declaration
2021-10-02 15:29:27 +01:00
Kristoffer Dalby
6ffea2225d Attempt to close failed streams
If we have a failed write toward any of our connections, attempt to
close the connection by returning "false" as in unsuccessful stream
2021-10-02 15:28:19 +01:00
Juan Font Alonso
64185cc2bc Fixed go mod 2021-10-02 15:18:05 +02:00
Juan Font
990ff153c0 Merge branch 'main' into magic-dns-support 2021-10-02 15:16:51 +02:00
Juan Font Alonso
47dcc940c0 Fixed issue in tests 2021-10-02 14:49:14 +02:00
Juan Font Alonso
8d60ae2c7e Tidy gomod 2021-10-02 13:03:41 +02:00
Juan Font Alonso
19492650d4 Fixed error on assign 2021-10-02 13:03:08 +02:00
Juan Font Alonso
36ae14bccf Send search domains 2021-10-02 12:13:19 +02:00
Juan Font Alonso
45e71ecba0 Generated MagicDNS search domains (only in 100.64.0.0/10) 2021-10-02 12:13:05 +02:00
Juan Font Alonso
e432e98413 Send hostname in toNode 2021-10-02 12:12:22 +02:00
Juan Font Alonso
656237e167 Propagate dns config vales across Headscale 2021-10-02 11:20:42 +02:00
Juan Font Alonso
5dbf6b5127 Extended DNS config unit tests 2021-10-02 11:14:18 +02:00
Juan Font Alonso
c9e4da3ff5 Improving documentation for DNS config 2021-10-02 11:11:18 +02:00
Juan Font
cfd4781eb4 Merge pull request #131 from Extrality/main
fix some typos in README
2021-10-01 21:36:55 +02:00
Arthur Woimbée
986725519f fix some typos in README 2021-10-01 15:59:54 +02:00
Juan Font Alonso
3f3cfedffa Add support for MagicDNS 2021-09-28 00:22:29 +02:00
Juan Font
e9ea698130 Merge pull request #128 from juanfont/version-checker
Added version checker on Headscale startup
2021-09-27 23:28:16 +02:00
Juan Font Alonso
a6adcdafa9 Added switch to disable the update checks 2021-09-27 17:24:34 +02:00
Juan Font Alonso
7c37086dd6 Handle lack of internet 2021-09-27 17:12:31 +02:00
Juan Font Alonso
2048e9e136 Added version checker on startup 2021-09-27 16:26:18 +02:00
Kristoffer Dalby
0bbf343348 Merge pull request #113 from kradalby/apple-mobileconfig
Apple macOS profile support
2021-09-26 21:34:11 +01:00
Kristoffer Dalby
9811809f6a Resolve conflict 2021-09-26 20:51:07 +01:00
Kristoffer Dalby
237a14858a Add apple endpoint to readme 2021-09-26 20:47:39 +01:00
Kristoffer Dalby
59c3d4bcfe Comment out iOS from /apple for now 2021-09-26 20:41:48 +01:00
Juan Font
7612cc84d2 Merge pull request #122 from juanfont/taildrop-support
Add support for Taildrop (file sharing)
2021-09-26 20:40:26 +02:00
Kristoffer Dalby
4aa91bc420 Merge branch 'main' into taildrop-support 2021-09-26 19:29:00 +01:00
Juan Font Alonso
c801a8c553 Improve comments on taildrop tests 2021-09-26 20:23:15 +02:00
Juan Font Alonso
5626f598ce Do several attempts to send the file 2021-09-26 18:59:23 +02:00
Juan Font Alonso
7d0da8b578 Added retries 2021-09-26 17:38:51 +02:00
Juan Font Alonso
eb87fc9215 Fixed getAPIURLs method 2021-09-26 15:17:27 +02:00
Juan Font Alonso
ada40960bd Removed unnecesary prints 2021-09-26 14:33:01 +02:00
Juan Font Alonso
83ead36fce Integration tests working for taildrop 2021-09-26 14:22:11 +02:00
Juan Font
05a5f21c3d Use curl to uploaded the file 2021-09-26 12:22:59 +02:00
Juan Font
a36328dbfc Added integration tests 2021-09-25 13:12:44 +02:00
Juan Font
cab5641d95 Update readme 2021-09-24 09:50:01 +02:00
Juan Font
b83894abd6 Add support for taildrop (#118) 2021-09-24 09:49:29 +02:00
Kristoffer Dalby
8e588ae146 Add a more comprehensive macOS explaination 2021-09-23 20:22:07 +01:00
Juan Font
83815f567d Merge pull request #109 from juanfont/tailscale-1.14
Update to Tailscale 1.14
2021-09-23 16:44:51 +02:00
Kristoffer Dalby
7db91c68be Merge pull request #121 from juanfont/main
New integration test for tailscale 1.14
2021-09-23 14:51:26 +01:00
Juan Font
109115c13b Merge pull request #120 from t56k/main
Fix namespace instructions in README.md
2021-09-23 09:55:57 +02:00
t56k
11e0402396 create a db file first 2021-09-23 17:14:04 +12:00
t56k
fd94105483 fix namespace instructions in README.md 2021-09-23 16:32:15 +12:00
Juan Font
96e8142540 Merge pull request #114 from kradalby/integration-tests-improvement
Improve integration tests
2021-09-22 14:51:47 +02:00
Kristoffer Dalby
9615138202 Remove non working default 2021-09-21 11:10:26 +01:00
Juan Font
9900b215cc Merge pull request #115 from ohdearaugustin/topic/fix-docu
Topic/fix docu
2021-09-21 09:19:09 +02:00
Kristoffer Dalby
d5ea224e11 Merge pull request #116 from ohdearaugustin/topic/docker-release-tag 2021-09-20 23:34:44 +01:00
Kristoffer Dalby
024d6ee7c3 Initial work on shared node integration test
This commit adds initial integration tests for shared nodes, it adds
them and verifies that they are shared.

It does not yet manage to ping the shared node because it does not seem
to establish the connection.
2021-09-20 23:21:25 +01:00
ohdearaugustin
f653b00258 workflows/release: add docker full version tag 2021-09-21 00:10:00 +02:00
ohdearaugustin
ff1ee4ca64 Fix README whitespace 2021-09-21 00:02:54 +02:00
ohdearaugustin
830aa250e1 Fix README formating 2021-09-21 00:01:18 +02:00
ohdearaugustin
f0bbc3c7d8 Fix docker docu 2021-09-20 23:53:52 +02:00
Kristoffer Dalby
994b4eef17 Use JSON output and proper datamodel for tailscale status
This commit uses tailscale --json to check status, and unmarshal it into
the proper ipn Status.
2021-09-20 22:53:34 +01:00
Kristoffer Dalby
f905812afa Test two namespaces
This expands the tests to verify two namespaces instead of only one.

It verifies some of the isolation, and is prework for shared nodes
testing
2021-09-20 20:18:28 +01:00
Kristoffer Dalby
d68d201722 Add version support to integration support
This commit adds a list of tailscale versions to use in the integration
test. An equal distribution of versions will be used across the clients.
2021-09-20 19:23:18 +01:00
Juan Font
da209e89a7 Update README.md
Fixed typo (causing #110)
2021-09-20 18:42:04 +02:00
Juan Font
7940dbc78b Merge pull request #111 from woudsma/main
fix typo
2021-09-20 18:37:52 +02:00
Juan Font
4d22b4252f Merge pull request #108 from ohdearaugustin/topic/docker-image-version
Dockerfile: add golang tag
2021-09-20 18:37:22 +02:00
Kristoffer Dalby
b3efd1e47b Handle errors 2021-09-20 07:54:18 +01:00
Kristoffer Dalby
2d39d6602c Merge remote-tracking branch 'upstream/main' into apple-mobileconfig 2021-09-19 18:00:40 +01:00
Kristoffer Dalby
dfcab2b6d5 Wire up new handlers 2021-09-19 17:56:29 +01:00
Kristoffer Dalby
40c5263927 Add initial code for generating Apple profiles
This code adds new http handlers that will generate iOS and macOS
configuration profiles allowing us to override the Control server of the
official Tailscale.app.

Currently, macOS is working, as I have not found the correct "key" to
inject for iOS.

This means that a profile will allow users to no longer log in via the
command line, but they can use the app.
2021-09-19 17:54:41 +01:00
Kristoffer Dalby
bf26e37e0e Merge pull request #112 from fkr/main 2021-09-19 11:19:35 +01:00
Felix Kronlage-Dammers
e154e7a0fb fix typo, it is 'relayed' not 'relied' 2021-09-19 12:07:17 +02:00
Tjerk Woudsma
b28ebb5d20 fix typo 2021-09-18 12:34:04 +02:00
Juan Font
5840f88251 Update tailscale dependencies to v1.14 2021-09-14 23:46:16 +02:00
Juan Font
2c2968473a Update basic dependencies 2021-09-14 23:42:19 +02:00
Juan Font
8f1f48b7d0 Update README.md
Remove Google registry for the time being
2021-09-13 23:11:15 +02:00
Juan Font
536e8b71bf Removed wrong syntax in actions 2021-09-13 22:59:33 +02:00
Juan Font
acc43c39af Increased linter timeout in makefile 2021-09-13 22:58:35 +02:00
Juan Font
eae1b6a3de More timeout in linting 2021-09-13 22:51:58 +02:00
Juan Font
31cc61478f More timeout in linting 2021-09-13 22:47:38 +02:00
Juan Font
3095c1e150 Trying to correct Actions issues 2021-09-13 22:45:31 +02:00
Juan Font
e1d5da5bd9 Merge pull request #107 from qbit/no_color_trace
Remove trace lines about NO_COLOR.
2021-09-13 22:38:37 +02:00
Juan Font
5f818b7349 Merge pull request #89 from ohdearaugustin/topic/docker-release
Topic/docker release
2021-09-13 22:37:33 +02:00
ohdearaugustin
0aac79f8fa Dockerfile: add golang tag 2021-09-13 20:03:03 +02:00
ohdearaugustin
1e93347a26 Merge branch 'main' into topic/docker-release 2021-09-12 18:18:34 +02:00
ohdearaugustin
18867a4c84 update docu 2021-09-12 18:08:43 +02:00
ohdearaugustin
3b97c7bdec gitignore: add jetbrains 2021-09-12 18:08:43 +02:00
Aaron Bieber
203e6bc6b2 Remove trace lines about NO_COLOR. 2021-09-12 07:30:35 -06:00
Juan Font
e27753e46e Merge pull request #103 from juanfont/shared-nodes
Add support for sharing nodes across namespaces
2021-09-11 23:31:37 +02:00
Juan Font
11fbef4bf0 Added extra timeout 2021-09-11 23:21:45 +02:00
Juan Font
c4e6ad1ec7 Fixed some typos 2021-09-10 00:52:08 +02:00
Juan Font
263a3f1983 Merge branch 'main' into shared-nodes 2021-09-10 00:49:50 +02:00
Juan Font
8acaea0fbe Increased timeout 2021-09-10 00:44:27 +02:00
Juan Font
bd6adfaec6 Changes a few more variables 2021-09-10 00:37:01 +02:00
Juan Font
4b4a5a4b93 Update sharing.go
Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-09-10 00:32:42 +02:00
Juan Font
b098d84557 Apply suggestions from code review
Changed more variable names

Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-09-10 00:32:06 +02:00
Juan Font
b937f9b762 Update machine.go
Added comment on toNode
2021-09-10 00:30:02 +02:00
Juan Font
55f3e07bd4 Apply suggestions from code review
Removed one letter variables

Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-09-10 00:26:46 +02:00
Juan Font
2780623076 Renamed SharedNode to SharedMachine 2021-09-06 14:43:43 +02:00
Juan Font
75a342f96e Renamed files 2021-09-06 14:40:37 +02:00
Juan Font
729cd54401 Renamed sharing function 2021-09-06 14:39:52 +02:00
Juan Font
a023f51971 Merge pull request #101 from SilverBut/main
fix: check last seen time without possible null pointer
2021-09-03 10:35:49 +02:00
Juan Font
5076eb9215 Merge pull request #102 from SilverBut/patch-1
docs: add notes on how to build own DERP server
2021-09-03 10:24:32 +02:00
Juan Font
7edd0cd14c Added add node cli 2021-09-03 10:23:45 +02:00
Juan Font
7ce4738d8a Preload namespace so the name can be shown 2021-09-03 10:23:26 +02:00
Juan Font
7287e0259c Minor linting issues 2021-09-02 17:08:39 +02:00
Juan Font
d86de68b40 Show namespace in node list table 2021-09-02 17:06:47 +02:00
Juan Font
4ba107a765 README updated 2021-09-02 17:00:46 +02:00
Juan Font
187b016d09 Added helper function to get list of shared nodes 2021-09-02 16:59:50 +02:00
Juan Font
7010f5afad Added unit tests on sharing nodes 2021-09-02 16:59:12 +02:00
Juan Font
48b73fa12f Implement node sharing functionality 2021-09-02 16:59:03 +02:00
Juan Font
1ecd0d7ca4 Added DB SharedNode model to support sharing nodes 2021-09-02 16:57:26 +02:00
Silver Bullet
6faaae0c5f docs: add notes on how to build own DERP server
The official doc is hidden under a bunch of issues. Add a doc link here and hope it could be helpful.
2021-09-02 06:08:12 +08:00
Silver Bullet
e4ef65be76 fix: check last seen time without possible null pointer 2021-09-02 05:44:42 +08:00
Juan Font
39c661d408 Merge pull request #99 from juanfont/explicit-ubuntu-version
Use explicit version in Dockerfile
2021-08-26 21:18:16 +02:00
Juan Font
91a48d6a43 Update Dockerfile
Use explicit version in Dockerfile (addresses #95)
2021-08-26 10:23:45 +02:00
Kristoffer Dalby
123f0fa185 Merge pull request #98 from kradalby/initial-dns-server-exit-node 2021-08-25 22:58:25 +01:00
Kristoffer Dalby
ba3dffecbf Update readme 2021-08-25 19:05:10 +01:00
Kristoffer Dalby
8735e5675c Add a test for the getdnsconfig function 2021-08-25 19:03:04 +01:00
Kristoffer Dalby
3f5e06a0f8 Dont add the portnumber to the ip 2021-08-25 18:43:13 +01:00
Juan Font
ba40a40b73 Merge pull request #96 from qbit/version_fix
Fix setting of version
2021-08-25 12:34:34 +02:00
Kristoffer Dalby
b3732e7fb9 Add nameserver as resolver aswell 2021-08-25 07:04:48 +01:00
Aaron Bieber
104776ee84 fix setting of version 2021-08-24 07:49:15 -06:00
Kristoffer Dalby
01e781e546 Pass DNSConfig to nodes in MapResponse 2021-08-24 07:11:45 +01:00
Kristoffer Dalby
e77c16b55a Add DNSConfig to example and setup test 2021-08-24 07:10:09 +01:00
Kristoffer Dalby
987bbee1db Add DNSConfig field to configuration 2021-08-24 07:09:47 +01:00
Juan Font
74d2fe1baa Merge pull request #84 from kradalby/integration-tests-ci
Improve logic to keep nodes up to date with the network state
2021-08-23 09:42:07 +02:00
Kristoffer Dalby
98e63d5561 Merge pull request #94 from kradalby/split-lint-test
Split lint and test CI files
2021-08-23 07:46:11 +01:00
Kristoffer Dalby
059f13fc9d Add missing comment for stream function 2021-08-23 07:38:14 +01:00
Kristoffer Dalby
ebd27b46af Add comment to updatemachine 2021-08-23 07:35:44 +01:00
Juan Font
ca8d814918 Merge pull request #92 from kradalby/enhance-route-command
Enhance route command with ptables and multiple routes
2021-08-22 12:48:30 +02:00
Kristoffer Dalby
0aeeaac361 Always load machine object from DB before save/modify
We are currently holding Machine objects in memory for a long time,
while waiting for stream/longpoll, this might make us end up with stale
objects, that we just call save on, potentially overwriting stuff in
the database.

A typical scenario would be someone changing something from the CLI,
e.g. enabling routes, which in turn is overwritten again by the stale
object in the longpolling function.

The code has been left with TODO's and a discussion is available in #93.
2021-08-21 16:52:19 +01:00
Kristoffer Dalby
28ed8a5742 Actually rename lint 2021-08-21 15:42:23 +01:00
Kristoffer Dalby
f749be1490 Split lint and test CI files
This commit splits the lint and test steps into two different jobs in
github actions.

Consider this a suggestion, the idea is that when we look at PRs we will
see explicitly which one of the two types of checks fails without having
to open Github actions.
2021-08-21 15:40:27 +01:00
Kristoffer Dalby
693bce1b10 Update test machine name properly 2021-08-21 15:35:26 +01:00
Kristoffer Dalby
4f97e077db Add --all flag to routes enable command to enable all advertised routes 2021-08-21 15:04:30 +01:00
Kristoffer Dalby
c883e79884 Enhance route command with ptables and multiple routes
This commit rewrites the `routes list` command to use ptables to present
a slightly nicer list, including a new field if the route is enabled or
not (which is quite useful).

In addition, it reworks the enable command to support enabling multiple
routes (not only one route as per removed TODO). This allows users to
actually take advantage of exit-nodes and subnet relays.
2021-08-21 14:49:46 +01:00
ohdearaugustin
a613501ff2 Update .github/workflows/release.yml
Fix typo

Co-authored-by: Kristoffer Dalby <kradalby@kradalby.no>
2021-08-21 11:17:21 +02:00
Kristoffer Dalby
a054e2514a Keep tailscale count at 25 in integration tests 2021-08-21 09:26:18 +01:00
Kristoffer Dalby
c49fe26da7 Code clean up, loglevel debug for integration tests 2021-08-21 09:15:16 +01:00
ohdearaugustin
75afdc6306 github/workflows: remove version tag 2021-08-20 20:10:34 +02:00
ohdearaugustin
f02beaf075 github/workflows: add checkout 2021-08-20 19:45:01 +02:00
ohdearaugustin
8bcc7e88f0 github/workflows: add dispatch 2021-08-20 19:37:15 +02:00
ohdearaugustin
0adbd720bf github/workflows: add docker release 2021-08-20 19:15:20 +02:00
Kristoffer Dalby
d93a7f2e02 Make Info default log level 2021-08-20 17:15:07 +01:00
Kristoffer Dalby
88d7ac04bf Account for racecondition in deleting/closing update channel
This commit tries to address the possible raceondition  that can happen
if a client closes its connection after we have fetched it from the
syncmap before sending the message.

To try to avoid introducing new dead lock conditions, all messages sent
to updateChannel has been moved into a function, which handles the
locking (instead of calling it all over the place)

The same lock is used around the delete/close function.
2021-08-20 16:52:34 +01:00
Kristoffer Dalby
1f422af1c8 Save headscale logs if jobs fail 2021-08-20 16:50:55 +01:00
Kristoffer Dalby
53168d54d8 Make http timeout 30s instead of 10s 2021-08-19 22:29:03 +01:00
Kristoffer Dalby
b0ec945dbb Make lastStateChange namespaced 2021-08-19 18:19:26 +01:00
Kristoffer Dalby
48ef6e5a6f Rename keepAlive function, as it now does more things 2021-08-19 18:06:57 +01:00
Kristoffer Dalby
8d1adaaef3 Move isOutdated logic to updateChan consumation 2021-08-19 18:05:33 +01:00
Kristoffer Dalby
dd8c0d1e9e Move most "poll" functionality to poll.go
This function migrates more poll functions (including keepalive) to
poll.go to keep it somehow in the same file.

In addition it makes changes to improve the stability and ensure nodes
get the appropriate updates from the headscale control and are not left
in an inconsistent state.

Two new additions is:

omitpeers=true will now trigger an update if the clients are not already up
to date

keepalive has been extended with a timer that will check every 120s if
all nodes are up to date.
2021-08-18 23:24:22 +01:00
Kristoffer Dalby
57b79aa852 Set timeout, add lastupdate field
This commit makes two reasonably major changes:

Set a default timeout for the go HTTP server (which gin uses), which
allows us to actually have broken long poll sessions fail so we can have
the client re-establish them.
The current 10s number is chosen randomly and we need more testing to
ensure that the feature work as intended.

The second is adding a last updated field to keep track of the last time
we had an update that needs to be propagated to all of our
clients/nodes. This will be used to keep track of our machines and if
they are up to date or need us to push an update.
2021-08-18 23:21:11 +01:00
Kristoffer Dalby
2f883410d2 Add lastUpdate field to machine, function issue message on update channel
This commit adds a new field to machine, lastSuccessfulUpdate which
tracks when we last was able to send a proper mapupdate to the node. The
purpose of this is to be able to compare to a "global" last updated time
and determine if we need to send an update map request to a node.

In addition it allows us to create a scheduled check to see if all known
nodes are up to date.

Also, add a helper function to send a message to the update channel of a
machine.
2021-08-18 23:17:38 +01:00
Kristoffer Dalby
6fa61380b2 Up client count, make arguments more explicit and clean up unused assignments 2021-08-18 23:17:09 +01:00
Juan Font
47b61c0cea Merge pull request #86 from juanfont/better-ui
Improve tables in CLI
2021-08-16 09:33:47 +02:00
Juan Font
d739ac830f Merge pull request #87 from juanfont/fix-route-notify
Send notifications when enabling a route
2021-08-16 09:25:31 +02:00
Juan Font
26024fedc7 Merge branch 'main' into fix-route-notify 2021-08-16 00:29:38 +02:00
Juan Font
a376b697c0 Send notifications when enabling a route 2021-08-16 00:17:26 +02:00
Juan Font
bc2574680d Linting 2021-08-15 23:35:03 +02:00
Juan Font
f194b41435 Better table in preauthkeys 2021-08-15 23:29:55 +02:00
Juan Font
350f7da55d Better table in namespaces 2021-08-15 23:20:38 +02:00
Juan Font
36f5f78f46 pterm dependency 2021-08-15 23:10:50 +02:00
Juan Font
55fe5b0b41 Use pterm table in node list 2021-08-15 23:10:39 +02:00
Kristoffer Dalby
7d1a5c00a0 Try with longer timeout 2021-08-13 16:56:28 +01:00
Kristoffer Dalby
036061664e initial integration test file 2021-08-13 16:12:01 +01:00
Kristoffer Dalby
5b1b40ce93 Merge pull request #83 from kradalby/more-integration-tests
Improve reliability of PollMapHandler, more integration tests
2021-08-13 16:05:32 +01:00
Kristoffer Dalby
a8d9fdce3c Uncomment ping test 2021-08-13 11:01:23 +01:00
Kristoffer Dalby
700382cba4 Split stream part of pollhandlermap into its own func 2021-08-13 10:33:50 +01:00
Kristoffer Dalby
9698abbfd5 Resolve merge conflict 2021-08-13 10:33:19 +01:00
Juan Font
5bfcf5c917 Merge pull request #82 from juanfont/really-expire-ephemeral
Also notify peers when deleting ephemerals
2021-08-12 22:05:53 +02:00
Juan Font
8eb7d47072 Fixed linting 2021-08-12 21:57:20 +02:00
Juan Font
ab61c87701 Also notify peers when deleting ephemerals 2021-08-12 21:53:37 +02:00
Juan Font
c1e6157847 Expire ephemeral is internal 2021-08-12 21:45:40 +02:00
Juan Font
4c849539fc Expire the ephemeral nodes in the Serve method 2021-08-12 21:44:12 +02:00
Juan Font
9c2a630055 Merge pull request #81 from kradalby/integration-tests
Add Integration tests
2021-08-12 11:15:45 +02:00
Kristoffer Dalby
0e1ddf9715 Set longer timeout for integration tests 2021-08-12 07:36:38 +01:00
Kristoffer Dalby
54da1a4155 Commit the correct integration etc files 2021-08-12 07:05:26 +01:00
Kristoffer Dalby
7141e2ed70 Fix hostname passed to join command 2021-08-11 17:12:39 +01:00
Kristoffer Dalby
c9e5048015 Merge remote-tracking branch 'upstream/main' into integration-tests 2021-08-08 17:57:28 +01:00
Kristoffer Dalby
4e077b053c Initial work, add integration tests
This commit adds integration tests to headscale. They are currently
quite simple, but it lays the groundwork for more comprehensive testing
and ensuring we dont break things with the official tailscale client.

The test works by leveraging Docker (via dockertest) to spin up a
Headscale container, and a number of tailscale containers (10).

Each tailscale container is joined to the headscale and then "passed on"
to the tests.

Currently three tests have been implemented:

- Have all tailscale containers join headscale (in the setup process)
- Get IP from each container (I plan to extend this with cross-ping)
- List nodes with headscales CLI and verify all has been registered

This test depends on Docker, and currently, I have not looked into
hooking it into Github Actions.
2021-08-08 17:50:32 +01:00
Kristoffer Dalby
f973aef80c Add Dockerfile to build tailscale docker image for integration tests 2021-08-08 17:43:06 +01:00
Kristoffer Dalby
a43bb1bb40 Improve Dockerfile
This commit makes several changes to the dockerfile:

- Add go.mod and go.sum in a seperate stage, subsequently calling `go
  mod download` to make it cache dependencies and speed up builds
- Use ubuntu:latest (28MB larger) instead of scratch, makes the image a
  lot easier to debug (e.g. it has a shell and a package manager)
- Change ENTRYPOINT to CMD, this makes the behaviour of the image
  slightly different from a CLI perspective, but makes interacting with
  the image from code, docker-compose and kubernetes easier.
2021-08-08 17:39:39 +01:00
Kristoffer Dalby
d86123195c Add a dockerignore file to speed up builds and make cachine better 2021-08-08 17:38:44 +01:00
Kristoffer Dalby
91ffd10192 Remove "Keys: " from create auth key output
This is based on the premis that "the user know what command they
executed" and therefor know that the output is the key.

This makes the command a lot more useful in scripts.
2021-08-08 17:37:23 +01:00
Kristoffer Dalby
642c7824a7 Add trace log for machine failing to parce ip in toNode 2021-08-08 17:37:04 +01:00
Kristoffer Dalby
149279f3d5 Add health endpoint
Allow us to tell when the server is up and running and can answer
requests
2021-08-08 17:36:25 +01:00
Juan Font
275214920f Merge pull request #80 from juanfont/delete-pak
Add CLI command to mark preauthkeys as expired
2021-08-08 10:52:18 +02:00
Juan Font
0124899759 fixed linting x 2 2021-08-08 00:14:10 +02:00
Juan Font
033136cb9a fixed linting 2021-08-08 00:13:44 +02:00
Juan Font
05e08e0ac7 Added cmd to expire preauth keys (requested in #78) 2021-08-08 00:10:30 +02:00
Juan Font
226cb89d97 Added func to expire PAKs 2021-08-07 23:57:52 +02:00
Juan Font
3007c0ec4f Merge pull request #79 from felixonmars/patch-1
Correct a typo in routes.go
2021-08-07 20:02:16 +02:00
Felix Yan
3fa1ac9c79 Correct a typo in routes.go 2021-08-08 01:52:01 +08:00
Juan Font
bb2ccfddd9 Merge pull request #77 from kradalby/deadlierlocks
Remove more deadlocks
2021-08-07 01:05:01 +02:00
Kristoffer Dalby
99fd126219 Remove unused mutex 2021-08-06 21:11:38 +01:00
Kristoffer Dalby
15b8c8f4c5 Remove lock from keepAlive 2021-08-06 20:08:51 +01:00
Kristoffer Dalby
4243885246 Rewrite old lock error msg 2021-08-06 20:03:25 +01:00
Kristoffer Dalby
5bc5c5dc1b Remove forgotten lock 2021-08-06 20:02:47 +01:00
Juan Font
db4f49901e Merge pull request #76 from kradalby/no-color-logs
Try to detect color support, make color configurable
2021-08-06 08:40:54 +02:00
Kristoffer Dalby
73a00c89ff Try to detect color support, make color configurable
This commit tries to detect if users can render colors in their terminal
and only enables color logs if that is true.

It also adds no-color.org's NO_COLOR env var support to allow it to be
disabled.
2021-08-06 07:29:57 +01:00
Juan Font
8a614dabc0 Headscale is from no-juan 2021-08-06 00:23:07 +02:00
Juan Font
c95cf15731 Fixed log message 2021-08-06 00:21:34 +02:00
Juan Font
e7ce902f9d Merge pull request #75 from kradalby/syncmap
Fix deadlock issue
2021-08-06 00:19:34 +02:00
Juan Font
d421c7b665 Merge pull request #74 from kradalby/deadlock-logging
Switch to a structured logger
2021-08-06 00:18:40 +02:00
Kristoffer Dalby
1abc68ccf4 Removes locks causing deadlock
This commit removes most of the locks in the PollingMap handler as there
was combinations that caused deadlocks. Instead of doing a plain map and
doing the locking ourselves, we use sync.Map which handles it for us.
2021-08-05 22:14:37 +01:00
Kristoffer Dalby
575b15e5fa Add more trace logging 2021-08-05 21:47:06 +01:00
Kristoffer Dalby
a8c8a358d0 Make log keys lowercase 2021-08-05 20:57:47 +01:00
Kristoffer Dalby
cd2ca137c0 Make log_level user configurable 2021-08-05 19:19:25 +01:00
Kristoffer Dalby
0660867a16 Correct url 2021-08-05 18:58:15 +01:00
Kristoffer Dalby
b1200140b8 Convert cli/utils.go 2021-08-05 18:26:49 +01:00
Kristoffer Dalby
d10b57b317 Convert namespaces.go 2021-08-05 18:23:02 +01:00
Kristoffer Dalby
42bf566fff Convert acls.go 2021-08-05 18:18:18 +01:00
Kristoffer Dalby
0bb2fabc6c Convert missing from api.go 2021-08-05 18:16:21 +01:00
Kristoffer Dalby
ee704f8ef3 Initial port to zerologger 2021-08-05 18:11:26 +01:00
Juan Font
4aad3b7933 Improved README.md on ip_prefix 2021-08-03 20:38:23 +02:00
Juan Font
6091373b53 Merge pull request #63 from juanfont/use-kv-for-updates
Added communication between Serve and CLI using KV table
2021-08-03 20:30:33 +02:00
Juan Font
3879120967 Merge pull request #72 from kradalby/ip-pool
Make IP Prefix configurable and available ip deterministic
2021-08-03 20:27:42 +02:00
Kristoffer Dalby
465669f650 Merge pull request #1 from kradalby/ip-pool-test
Fix empty ip issue and remove network/broadcast addresses
2021-08-03 10:12:09 +01:00
Kristoffer Dalby
ea615e3a26 Do not issue "network" or "broadcast" addresses (0 or 255) 2021-08-03 10:06:42 +01:00
Kristoffer Dalby
d3349aa4d1 Add test to ensure we can deal with empty ips from database 2021-08-03 09:26:28 +01:00
Kristoffer Dalby
73207decfd Check that IP is set before parsing
Machine is saved to db before it is assigned an ip, so we might have
empty ip fields coming back.
2021-08-03 07:42:11 +01:00
Kristoffer Dalby
eda6e560c3 debug logging 2021-08-02 22:51:50 +01:00
Kristoffer Dalby
95de823b72 Add test to ensure we can read back ips 2021-08-02 22:39:18 +01:00
Kristoffer Dalby
9f85efffd5 Update readme 2021-08-02 22:06:15 +01:00
Kristoffer Dalby
b5841c8a8b Rework getAvailableIp
This commit reworks getAvailableIp with a "simpler" version that will
look for the first available IP address in our IP Prefix.

There is a couple of ideas behind this:

* Make the host IPs reasonably predictable and in within similar
  subnets, which should simplify ACLs for subnets
* The code is not random, but deterministic so we can have tests
* The code is a bit more understandable (no bit shift magic)
2021-08-02 21:57:45 +01:00
Kristoffer Dalby
309f868a21 Make IP prefix configurable
This commit makes the IP prefix used to generate addresses configurable
to users. This can be useful if you would like to use a smaller range or
if your current setup is overlapping with the current range.

The current range is left as a default
2021-08-02 20:06:26 +01:00
Juan Font
6c903d4a2f Fixed missing nodes cmd 2021-07-31 23:14:24 +02:00
Juan Font
c3aa9a5d4c Merge pull request #69 from juanfont/change-default-port
Use 8080 as default port in the example config
2021-07-31 11:47:15 +02:00
Juan Font Alonso
4fb55e1684 Use 8080 as default port, like in the Kubernetes yamls 2021-07-30 17:07:19 +02:00
Juan Font Alonso
91bfb481c1 Fix identation 2021-07-30 16:42:26 +02:00
Juan Font
201ba109c3 Merge pull request #62 from ohdearaugustin/topic/refactor-config
Topic/refactor config
2021-07-30 16:40:38 +02:00
Juan Font
d3f965d493 Merge pull request #66 from juanfont/remove-old-docker
Remove old docker code
2021-07-28 13:43:58 +02:00
Juan Font
f832d7325b Merge pull request #67 from kradalby/patch-1
Fix typo in example
2021-07-27 19:58:15 +02:00
Kristoffer Dalby
b1d1bd32c3 Fix typo in example
The example command is missing the `s` in `preauthkeys`
2021-07-27 18:37:43 +01:00
Juan Font Alonso
df6d4de6fd Remove old docker code 2021-07-27 17:05:22 +02:00
Juan Font Alonso
461a893ee4 Added log message when sending updates 2021-07-25 20:47:51 +02:00
Juan Font Alonso
97f7c90092 Added communication between Serve and CLI using KV table (helps in #52) 2021-07-25 17:59:48 +02:00
ohdearaugustin
ea3043cdcb cmd: Add error check for Persistent Flags 2021-07-25 16:26:15 +02:00
ohdearaugustin
04dffcc4ae Refactor cli commands 2021-07-25 15:14:09 +02:00
ohdearaugustin
3a07360b6e Add root cmd 2021-07-25 15:10:34 +02:00
ohdearaugustin
b97d6f71b1 Refactor version cmd 2021-07-25 15:09:53 +02:00
ohdearaugustin
4915902e04 Refactor server cmd 2021-07-25 15:09:33 +02:00
ohdearaugustin
d87a4c87cc Refactor routes cmd 2021-07-25 15:08:40 +02:00
ohdearaugustin
e56755fd67 Refactor preauthkeys cmd 2021-07-25 15:07:27 +02:00
ohdearaugustin
2862c2034b Refactor nodes cmd 2021-07-25 15:04:06 +02:00
ohdearaugustin
53185eaa9e Refactor namespaces cmd 2021-07-25 15:03:45 +02:00
Juan Font
b83ecc3e6e Merge pull request #61 from ohdearaugustin/topic/refactor-cli-versionCmd
Refactor cmdVersion to cli package
2021-07-25 12:00:06 +02:00
Juan Font
04fdd94201 Merge pull request #60 from cure/tls-more-readme-changes
Add some more detail to the README about the different Let's Encrypt
2021-07-25 11:38:31 +02:00
ohdearaugustin
48ec51d166 Refactor cmdVersion to cli package 2021-07-25 02:02:05 +02:00
Ward Vandewege
3260362436 Add some more detail to the README about the different Let's Encrypt
validation methods.
2021-07-24 09:20:38 -04:00
Ward Vandewege
5f60671d12 Merge pull request #59 from qbit/tls_letsencrypt_listen
Add a 'tls_letsencrypt_listen' config option
2021-07-24 09:03:04 -04:00
Aaron Bieber
69d77f6e9d Add a 'tls_letsencrypt_listen' config option
Currently the default (and non-configurable) Let's Encrypt listener will
bind to all IPs. This isn't ideal if we want to run headscale on a specific
IP only.

This also allows for one to set the listener to something other than
port 80. This is useful for OSs like OpenBSD which only allow root to
bind the lower port ranges (and don't have `setcap`) as we can now run
`headscale` as a non-privileged user while still using the baked in ACME
magic. Obviously this configuration would also require a reverse proxy
or firewall rule to redirect traffic. I attempted to outline that in the
README change.
2021-07-23 16:12:01 -06:00
Juan Font
1af9c11bdd Merge pull request #54 from juanfont/delete-nodes
Implement node deletion
2021-07-19 16:18:09 +02:00
Juan Font Alonso
57c115e60a Fix linting error: 2021-07-17 11:17:42 +02:00
Juan Font Alonso
96b4d2f391 Mark the machine as unregistered before soft delete 2021-07-17 11:12:24 +02:00
Juan Font Alonso
0f649aae8b Ask for confirmation before deleting 2021-07-17 11:09:42 +02:00
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 Alonso
39b756cf55 Fixed linting 2021-07-17 00:29:14 +02:00
Juan Font Alonso
9ca2ae7fc5 Implemented delete nodes (#52) 2021-07-17 00:23:12 +02:00
Juan Font Alonso
f3139d26c8 Added methods to delete nodes 2021-07-17 00:14:22 +02: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
94 changed files with 7557 additions and 1297 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
// integration tests are not needed in docker
// ignoring it let us speed up the integration test
// development
integration_test.go
integration_test/
Dockerfile*
docker-compose*
.dockerignore
.goreleaser.yml
.git
.github
.gitignore
README.md
LICENSE
.vscode

36
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: "1.16.3"
- name: Install dependencies
run: |
go version
go install golang.org/x/lint/golint@latest
sudo apt update
sudo apt install -y make
- name: Run lint
run: make build
- uses: actions/upload-artifact@v2
with:
name: headscale-linux
path: headscale

39
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: CI
on: [push, pull_request]
jobs:
# The "build" workflow
lint:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
# Install and run golangci-lint as a separate step, it's much faster this
# way because this action has caching. It'll get run again in `make lint`
# below, but it's still much faster in the end than installing
# golangci-lint manually in the `Run lint` step.
- uses: golangci/golangci-lint-action@v2
with:
args: --timeout 5m
# Setup Go
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: "1.16.3" # The Go version to download (if necessary) and use.
# Install all the dependencies
- name: Install dependencies
run: |
go version
go install golang.org/x/lint/golint@latest
sudo apt update
sudo apt install -y make
- name: Run lint
run: make lint

View File

@@ -1,13 +1,15 @@
name: goreleaser ---
name: release
on: on:
push: push:
tags: tags:
- "*" # triggers only if push new tag version - "*" # triggers only if push new tag version
workflow_dispatch:
jobs: jobs:
goreleaser: goreleaser:
runs-on: ubuntu-latest runs-on: ubuntu-18.04 # due to CGO we need to user an older version
steps: steps:
- -
name: Checkout name: Checkout
@@ -27,4 +29,49 @@ jobs:
version: latest version: latest
args: release --rm-dist args: release --rm-dist
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
docker-release:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
with:
fetch-depth: 0
-
name: Docker meta
id: meta
uses: docker/metadata-action@v3
with:
# list of Docker images to use as base name for tags
images: |
${{ secrets.DOCKERHUB_USERNAME }}/headscale
ghcr.io/${{ github.repository_owner }}/headscale
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
-
name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
-
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
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
context: .
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

23
.github/workflows/test-integration.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: CI
on: [pull_request]
jobs:
# The "build" workflow
integration-test:
# The type of runner that the job will run on
runs-on: ubuntu-latest
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
# Setup Go
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: "1.16.3"
- name: Run Integration tests
run: go test -tags integration -timeout 30m

View File

@@ -10,36 +10,24 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job # Steps represent a sequence of tasks that will be executed as part of the job
steps: steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2 - uses: actions/checkout@v2
# Install and run golangci-lint as a separate step, it's much faster this # Setup Go
# way because this action has caching. It'll get run again in `make lint` - name: Setup Go
# below, but it's still much faster in the end than installing uses: actions/setup-go@v2
# golangci-lint manually in the `Run lint` step. with:
- uses: golangci/golangci-lint-action@v2 go-version: "1.16.3" # The Go version to download (if necessary) and use.
with:
args: --timeout 2m
# Setup Go
- name: Setup Go
uses: actions/setup-go@v2
with:
go-version: '1.16.3' # The Go version to download (if necessary) and use.
# Install all the dependencies # Install all the dependencies
- name: Install dependencies - name: Install dependencies
run: | run: |
go version go version
go install golang.org/x/lint/golint@latest sudo apt update
sudo apt update sudo apt install -y make
sudo apt install -y make
- name: Run tests
run: make test
- name: Run lint - name: Run tests
run: make lint run: make test
- name: Run build - name: Run build
run: make run: make

6
.gitignore vendored
View File

@@ -18,3 +18,9 @@
config.json config.json
*.key *.key
/db.sqlite /db.sqlite
*.sqlite3
# Exclude Jetbrains Editors
.idea
test_output/

View File

@@ -19,7 +19,8 @@ builds:
flags: flags:
- -mod=readonly - -mod=readonly
ldflags: ldflags:
- -s -w -X main.version={{.Version}} - -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
- id: linux-armhf - id: linux-armhf
main: ./cmd/headscale/headscale.go main: ./cmd/headscale/headscale.go
mod_timestamp: '{{ .CommitTimestamp }}' mod_timestamp: '{{ .CommitTimestamp }}'
@@ -39,7 +40,7 @@ builds:
flags: flags:
- -mod=readonly - -mod=readonly
ldflags: ldflags:
- -s -w -X main.version={{.Version}} - -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
- id: linux-amd64 - id: linux-amd64
@@ -49,11 +50,20 @@ builds:
- linux - linux
goarch: goarch:
- amd64 - amd64
goarm:
- 6
- 7
main: ./cmd/headscale/headscale.go 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}}
- id: linux-arm64
goos:
- linux
goarch:
- arm64
main: ./cmd/headscale/headscale.go
mod_timestamp: '{{ .CommitTimestamp }}'
ldflags:
- -s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=v{{.Version}}
archives: archives:
- id: golang-cross - id: golang-cross
@@ -61,9 +71,9 @@ archives:
- darwin-amd64 - darwin-amd64
- linux-armhf - linux-armhf
- linux-amd64 - linux-amd64
- linux-arm64
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
format: zip format: binary
# wrap_in_directory: true
checksum: checksum:
name_template: 'checksums.txt' name_template: 'checksums.txt'

19
Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM golang:1.17.1-bullseye AS build
ENV GOPATH /go
COPY go.mod go.sum /go/src/headscale/
WORKDIR /go/src/headscale
RUN go mod download
COPY . /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 ubuntu:20.04
COPY --from=build /go/bin/headscale /usr/local/bin/headscale
ENV TZ UTC
EXPOSE 8080/tcp
CMD ["headscale"]

11
Dockerfile.tailscale Normal file
View File

@@ -0,0 +1,11 @@
FROM ubuntu:latest
ARG TAILSCALE_VERSION
RUN apt-get update \
&& apt-get install -y gnupg curl \
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.gpg | apt-key add - \
&& curl -fsSL https://pkgs.tailscale.com/stable/ubuntu/focal.list | tee /etc/apt/sources.list.d/tailscale.list \
&& apt-get update \
&& apt-get install -y tailscale=${TAILSCALE_VERSION} \
&& rm -rf /var/lib/apt/lists/*

View File

@@ -2,13 +2,16 @@
version = $(shell ./scripts/version-at-commit.sh) version = $(shell ./scripts/version-at-commit.sh)
build: build:
go build -ldflags "-s -w -X main.version=$(version)" cmd/headscale/headscale.go go build -ldflags "-s -w -X github.com/juanfont/headscale/cmd/headscale/cli.Version=$(version)" cmd/headscale/headscale.go
dev: lint test build dev: lint test build
test: test:
@go test -coverprofile=coverage.out ./... @go test -coverprofile=coverage.out ./...
test_integration:
go test -tags integration -timeout 30m ./...
coverprofile_func: coverprofile_func:
go tool cover -func=coverage.out go tool cover -func=coverage.out
@@ -17,7 +20,7 @@ coverprofile_html:
lint: lint:
golint golint
golangci-lint run golangci-lint run --timeout 5m
compress: build compress: build
upx --brute headscale upx --brute headscale

224
README.md
View File

@@ -1,8 +1,10 @@
# Headscale # 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/test.yml/badge.svg) ![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.
Join our [Discord](https://discord.gg/XcQxk2VHjx) server for a chat.
## Overview ## Overview
@@ -10,106 +12,185 @@ 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'. 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. Headscale implements this coordination server.
## Status ## 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] Node registration through the web flow
- [x] Network changes are relied to the nodes - [x] Network changes are relayed to the nodes
- [x] ~~Multiuser~~ Namespace support - [x] Namespace support (~equivalent to multi-user in Tailscale.com)
- [x] Basic routing (advertise & accept) - [x] Routing (advertise & accept, including exit nodes)
- [ ] Share nodes between ~~users~~ namespaces - [x] Node registration via pre-auth keys (including reusable keys, and ephemeral node support)
- [x] Node registration via pre-auth keys (including reusable keys and ephemeral node support) - [x] JSON-formatted output
- [X] JSON-formatted output - [x] ACLs
- [ ] ACLs - [x] Taildrop (File Sharing)
- [ ] DNS - [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] MagicDNS (see `docs/`)
... and probably lots of stuff missing ## Client OS support
| OS | Supports headscale |
| ------- | ----------------------------------------------------------------------------------------------------------------- |
| Linux | Yes |
| OpenBSD | Yes |
| macOS | Yes (see `/apple` on your headscale for more information) |
| Windows | Yes |
| Android | [You need to compile the client yourself](https://github.com/juanfont/headscale/issues/58#issuecomment-885255270) |
| iOS | Not yet |
## Roadmap 🤷 ## Roadmap 🤷
Basic multiuser support (multinamespace, actually) is now implemented. No node sharing or ACLs between namespaces yet though...
Suggestions/PRs welcomed! Suggestions/PRs welcomed!
## Running it ## Running it
1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH 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
```
<!--
or
```shell
docker pull ghrc.io/juanfont/headscale:x.x.x
``` -->
2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running 2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running
```shell ```shell
docker run --name headscale -e POSTGRES_DB=headscale -e \ docker run --name headscale -e POSTGRES_DB=headscale -e \
POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -p 5432:5432 -d postgres POSTGRES_USER=foo -e POSTGRES_PASSWORD=bar -p 5432:5432 -d postgres
``` ```
3. Set some stuff up (headscale Wireguard keys & the config.json file) 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 ```shell
cp config.json.postgres.example config.json wg genkey > private.key
# or wg pubkey < private.key > public.key # not needed
# SQLite
cp config.json.sqlite.example config.json # 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) 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 ```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 <container_name> headscale create myfirstnamespace
```
5. Run the server 5. Run the server
```shell
headscale serve
```
6. Add your first machine ```shell
```shell headscale serve
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. or docker:
8. In the server, register your machine to a namespace with the CLI the db.sqlite mount is only needed if you use sqlite
```shell
headscale -n myfirstnamespace node register YOURMACHINEKEY ```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 <container_name> headscale -n myfirstnamespace nodes register YOURMACHINEKEY
```
Alternatively, you can use Auth Keys to register your machines: Alternatively, you can use Auth Keys to register your machines:
1. Create an authkey 1. Create an authkey
```shell
headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h ```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 <container_name> headscale -n myfirstnamespace preauthkeys create --reusable --expiration 24h
```
2. Use the authkey from your machine to register it 2. Use the authkey from your machine to register it
```shell ```shell
tailscale up -login-server YOUR_HEADSCALE_URL --authkey YOURAUTHKEY 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. 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. 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 ## 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. 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:8000", "server_url": "http://192.168.1.12:8080",
"listen_addr": "0.0.0.0:8000", "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. `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": "private.key",
@@ -150,22 +231,43 @@ Headscale can be configured to expose its web service via TLS. To configure the
``` ```
"tls_letsencrypt_hostname": "", "tls_letsencrypt_hostname": "",
"tls_letsencrypt_listen": ":http",
"tls_letsencrypt_cache_dir": ".cache", "tls_letsencrypt_cache_dir": ".cache",
"tls_letsencrypt_challenge_type": "HTTP-01", "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. 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. 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 ## Disclaimer
1. We have nothing to do with Tailscale, or Tailscale Inc. 1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of writing this was to learn how Tailscale works. 2. The purpose of writing this was to learn how Tailscale works.
## More on Tailscale ## More on Tailscale
- https://tailscale.com/blog/how-tailscale-works/ - https://tailscale.com/blog/how-tailscale-works/
- https://tailscale.com/blog/tailscale-key-management/ - https://tailscale.com/blog/tailscale-key-management/
- https://tailscale.com/blog/an-unlikely-database-migration/ - https://tailscale.com/blog/an-unlikely-database-migration/

266
acls.go Normal file
View File

@@ -0,0 +1,266 @@
package headscale
import (
"encoding/json"
"fmt"
"io"
"os"
"strconv"
"strings"
"github.com/rs/zerolog/log"
"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.Error().
Msgf("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.Error().
Msgf("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
}

427
api.go
View File

@@ -3,19 +3,19 @@ package headscale
import ( import (
"encoding/binary" "encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"time" "time"
"github.com/rs/zerolog/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/klauspost/compress/zstd" "github.com/klauspost/compress/zstd"
"gorm.io/datatypes" "gorm.io/gorm"
"inet.af/netaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg" "tailscale.com/types/wgkey"
) )
// KeyHandler provides the Headscale pub key // KeyHandler provides the Headscale pub key
@@ -33,8 +33,6 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
return return
} }
// spew.Dump(c.Params)
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(fmt.Sprintf(` c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(fmt.Sprintf(`
<html> <html>
<body> <body>
@@ -45,7 +43,7 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
<p> <p>
<code> <code>
<b>headscale -n NAMESPACE node register %s</b> <b>headscale -n NAMESPACE nodes register %s</b>
</code> </code>
</p> </p>
@@ -60,91 +58,120 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
func (h *Headscale) RegistrationHandler(c *gin.Context) { func (h *Headscale) RegistrationHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body) body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id") mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr) mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil { if err != nil {
log.Printf("Cannot parse machine key: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot parse machine key")
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
c.String(http.StatusInternalServerError, "Sad!") c.String(http.StatusInternalServerError, "Sad!")
return return
} }
req := tailcfg.RegisterRequest{} req := tailcfg.RegisterRequest{}
err = decode(body, &req, &mKey, h.privateKey) err = decode(body, &req, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot decode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot decode message")
machineRegistrations.WithLabelValues("unkown", "web", "error", "unknown").Inc()
c.String(http.StatusInternalServerError, "Very sad!") c.String(http.StatusInternalServerError, "Very sad!")
return return
} }
db, err := h.db() now := time.Now().UTC()
if err != nil {
log.Printf("Cannot open DB: %s", err)
c.String(http.StatusInternalServerError, ":(")
return
}
defer db.Close()
var m Machine 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!") log.Info().Str("machine", req.Hostinfo.Hostname).Msg("New machine")
m = Machine{ m = Machine{
Expiry: &req.Expiry, Expiry: &req.Expiry,
MachineKey: mKey.HexString(), MachineKey: mKey.HexString(),
Name: req.Hostinfo.Hostname, Name: req.Hostinfo.Hostname,
NodeKey: wgcfg.Key(req.NodeKey).HexString(), NodeKey: wgkey.Key(req.NodeKey).HexString(),
LastSuccessfulUpdate: &now,
} }
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) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Could not create row")
machineRegistrations.WithLabelValues("unkown", "web", "error", m.Namespace.Name).Inc()
return return
} }
} }
if !m.Registered && req.Auth.AuthKey != "" { if !m.Registered && req.Auth.AuthKey != "" {
h.handleAuthKey(c, db, mKey, req, m) h.handleAuthKey(c, h.db, mKey, req, m)
return return
} }
resp := tailcfg.RegisterResponse{} resp := tailcfg.RegisterResponse{}
// We have the updated key! // We have the updated key!
if m.NodeKey == wgcfg.Key(req.NodeKey).HexString() { if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
if m.Registered { if m.Registered {
log.Printf("[%s] Client is registered and we have the current NodeKey. All clear to /map", m.Name) log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("Client is registered and we have the current NodeKey. All clear to /map")
resp.AuthURL = "" resp.AuthURL = ""
resp.MachineAuthorized = true resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser() resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey) respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
machineRegistrations.WithLabelValues("update", "web", "error", m.Namespace.Name).Inc()
c.String(http.StatusInternalServerError, "") c.String(http.StatusInternalServerError, "")
return return
} }
machineRegistrations.WithLabelValues("update", "web", "success", m.Namespace.Name).Inc()
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(200, "application/json; charset=utf-8", respBody)
return return
} }
log.Printf("[%s] Not registered and not NodeKey rotation. Sending a authurl to register", m.Name) log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("Not registered and not NodeKey rotation. Sending a authurl to register")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s", resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString()) h.cfg.ServerURL, mKey.HexString())
respBody, err := encode(resp, &mKey, h.privateKey) respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
machineRegistrations.WithLabelValues("new", "web", "error", m.Namespace.Name).Inc()
c.String(http.StatusInternalServerError, "") c.String(http.StatusInternalServerError, "")
return return
} }
machineRegistrations.WithLabelValues("new", "web", "success", m.Namespace.Name).Inc()
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(200, "application/json; charset=utf-8", respBody)
return return
} }
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration // 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) log.Debug().
m.NodeKey = wgcfg.Key(req.NodeKey).HexString() Str("handler", "Registration").
db.Save(&m) Str("machine", m.Name).
Msg("We have the OldNodeKey in the database. This is a key refresh")
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
h.db.Save(&m)
resp.AuthURL = "" resp.AuthURL = ""
resp.User = *m.Namespace.toUser() resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey) respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "Extremely sad!") c.String(http.StatusInternalServerError, "Extremely sad!")
return return
} }
@@ -155,234 +182,108 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
// We arrive here after a client is restarted without finalizing the authentication flow or // We arrive here after a client is restarted without finalizing the authentication flow or
// when headscale is stopped in the middle of the auth process. // when headscale is stopped in the middle of the auth process.
if m.Registered { if m.Registered {
log.Printf("[%s] The node is sending us a new NodeKey, but machine is registered. All clear for /map", m.Name) log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("The node is sending us a new NodeKey, but machine is registered. All clear for /map")
resp.AuthURL = "" resp.AuthURL = ""
resp.MachineAuthorized = true resp.MachineAuthorized = true
resp.User = *m.Namespace.toUser() resp.User = *m.Namespace.toUser()
respBody, err := encode(resp, &mKey, h.privateKey) respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "") c.String(http.StatusInternalServerError, "")
return return
} }
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(200, "application/json; charset=utf-8", respBody)
return return
} }
log.Printf("[%s] The node is sending us a new NodeKey, sending auth url", m.Name)
log.Debug().
Str("handler", "Registration").
Str("machine", m.Name).
Msg("The node is sending us a new NodeKey, sending auth url")
resp.AuthURL = fmt.Sprintf("%s/register?key=%s", resp.AuthURL = fmt.Sprintf("%s/register?key=%s",
h.cfg.ServerURL, mKey.HexString()) h.cfg.ServerURL, mKey.HexString())
respBody, err := encode(resp, &mKey, h.privateKey) respBody, err := encode(resp, &mKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("handler", "Registration").
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "") c.String(http.StatusInternalServerError, "")
return return
} }
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(200, "application/json; charset=utf-8", respBody)
} }
// PollNetMapHandler takes care of /machine/:id/map func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m *Machine) ([]byte, error) {
// log.Trace().
// This is the busiest endpoint, as it keeps the HTTP long poll that updates Str("func", "getMapResponse").
// the clients when something in the network changes. Str("machine", req.Hostinfo.Hostname).
// Msg("Creating Map response")
// The clients POST stuff like HostInfo and their Endpoints here, but node, err := m.toNode(h.cfg.BaseDomain, h.cfg.DNSConfig, true)
// only after their first request (marked with the ReadOnly field).
//
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (h *Headscale) PollNetMapHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr)
if err != nil { if err != nil {
log.Printf("Cannot parse client key: %s", err) log.Error().
return Str("func", "getMapResponse").
} Err(err).
req := tailcfg.MapRequest{} Msg("Cannot convert to node")
err = decode(body, &req, &mKey, h.privateKey)
if err != nil {
log.Printf("Cannot decode message: %s", err)
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() {
log.Printf("Ignoring request, cannot find machine with key %s", mKey.HexString())
return
}
hostinfo, _ := json.Marshal(req.Hostinfo)
m.Name = req.Hostinfo.Hostname
m.HostInfo = datatypes.JSON(hostinfo)
m.DiscoKey = wgcfg.Key(req.DiscoKey).HexString()
now := time.Now().UTC()
// From Tailscale client:
//
// ReadOnly is whether the client just wants to fetch the MapResponse,
// without updating their Endpoints. The Endpoints field will be ignored and
// LastSeen will not be updated and peers will not be notified of changes.
//
// The intended use is for clients to discover the DERP map at start-up
// before their first real endpoint update.
if !req.ReadOnly {
endpoints, _ := json.Marshal(req.Endpoints)
m.Endpoints = datatypes.JSON(endpoints)
m.LastSeen = &now
}
db.Save(&m)
pollData := make(chan []byte, 1)
update := make(chan []byte, 1)
cancelKeepAlive := make(chan []byte, 1)
defer close(pollData)
defer close(cancelKeepAlive)
h.pollMu.Lock()
h.clientsPolling[m.ID] = update
h.pollMu.Unlock()
data, err := h.getMapResponse(mKey, req, m)
if err != nil {
c.String(http.StatusInternalServerError, ":(")
return
}
// We update our peers if the client is not sending ReadOnly in the MapRequest
// so we don't distribute its initial request (it comes with
// empty endpoints to peers)
// Details on the protocol can be found in https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L696
log.Printf("[%s] ReadOnly=%t OmitPeers=%t Stream=%t", m.Name, req.ReadOnly, req.OmitPeers, req.Stream)
if req.ReadOnly {
log.Printf("[%s] Client is starting up. Asking for DERP map", m.Name)
c.Data(200, "application/json; charset=utf-8", *data)
return
}
if req.OmitPeers && !req.Stream {
log.Printf("[%s] Client sent endpoint update and is ok with a response without peer list", m.Name)
c.Data(200, "application/json; charset=utf-8", *data)
return
} else if req.OmitPeers && req.Stream {
log.Printf("[%s] Warning, ignoring request, don't know how to handle it", m.Name)
c.String(http.StatusBadRequest, "")
return
}
log.Printf("[%s] Client is ready to access the tailnet", m.Name)
log.Printf("[%s] Sending initial map", m.Name)
pollData <- *data
log.Printf("[%s] Notifying peers", m.Name)
peers, _ := h.getPeers(m)
h.pollMu.Lock()
for _, p := range *peers {
pUp, ok := h.clientsPolling[uint64(p.ID)]
if ok {
log.Printf("[%s] Notifying peer %s (%s)", m.Name, p.Name, p.Addresses[0])
pUp <- []byte{}
} else {
log.Printf("[%s] Peer %s does not appear to be polling", m.Name, p.Name)
}
}
h.pollMu.Unlock()
go h.keepAlive(cancelKeepAlive, pollData, mKey, req, m)
c.Stream(func(w io.Writer) bool {
select {
case data := <-pollData:
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)
}
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
return true
case <-update:
log.Printf("[%s] Received a request for update", m.Name)
data, err := h.getMapResponse(mKey, req, m)
if err != nil {
log.Printf("[%s] Could not get the map update: %s", m.Name, err)
}
_, err = w.Write(*data)
if err != nil {
log.Printf("[%s] Could not write the map response: %s", m.Name, err)
}
return true
case <-c.Request.Context().Done():
log.Printf("[%s] The client has closed the connection", m.Name)
now := time.Now().UTC()
m.LastSeen = &now
db.Save(&m)
h.pollMu.Lock()
cancelKeepAlive <- []byte{}
delete(h.clientsPolling, m.ID)
close(update)
h.pollMu.Unlock()
return false
}
})
}
func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) {
for {
select {
case <-cancel:
return
default:
h.pollMu.Lock()
data, err := h.getMapKeepAliveResponse(mKey, req, m)
if err != nil {
log.Printf("Error generating the keep alive msg: %s", err)
return
}
log.Printf("[%s] Sending keepalive", m.Name)
pollData <- *data
h.pollMu.Unlock()
time.Sleep(60 * time.Second)
}
}
}
func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
node, err := m.toNode()
if err != nil {
log.Printf("Cannot convert to node: %s", err)
return nil, err return nil, err
} }
peers, err := h.getPeers(m) peers, err := h.getPeers(m)
if err != nil { if err != nil {
log.Printf("Cannot fetch peers: %s", err) log.Error().
Str("func", "getMapResponse").
Err(err).
Msg("Cannot fetch peers")
return nil, err return nil, err
} }
profiles := getMapResponseUserProfiles(*m, peers)
nodePeers, err := peers.toNodes(h.cfg.BaseDomain, h.cfg.DNSConfig, true)
if err != nil {
log.Error().
Str("func", "getMapResponse").
Err(err).
Msg("Failed to convert peers to Tailscale nodes")
return nil, err
}
dnsConfig, err := getMapResponseDNSConfig(h.cfg.DNSConfig, h.cfg.BaseDomain, *m, peers)
if err != nil {
log.Error().
Str("func", "getMapResponse").
Err(err).
Msg("Failed generate the DNSConfig")
return nil, err
}
resp := tailcfg.MapResponse{ resp := tailcfg.MapResponse{
KeepAlive: false, KeepAlive: false,
Node: node, Node: node,
Peers: *peers, Peers: nodePeers,
DNS: []netaddr.IP{}, DNSConfig: dnsConfig,
SearchPaths: []string{}, Domain: h.cfg.BaseDomain,
Domain: "foobar@example.com", PacketFilter: *h.aclRules,
PacketFilter: tailcfg.FilterAllowAll,
DERPMap: h.cfg.DerpMap, DERPMap: h.cfg.DerpMap,
UserProfiles: []tailcfg.UserProfile{}, UserProfiles: profiles,
Roles: []tailcfg.Role{},
} }
log.Trace().
Str("func", "getMapResponse").
Str("machine", req.Hostinfo.Hostname).
// Interface("payload", resp).
Msgf("Generated map response: %s", tailMapResponseToString(resp))
var respBody []byte var respBody []byte
if req.Compress == "zstd" { if req.Compress == "zstd" {
src, _ := json.Marshal(resp) src, _ := json.Marshal(resp)
encoder, _ := zstd.NewWriter(nil) encoder, _ := zstd.NewWriter(nil)
srcCompressed := encoder.EncodeAll(src, nil) srcCompressed := encoder.EncodeAll(src, nil)
respBody, err = encodeMsg(srcCompressed, &mKey, h.privateKey) respBody, err = encodeMsg(srcCompressed, &mKey, h.privateKey)
@@ -395,15 +296,14 @@ func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Mac
return nil, err return nil, err
} }
} }
// spew.Dump(resp)
// declare the incoming size on the first 4 bytes // declare the incoming size on the first 4 bytes
data := make([]byte, 4) data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(respBody))) binary.LittleEndian.PutUint32(data, uint32(len(respBody)))
data = append(data, respBody...) data = append(data, respBody...)
return &data, nil 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{ resp := tailcfg.MapResponse{
KeepAlive: true, KeepAlive: true,
} }
@@ -426,46 +326,91 @@ func (h *Headscale) getMapKeepAliveResponse(mKey wgcfg.Key, req tailcfg.MapReque
data := make([]byte, 4) data := make([]byte, 4)
binary.LittleEndian.PutUint32(data, uint32(len(respBody))) binary.LittleEndian.PutUint32(data, uint32(len(respBody)))
data = append(data, respBody...) data = append(data, respBody...)
return &data, nil 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) {
log.Debug().
Str("func", "handleAuthKey").
Str("machine", req.Hostinfo.Hostname).
Msgf("Processing auth key for %s", req.Hostinfo.Hostname)
resp := tailcfg.RegisterResponse{} resp := tailcfg.RegisterResponse{}
pak, err := h.checkKeyValidity(req.Auth.AuthKey) pak, err := h.checkKeyValidity(req.Auth.AuthKey)
if err != nil { if err != nil {
log.Error().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Err(err).
Msg("Failed authentication via AuthKey")
resp.MachineAuthorized = false resp.MachineAuthorized = false
respBody, err := encode(resp, &idKey, h.privateKey) respBody, err := encode(resp, &idKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Err(err).
Msg("Cannot encode message")
c.String(http.StatusInternalServerError, "") c.String(http.StatusInternalServerError, "")
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
return return
} }
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(401, "application/json; charset=utf-8", respBody)
log.Printf("[%s] Failed authentication via AuthKey", m.Name) log.Error().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Msg("Failed authentication via AuthKey")
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
return return
} }
log.Debug().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Msg("Authentication key was valid, proceeding to acquire an IP address")
ip, err := h.getAvailableIP() ip, err := h.getAvailableIP()
if err != nil { if err != nil {
log.Println(err) log.Error().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Msg("Failed to find an available IP")
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
return return
} }
log.Info().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Str("ip", ip.String()).
Msgf("Assigning %s to %s", ip, m.Name)
m.AuthKeyID = uint(pak.ID) m.AuthKeyID = uint(pak.ID)
m.IPAddress = ip.String() m.IPAddress = ip.String()
m.NamespaceID = pak.NamespaceID 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.Registered = true
m.RegisterMethod = "authKey" m.RegisterMethod = "authKey"
db.Save(&m) db.Save(&m)
pak.Used = true
db.Save(&pak)
resp.MachineAuthorized = true resp.MachineAuthorized = true
resp.User = *pak.Namespace.toUser() resp.User = *pak.Namespace.toUser()
respBody, err := encode(resp, &idKey, h.privateKey) respBody, err := encode(resp, &idKey, h.privateKey)
if err != nil { if err != nil {
log.Printf("Cannot encode message: %s", err) log.Error().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Err(err).
Msg("Cannot encode message")
machineRegistrations.WithLabelValues("new", "authkey", "error", m.Namespace.Name).Inc()
c.String(http.StatusInternalServerError, "Extremely sad!") c.String(http.StatusInternalServerError, "Extremely sad!")
return return
} }
machineRegistrations.WithLabelValues("new", "authkey", "success", m.Namespace.Name).Inc()
c.Data(200, "application/json; charset=utf-8", respBody) c.Data(200, "application/json; charset=utf-8", respBody)
log.Printf("[%s] Successfully authenticated via AuthKey", m.Name) log.Info().
Str("func", "handleAuthKey").
Str("machine", m.Name).
Str("ip", ip.String()).
Msg("Successfully authenticated via AuthKey")
} }

172
app.go
View File

@@ -3,17 +3,24 @@ package headscale
import ( import (
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"os" "os"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/rs/zerolog/log"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
ginprometheus "github.com/zsais/go-gin-prometheus"
"golang.org/x/crypto/acme"
"golang.org/x/crypto/acme/autocert" "golang.org/x/crypto/acme/autocert"
"gorm.io/gorm"
"inet.af/netaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg" "tailscale.com/types/dnstype"
"tailscale.com/types/wgkey"
) )
// Config contains the initial Headscale configuration // Config contains the initial Headscale configuration
@@ -23,6 +30,8 @@ type Config struct {
PrivateKeyPath string PrivateKeyPath string
DerpMap *tailcfg.DERPMap DerpMap *tailcfg.DERPMap
EphemeralNodeInactivityTimeout time.Duration EphemeralNodeInactivityTimeout time.Duration
IPPrefix netaddr.IPPrefix
BaseDomain string
DBtype string DBtype string
DBpath string DBpath string
@@ -32,25 +41,34 @@ type Config struct {
DBuser string DBuser string
DBpass string DBpass string
TLSLetsEncryptListen string
TLSLetsEncryptHostname string TLSLetsEncryptHostname string
TLSLetsEncryptCacheDir string TLSLetsEncryptCacheDir string
TLSLetsEncryptChallengeType string TLSLetsEncryptChallengeType string
TLSCertPath string TLSCertPath string
TLSKeyPath string TLSKeyPath string
ACMEURL string
ACMEEmail string
DNSConfig *tailcfg.DNSConfig
} }
// Headscale represents the base app of the service // Headscale represents the base app of the service
type Headscale struct { type Headscale struct {
cfg Config cfg Config
db *gorm.DB
dbString string dbString string
dbType string dbType string
dbDebug bool dbDebug bool
publicKey *wgcfg.Key publicKey *wgkey.Key
privateKey *wgcfg.PrivateKey privateKey *wgkey.Private
pollMu sync.Mutex aclPolicy *ACLPolicy
clientsPolling map[uint64]chan []byte // this is by all means a hackity hack aclRules *[]tailcfg.FilterRule
lastStateChange sync.Map
} }
// NewHeadscale returns the Headscale app // NewHeadscale returns the Headscale app
@@ -59,7 +77,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
privKey, err := wgcfg.ParsePrivateKey(string(content)) privKey, err := wgkey.ParsePrivate(string(content))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -73,7 +91,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
case "sqlite3": case "sqlite3":
dbString = cfg.DBpath dbString = cfg.DBpath
default: default:
return nil, errors.New("Unsupported DB") return nil, errors.New("unsupported DB")
} }
h := Headscale{ h := Headscale{
@@ -82,12 +100,28 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
dbString: dbString, dbString: dbString,
privateKey: privKey, privateKey: privKey,
publicKey: &pubKey, publicKey: &pubKey,
aclRules: &tailcfg.FilterAllowAll, // default allowall
} }
err = h.initDB() err = h.initDB()
if err != nil { if err != nil {
return nil, err return nil, err
} }
h.clientsPolling = make(map[uint64]chan []byte)
if h.cfg.DNSConfig != nil && h.cfg.DNSConfig.Proxied { // if MagicDNS
magicDNSDomains, err := generateMagicDNSRootDomains(h.cfg.IPPrefix, h.cfg.BaseDomain)
if err != nil {
return nil, err
}
// we might have routes already from Split DNS
if h.cfg.DNSConfig.Routes == nil {
h.cfg.DNSConfig.Routes = make(map[string][]dnstype.Resolver)
}
for _, d := range magicDNSDomains {
h.cfg.DNSConfig.Routes[d.WithoutTrailingDot()] = nil
}
}
return &h, nil return &h, nil
} }
@@ -97,9 +131,9 @@ func (h *Headscale) redirect(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, target, http.StatusFound) http.Redirect(w, req, target, http.StatusFound)
} }
// ExpireEphemeralNodes deletes ephemeral machine records that have not been // expireEphemeralNodes deletes ephemeral machine records that have not been
// seen for longer than h.cfg.EphemeralNodeInactivityTimeout // seen for longer than h.cfg.EphemeralNodeInactivityTimeout
func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) { func (h *Headscale) expireEphemeralNodes(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond) ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C { for range ticker.C {
h.expireEphemeralNodesWorker() h.expireEphemeralNodesWorker()
@@ -107,85 +141,151 @@ func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
} }
func (h *Headscale) expireEphemeralNodesWorker() { 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() namespaces, err := h.ListNamespaces()
if err != nil { if err != nil {
log.Printf("Error listing namespaces: %s", err) log.Error().Err(err).Msg("Error listing namespaces")
return return
} }
for _, ns := range *namespaces { for _, ns := range *namespaces {
machines, err := h.ListMachinesInNamespace(ns.Name) machines, err := h.ListMachinesInNamespace(ns.Name)
if err != nil { if err != nil {
log.Printf("Error listing machines in namespace %s: %s", ns.Name, err) log.Error().Err(err).Str("namespace", ns.Name).Msg("Error listing machines in namespace")
return return
} }
for _, m := range *machines { 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.Printf("[%s] Ephemeral client removed from database\n", m.Name) log.Info().Str("machine", m.Name).Msg("Ephemeral client removed from database")
err = db.Unscoped().Delete(m).Error err = h.db.Unscoped().Delete(m).Error
if err != nil { if err != nil {
log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err) log.Error().Err(err).Str("machine", m.Name).Msg("🤮 Cannot delete ephemeral machine from the database")
} }
} }
} }
h.setLastStateChangeToNow(ns.Name)
} }
} }
// WatchForKVUpdates checks the KV DB table for requests to perform tailnet upgrades
// This is a way to communitate the CLI with the headscale server
func (h *Headscale) watchForKVUpdates(milliSeconds int64) {
ticker := time.NewTicker(time.Duration(milliSeconds) * time.Millisecond)
for range ticker.C {
h.watchForKVUpdatesWorker()
}
}
func (h *Headscale) watchForKVUpdatesWorker() {
h.checkForNamespacesPendingUpdates()
// more functions will come here in the future
}
// Serve launches a GIN server with the Headscale API // Serve launches a GIN server with the Headscale API
func (h *Headscale) Serve() error { func (h *Headscale) Serve() error {
r := gin.Default() r := gin.Default()
p := ginprometheus.NewPrometheus("gin")
p.Use(r)
r.GET("/health", func(c *gin.Context) { c.JSON(200, gin.H{"healthy": "ok"}) })
r.GET("/key", h.KeyHandler) r.GET("/key", h.KeyHandler)
r.GET("/register", h.RegisterWebAPI) r.GET("/register", h.RegisterWebAPI)
r.POST("/machine/:id/map", h.PollNetMapHandler) r.POST("/machine/:id/map", h.PollNetMapHandler)
r.POST("/machine/:id", h.RegistrationHandler) r.POST("/machine/:id", h.RegistrationHandler)
r.GET("/apple", h.AppleMobileConfig)
r.GET("/apple/:platform", h.ApplePlatformConfig)
var err error var err error
go h.watchForKVUpdates(5000)
go h.expireEphemeralNodes(5000)
s := &http.Server{
Addr: h.cfg.Addr,
Handler: r,
ReadTimeout: 30 * time.Second,
// Go does not handle timeouts in HTTP very well, and there is
// no good way to handle streaming timeouts, therefore we need to
// keep this at unlimited and be careful to clean up connections
// https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/#aboutstreaming
WriteTimeout: 0,
}
if h.cfg.TLSLetsEncryptHostname != "" { if h.cfg.TLSLetsEncryptHostname != "" {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") { if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
log.Println("WARNING: listening with TLS but ServerURL does not start with https://") log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
} }
m := autocert.Manager{ m := autocert.Manager{
Prompt: autocert.AcceptTOS, Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname), HostPolicy: autocert.HostWhitelist(h.cfg.TLSLetsEncryptHostname),
Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir), Cache: autocert.DirCache(h.cfg.TLSLetsEncryptCacheDir),
Client: &acme.Client{
DirectoryURL: h.cfg.ACMEURL,
},
Email: h.cfg.ACMEEmail,
} }
s := &http.Server{
Addr: h.cfg.Addr, s.TLSConfig = m.TLSConfig()
TLSConfig: m.TLSConfig(),
Handler: r,
}
if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" { if h.cfg.TLSLetsEncryptChallengeType == "TLS-ALPN-01" {
// Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737) // Configuration via autocert with TLS-ALPN-01 (https://tools.ietf.org/html/rfc8737)
// The RFC requires that the validation is done on port 443; in other words, headscale // The RFC requires that the validation is done on port 443; in other words, headscale
// must be configured to run on port 443. // must be reachable on port 443.
err = s.ListenAndServeTLS("", "") err = s.ListenAndServeTLS("", "")
} else if h.cfg.TLSLetsEncryptChallengeType == "HTTP-01" { } else if h.cfg.TLSLetsEncryptChallengeType == "HTTP-01" {
// Configuration via autocert with HTTP-01. This requires listening on // Configuration via autocert with HTTP-01. This requires listening on
// port 80 for the certificate validation in addition to the headscale // port 80 for the certificate validation in addition to the headscale
// service, which can be configured to run on any other port. // service, which can be configured to run on any other port.
go func() { go func() {
log.Fatal(http.ListenAndServe(":http", m.HTTPHandler(http.HandlerFunc(h.redirect)))) log.Fatal().
Err(http.ListenAndServe(h.cfg.TLSLetsEncryptListen, m.HTTPHandler(http.HandlerFunc(h.redirect)))).
Msg("failed to set up a HTTP server")
}() }()
err = s.ListenAndServeTLS("", "") err = s.ListenAndServeTLS("", "")
} else { } else {
return errors.New("Unknown value for TLSLetsEncryptChallengeType") return errors.New("unknown value for TLSLetsEncryptChallengeType")
} }
} else if h.cfg.TLSCertPath == "" { } else if h.cfg.TLSCertPath == "" {
if !strings.HasPrefix(h.cfg.ServerURL, "http://") { if !strings.HasPrefix(h.cfg.ServerURL, "http://") {
log.Println("WARNING: listening without TLS but ServerURL does not start with http://") log.Warn().Msg("Listening without TLS but ServerURL does not start with http://")
} }
err = r.Run(h.cfg.Addr) err = s.ListenAndServe()
} else { } else {
if !strings.HasPrefix(h.cfg.ServerURL, "https://") { if !strings.HasPrefix(h.cfg.ServerURL, "https://") {
log.Println("WARNING: listening with TLS but ServerURL does not start with https://") log.Warn().Msg("Listening with TLS but ServerURL does not start with https://")
} }
err = r.RunTLS(h.cfg.Addr, h.cfg.TLSCertPath, h.cfg.TLSKeyPath) err = s.ListenAndServeTLS(h.cfg.TLSCertPath, h.cfg.TLSKeyPath)
} }
return err return err
} }
func (h *Headscale) setLastStateChangeToNow(namespace string) {
now := time.Now().UTC()
lastStateUpdate.WithLabelValues("", "headscale").Set(float64(now.Unix()))
h.lastStateChange.Store(namespace, now)
}
func (h *Headscale) getLastStateChange(namespaces ...string) time.Time {
times := []time.Time{}
for _, namespace := range namespaces {
if wrapped, ok := h.lastStateChange.Load(namespace); ok {
lastChange, _ := wrapped.(time.Time)
times = append(times, lastChange)
}
}
sort.Slice(times, func(i, j int) bool {
return times[i].After(times[j])
})
log.Trace().Msgf("Latest times %#v", times)
if len(times) == 0 {
return time.Now().UTC()
} else {
return times[0]
}
}

View File

@@ -5,9 +5,8 @@ import (
"os" "os"
"testing" "testing"
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
"gopkg.in/check.v1" "gopkg.in/check.v1"
"inet.af/netaddr"
) )
func Test(t *testing.T) { func Test(t *testing.T) {
@@ -38,7 +37,9 @@ func (s *Suite) ResetDB(c *check.C) {
if err != nil { if err != nil {
c.Fatal(err) c.Fatal(err)
} }
cfg := Config{} cfg := Config{
IPPrefix: netaddr.MustParseIPPrefix("10.27.0.0/23"),
}
h = Headscale{ h = Headscale{
cfg: cfg, cfg: cfg,
@@ -49,4 +50,9 @@ func (s *Suite) ResetDB(c *check.C) {
if err != nil { if err != nil {
c.Fatal(err) c.Fatal(err)
} }
db, err := h.openDB()
if err != nil {
c.Fatal(err)
}
h.db = db
} }

226
apple_mobileconfig.go Normal file
View File

@@ -0,0 +1,226 @@
package headscale
import (
"bytes"
"net/http"
"text/template"
"github.com/rs/zerolog/log"
"github.com/gin-gonic/gin"
"github.com/gofrs/uuid"
)
// AppleMobileConfig shows a simple message in the browser to point to the CLI
// Listens in /register
func (h *Headscale) AppleMobileConfig(c *gin.Context) {
t := template.Must(template.New("apple").Parse(`
<html>
<body>
<h1>Apple configuration profiles</h1>
<p>
This page provides <a href="https://support.apple.com/guide/mdm/mdm-overview-mdmbf9e668/web">configuration profiles</a> for the official Tailscale clients for <a href="https://apps.apple.com/us/app/tailscale/id1470499037?ls=1">iOS</a> and <a href="https://apps.apple.com/ca/app/tailscale/id1475387142?mt=12">macOS</a>.
</p>
<p>
The profiles will configure Tailscale.app to use {{.Url}} as its control server.
</p>
<h3>Caution</h3>
<p>You should always inspect the profile before installing it:</p>
<!--
<p><code>curl {{.Url}}/apple/ios</code></p>
-->
<p><code>curl {{.Url}}/apple/macos</code></p>
<h2>Profiles</h2>
<!--
<h3>iOS</h3>
<p>
<a href="/apple/ios" download="headscale_ios.mobileconfig">iOS profile</a>
</p>
-->
<h3>macOS</h3>
<p>Headscale can be set to the default server by installing a Headscale configuration profile:</p>
<p>
<a href="/apple/macos" download="headscale_macos.mobileconfig">macOS profile</a>
</p>
<ol>
<li>Download the profile, then open it. When it has been opened, there should be a notification that a profile can be installed</li>
<li>Open System Preferences and go to "Profiles"</li>
<li>Find and install the Headscale profile</li>
<li>Restart Tailscale.app and log in</li>
</ol>
<p>Or</p>
<p>Use your terminal to configure the default setting for Tailscale by issuing:</p>
<code>defaults write io.tailscale.ipn.macos ControlURL {{.Url}}</code>
<p>Restart Tailscale.app and log in.</p>
</body>
</html>`))
config := map[string]interface{}{
"Url": h.cfg.ServerURL,
}
var payload bytes.Buffer
if err := t.Execute(&payload, config); err != nil {
log.Error().
Str("handler", "AppleMobileConfig").
Err(err).
Msg("Could not render Apple index template")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple index template"))
return
}
c.Data(http.StatusOK, "text/html; charset=utf-8", payload.Bytes())
}
func (h *Headscale) ApplePlatformConfig(c *gin.Context) {
platform := c.Param("platform")
id, err := uuid.NewV4()
if err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Failed not create UUID")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to create UUID"))
return
}
contentId, err := uuid.NewV4()
if err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Failed not create UUID")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Failed to create UUID"))
return
}
platformConfig := AppleMobilePlatformConfig{
UUID: contentId,
Url: h.cfg.ServerURL,
}
var payload bytes.Buffer
switch platform {
case "macos":
if err := macosTemplate.Execute(&payload, platformConfig); err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Could not render Apple macOS template")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple macOS template"))
return
}
case "ios":
if err := iosTemplate.Execute(&payload, platformConfig); err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Could not render Apple iOS template")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple iOS template"))
return
}
default:
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte("Invalid platform, only ios and macos is supported"))
return
}
config := AppleMobileConfig{
UUID: id,
Url: h.cfg.ServerURL,
Payload: payload.String(),
}
var content bytes.Buffer
if err := commonTemplate.Execute(&content, config); err != nil {
log.Error().
Str("handler", "ApplePlatformConfig").
Err(err).
Msg("Could not render Apple platform template")
c.Data(http.StatusInternalServerError, "text/html; charset=utf-8", []byte("Could not render Apple platform template"))
return
}
c.Data(http.StatusOK, "application/x-apple-aspen-config; charset=utf-8", content.Bytes())
}
type AppleMobileConfig struct {
UUID uuid.UUID
Url string
Payload string
}
type AppleMobilePlatformConfig struct {
UUID uuid.UUID
Url string
}
var commonTemplate = template.Must(template.New("mobileconfig").Parse(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadDisplayName</key>
<string>Headscale</string>
<key>PayloadDescription</key>
<string>Configure Tailscale login server to: {{.Url}}</string>
<key>PayloadIdentifier</key>
<string>com.github.juanfont.headscale</string>
<key>PayloadRemovalDisallowed</key>
<false/>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadContent</key>
<array>
{{.Payload}}
</array>
</dict>
</plist>`))
var iosTemplate = template.Must(template.New("iosTemplate").Parse(`
<dict>
<key>PayloadType</key>
<string>io.tailscale.ipn.ios</string>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadIdentifier</key>
<string>com.github.juanfont.headscale</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadEnabled</key>
<true/>
<key>ControlURL</key>
<string>{{.Url}}</string>
</dict>
`))
var macosTemplate = template.Must(template.New("macosTemplate").Parse(`
<dict>
<key>PayloadType</key>
<string>io.tailscale.ipn.macos</string>
<key>PayloadUUID</key>
<string>{{.UUID}}</string>
<key>PayloadIdentifier</key>
<string>com.github.juanfont.headscale</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadEnabled</key>
<true/>
<key>ControlURL</key>
<string>{{.Url}}</string>
</dict>
`))

17
cli.go
View File

@@ -2,9 +2,9 @@ package headscale
import ( import (
"errors" "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 // 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 { if err != nil {
return nil, err return nil, err
} }
mKey, err := wgcfg.ParseHexKey(key) mKey, err := wgkey.ParseHex(key)
if err != nil { if err != nil {
return nil, err 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{} 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") 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.NamespaceID = ns.ID
m.Registered = true m.Registered = true
m.RegisterMethod = "cli" m.RegisterMethod = "cli"
db.Save(&m) h.db.Save(&m)
return &m, nil return &m, nil
} }

View File

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

View File

@@ -3,17 +3,27 @@ package cli
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"github.com/pterm/pterm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var NamespaceCmd = &cobra.Command{ func init() {
Use: "namespace", rootCmd.AddCommand(namespaceCmd)
namespaceCmd.AddCommand(createNamespaceCmd)
namespaceCmd.AddCommand(listNamespacesCmd)
namespaceCmd.AddCommand(destroyNamespaceCmd)
namespaceCmd.AddCommand(renameNamespaceCmd)
}
var namespaceCmd = &cobra.Command{
Use: "namespaces",
Short: "Manage the namespaces of Headscale", Short: "Manage the namespaces of Headscale",
} }
var CreateNamespaceCmd = &cobra.Command{ var createNamespaceCmd = &cobra.Command{
Use: "create NAME", Use: "create NAME",
Short: "Creates a new namespace", Short: "Creates a new namespace",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
@@ -41,7 +51,7 @@ var CreateNamespaceCmd = &cobra.Command{
}, },
} }
var DestroyNamespaceCmd = &cobra.Command{ var destroyNamespaceCmd = &cobra.Command{
Use: "destroy NAME", Use: "destroy NAME",
Short: "Destroys a namespace", Short: "Destroys a namespace",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
@@ -69,7 +79,7 @@ var DestroyNamespaceCmd = &cobra.Command{
}, },
} }
var ListNamespacesCmd = &cobra.Command{ var listNamespacesCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List all the namespaces", Short: "List all the namespaces",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -87,9 +97,42 @@ var ListNamespacesCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
return return
} }
fmt.Printf("ID\tName\n")
d := pterm.TableData{{"ID", "Name", "Created"}}
for _, n := range *namespaces { for _, n := range *namespaces {
fmt.Printf("%d\t%s\n", n.ID, n.Name) d = append(d, []string{strconv.FormatUint(uint64(n.ID), 10), n.Name, n.CreatedAt.Format("2006-01-02 15:04:05")})
}
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
if err != nil {
log.Fatal(err)
} }
}, },
} }
var renameNamespaceCmd = &cobra.Command{
Use: "rename OLD_NAME NEW_NAME",
Short: "Renames a namespace",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return fmt.Errorf("Missing parameters")
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
err = h.RenameNamespace(args[0], args[1])
if strings.HasPrefix(o, "json") {
JsonOutput(map[string]string{"Result": "Namespace renamed"}, err, o)
return
}
if err != nil {
fmt.Printf("Error renaming namespace: %s\n", err)
return
}
fmt.Printf("Namespace renamed\n")
},
}

View File

@@ -3,17 +3,43 @@ package cli
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time"
survey "github.com/AlecAivazis/survey/v2"
"github.com/juanfont/headscale"
"github.com/pterm/pterm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
) )
var RegisterCmd = &cobra.Command{ func init() {
rootCmd.AddCommand(nodeCmd)
nodeCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
err := nodeCmd.MarkPersistentFlagRequired("namespace")
if err != nil {
log.Fatalf(err.Error())
}
nodeCmd.AddCommand(listNodesCmd)
nodeCmd.AddCommand(registerNodeCmd)
nodeCmd.AddCommand(deleteNodeCmd)
nodeCmd.AddCommand(shareMachineCmd)
nodeCmd.AddCommand(unshareMachineCmd)
}
var nodeCmd = &cobra.Command{
Use: "nodes",
Short: "Manage the nodes of Headscale",
}
var registerNodeCmd = &cobra.Command{
Use: "register machineID", Use: "register machineID",
Short: "Registers a machine to your network", Short: "Registers a machine to your network",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 { if len(args) < 1 {
return fmt.Errorf("Missing parameters") return fmt.Errorf("missing parameters")
} }
return nil return nil
}, },
@@ -41,7 +67,7 @@ var RegisterCmd = &cobra.Command{
}, },
} }
var ListNodesCmd = &cobra.Command{ var listNodesCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List the nodes in a given namespace", Short: "List the nodes in a given namespace",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -55,9 +81,26 @@ var ListNodesCmd = &cobra.Command{
if err != nil { if err != nil {
log.Fatalf("Error initializing: %s", err) log.Fatalf("Error initializing: %s", err)
} }
namespace, err := h.GetNamespace(n)
if err != nil {
log.Fatalf("Error fetching namespace: %s", err)
}
machines, err := h.ListMachinesInNamespace(n) machines, err := h.ListMachinesInNamespace(n)
if err != nil {
log.Fatalf("Error fetching machines: %s", err)
}
sharedMachines, err := h.ListSharedMachinesInNamespace(n)
if err != nil {
log.Fatalf("Error fetching shared machines: %s", err)
}
allMachines := append(*machines, *sharedMachines...)
if strings.HasPrefix(o, "json") { if strings.HasPrefix(o, "json") {
JsonOutput(machines, err, o) JsonOutput(allMachines, err, o)
return return
} }
@@ -65,19 +108,211 @@ var ListNodesCmd = &cobra.Command{
log.Fatalf("Error getting nodes: %s", err) log.Fatalf("Error getting nodes: %s", err)
} }
fmt.Printf("name\t\tlast seen\t\tephemeral\n") d, err := nodesToPtables(*namespace, allMachines)
for _, m := range *machines { if err != nil {
var ephemeral bool log.Fatalf("Error converting to table: %s", err)
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)
} }
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
if err != nil {
log.Fatal(err)
}
}, },
} }
var NodeCmd = &cobra.Command{ var deleteNodeCmd = &cobra.Command{
Use: "node", Use: "delete ID",
Short: "Manage the nodes of Headscale", Short: "Delete a node",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("missing parameters")
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
output, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
id, err := strconv.Atoi(args[0])
if err != nil {
log.Fatalf("Error converting ID to integer: %s", err)
}
m, err := h.GetMachineByID(uint64(id))
if err != nil {
log.Fatalf("Error getting node: %s", err)
}
confirm := false
force, _ := cmd.Flags().GetBool("force")
if !force {
prompt := &survey.Confirm{
Message: fmt.Sprintf("Do you want to remove the node %s?", m.Name),
}
err = survey.AskOne(prompt, &confirm)
if err != nil {
return
}
}
if confirm || force {
err = h.DeleteMachine(m)
if strings.HasPrefix(output, "json") {
JsonOutput(map[string]string{"Result": "Node deleted"}, err, output)
return
}
if err != nil {
log.Fatalf("Error deleting node: %s", err)
}
fmt.Printf("Node deleted\n")
} else {
if strings.HasPrefix(output, "json") {
JsonOutput(map[string]string{"Result": "Node not deleted"}, err, output)
return
}
fmt.Printf("Node not deleted\n")
}
},
}
var shareMachineCmd = &cobra.Command{
Use: "share ID namespace",
Short: "Shares a node from the current namespace to the specified one",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 {
return fmt.Errorf("missing parameters")
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
namespace, err := cmd.Flags().GetString("namespace")
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
output, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
_, err = h.GetNamespace(namespace)
if err != nil {
log.Fatalf("Error fetching origin namespace: %s", err)
}
destinationNamespace, err := h.GetNamespace(args[1])
if err != nil {
log.Fatalf("Error fetching destination namespace: %s", err)
}
id, err := strconv.Atoi(args[0])
if err != nil {
log.Fatalf("Error converting ID to integer: %s", err)
}
machine, err := h.GetMachineByID(uint64(id))
if err != nil {
log.Fatalf("Error getting node: %s", err)
}
err = h.AddSharedMachineToNamespace(machine, destinationNamespace)
if strings.HasPrefix(output, "json") {
JsonOutput(map[string]string{"Result": "Node shared"}, err, output)
return
}
if err != nil {
fmt.Printf("Error sharing node: %s\n", err)
return
}
fmt.Println("Node shared!")
},
}
var unshareMachineCmd = &cobra.Command{
Use: "unshare ID",
Short: "Unshares a node from the specified namespace",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("missing parameters")
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
namespace, err := cmd.Flags().GetString("namespace")
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
output, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
n, err := h.GetNamespace(namespace)
if err != nil {
log.Fatalf("Error fetching namespace: %s", err)
}
id, err := strconv.Atoi(args[0])
if err != nil {
log.Fatalf("Error converting ID to integer: %s", err)
}
machine, err := h.GetMachineByID(uint64(id))
if err != nil {
log.Fatalf("Error getting node: %s", err)
}
err = h.RemoveSharedMachineFromNamespace(machine, n)
if strings.HasPrefix(output, "json") {
JsonOutput(map[string]string{"Result": "Node unshared"}, err, output)
return
}
if err != nil {
fmt.Printf("Error unsharing node: %s\n", err)
return
}
fmt.Println("Node unshared!")
},
}
func nodesToPtables(currentNamespace headscale.Namespace, machines []headscale.Machine) (pterm.TableData, error) {
d := pterm.TableData{{"ID", "Name", "NodeKey", "Namespace", "IP address", "Ephemeral", "Last seen", "Online"}}
for _, machine := range machines {
var ephemeral bool
if machine.AuthKey != nil && machine.AuthKey.Ephemeral {
ephemeral = true
}
var lastSeen time.Time
var lastSeenTime string
if machine.LastSeen != nil {
lastSeen = *machine.LastSeen
lastSeenTime = lastSeen.Format("2006-01-02 15:04:05")
}
nKey, err := wgkey.ParseHex(machine.NodeKey)
if err != nil {
return nil, err
}
nodeKey := tailcfg.NodeKey(nKey)
var online string
if lastSeen.After(time.Now().Add(-5 * time.Minute)) { // TODO: Find a better way to reliably show if online
online = pterm.LightGreen("true")
} else {
online = pterm.LightRed("false")
}
var namespace string
if currentNamespace.ID == machine.NamespaceID {
namespace = pterm.LightMagenta(machine.Namespace.Name)
} else {
namespace = pterm.LightYellow(machine.Namespace.Name)
}
d = append(d, []string{strconv.FormatUint(machine.ID, 10), machine.Name, nodeKey.ShortString(), namespace, machine.IPAddress, strconv.FormatBool(ephemeral), lastSeenTime, online})
}
return d, nil
} }

View File

@@ -3,19 +3,36 @@ package cli
import ( import (
"fmt" "fmt"
"log" "log"
"strconv"
"strings" "strings"
"time" "time"
"github.com/hako/durafmt" "github.com/hako/durafmt"
"github.com/pterm/pterm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var PreauthkeysCmd = &cobra.Command{ func init() {
Use: "preauthkey", rootCmd.AddCommand(preauthkeysCmd)
preauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
err := preauthkeysCmd.MarkPersistentFlagRequired("namespace")
if err != nil {
log.Fatalf(err.Error())
}
preauthkeysCmd.AddCommand(listPreAuthKeys)
preauthkeysCmd.AddCommand(createPreAuthKeyCmd)
preauthkeysCmd.AddCommand(expirePreAuthKeyCmd)
createPreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
createPreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
createPreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")
}
var preauthkeysCmd = &cobra.Command{
Use: "preauthkeys",
Short: "Handle the preauthkeys in Headscale", Short: "Handle the preauthkeys in Headscale",
} }
var ListPreAuthKeys = &cobra.Command{ var listPreAuthKeys = &cobra.Command{
Use: "list", Use: "list",
Short: "List the preauthkeys for this namespace", Short: "List the preauthkeys for this namespace",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -39,25 +56,40 @@ var ListPreAuthKeys = &cobra.Command{
fmt.Printf("Error getting the list of keys: %s\n", err) fmt.Printf("Error getting the list of keys: %s\n", err)
return return
} }
d := pterm.TableData{{"ID", "Key", "Reusable", "Ephemeral", "Used", "Expiration", "Created"}}
for _, k := range *keys { for _, k := range *keys {
expiration := "-" expiration := "-"
if k.Expiration != nil { if k.Expiration != nil {
expiration = k.Expiration.Format("2006-01-02 15:04:05") expiration = k.Expiration.Format("2006-01-02 15:04:05")
} }
fmt.Printf(
"key: %s, namespace: %s, reusable: %v, ephemeral: %v, expiration: %s, created_at: %s\n", var reusable string
if k.Ephemeral {
reusable = "N/A"
} else {
reusable = fmt.Sprintf("%v", k.Reusable)
}
d = append(d, []string{
strconv.FormatUint(k.ID, 10),
k.Key, k.Key,
k.Namespace.Name, reusable,
k.Reusable, strconv.FormatBool(k.Ephemeral),
k.Ephemeral, fmt.Sprintf("%v", k.Used),
expiration, expiration,
k.CreatedAt.Format("2006-01-02 15:04:05"), k.CreatedAt.Format("2006-01-02 15:04:05"),
) })
}
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
if err != nil {
log.Fatal(err)
} }
}, },
} }
var CreatePreAuthKeyCmd = &cobra.Command{ var createPreAuthKeyCmd = &cobra.Command{
Use: "create", Use: "create",
Short: "Creates a new preauthkey in the specified namespace", Short: "Creates a new preauthkey in the specified namespace",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
@@ -94,6 +126,49 @@ var CreatePreAuthKeyCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
return return
} }
fmt.Printf("Key: %s\n", k.Key) fmt.Printf("%s\n", k.Key)
},
}
var expirePreAuthKeyCmd = &cobra.Command{
Use: "expire KEY",
Short: "Expire a preauthkey",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return fmt.Errorf("missing parameters")
}
return nil
},
Run: func(cmd *cobra.Command, args []string) {
n, err := cmd.Flags().GetString("namespace")
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
o, _ := cmd.Flags().GetString("output")
h, err := getHeadscaleApp()
if err != nil {
log.Fatalf("Error initializing: %s", err)
}
k, err := h.GetPreAuthKey(n, args[0])
if err != nil {
if strings.HasPrefix(o, "json") {
JsonOutput(k, err, o)
return
}
log.Fatalf("Error getting the key: %s", err)
}
err = h.MarkExpirePreAuthKey(k)
if strings.HasPrefix(o, "json") {
JsonOutput(k, err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Println("Expired")
}, },
} }

29
cmd/headscale/cli/root.go Normal file
View File

@@ -0,0 +1,29 @@
package cli
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func init() {
rootCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
rootCmd.PersistentFlags().Bool("force", false, "Disable prompts and forces the execution")
}
var rootCmd = &cobra.Command{
Use: "headscale",
Short: "headscale - a Tailscale control server",
Long: `
headscale is an open source implementation of the Tailscale control server
https://github.com/juanfont/headscale`,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

View File

@@ -5,15 +5,30 @@ import (
"log" "log"
"strings" "strings"
"github.com/pterm/pterm"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var RoutesCmd = &cobra.Command{ func init() {
rootCmd.AddCommand(routesCmd)
routesCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace")
err := routesCmd.MarkPersistentFlagRequired("namespace")
if err != nil {
log.Fatalf(err.Error())
}
enableRouteCmd.Flags().BoolP("all", "a", false, "Enable all routes advertised by the node")
routesCmd.AddCommand(listRoutesCmd)
routesCmd.AddCommand(enableRouteCmd)
}
var routesCmd = &cobra.Command{
Use: "routes", Use: "routes",
Short: "Manage the routes of Headscale", Short: "Manage the routes of Headscale",
} }
var ListRoutesCmd = &cobra.Command{ var listRoutesCmd = &cobra.Command{
Use: "list NODE", Use: "list NODE",
Short: "List the routes exposed by this node", Short: "List the routes exposed by this node",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
@@ -33,52 +48,100 @@ var ListRoutesCmd = &cobra.Command{
if err != nil { if err != nil {
log.Fatalf("Error initializing: %s", err) log.Fatalf("Error initializing: %s", err)
} }
routes, err := h.GetNodeRoutes(n, args[0])
if strings.HasPrefix(o, "json") {
JsonOutput(routes, err, o)
return
}
availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
return return
} }
fmt.Println(routes) if strings.HasPrefix(o, "json") {
// TODO: Add enable/disabled information to this interface
JsonOutput(availableRoutes, err, o)
return
}
d := h.RoutesToPtables(n, args[0], *availableRoutes)
err = pterm.DefaultTable.WithHasHeader().WithData(d).Render()
if err != nil {
log.Fatal(err)
}
}, },
} }
var EnableRouteCmd = &cobra.Command{ var enableRouteCmd = &cobra.Command{
Use: "enable node-name route", Use: "enable node-name route",
Short: "Allows exposing a route declared by this node to the rest of the nodes", Short: "Allows exposing a route declared by this node to the rest of the nodes",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 2 { all, err := cmd.Flags().GetBool("all")
return fmt.Errorf("Missing parameters") if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
if all {
if len(args) < 1 {
return fmt.Errorf("Missing parameters")
}
return nil
} else {
if len(args) < 2 {
return fmt.Errorf("Missing parameters")
}
return nil
} }
return nil
}, },
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
n, err := cmd.Flags().GetString("namespace") n, err := cmd.Flags().GetString("namespace")
if err != nil { if err != nil {
log.Fatalf("Error getting namespace: %s", err) log.Fatalf("Error getting namespace: %s", err)
} }
o, _ := cmd.Flags().GetString("output") o, _ := cmd.Flags().GetString("output")
all, err := cmd.Flags().GetBool("all")
if err != nil {
log.Fatalf("Error getting namespace: %s", err)
}
h, err := getHeadscaleApp() h, err := getHeadscaleApp()
if err != nil { if err != nil {
log.Fatalf("Error initializing: %s", err) log.Fatalf("Error initializing: %s", err)
} }
route, err := h.EnableNodeRoute(n, args[0], args[1])
if strings.HasPrefix(o, "json") {
JsonOutput(route, err, o)
return
}
if err != nil { if all {
fmt.Println(err) availableRoutes, err := h.GetAdvertisedNodeRoutes(n, args[0])
return if err != nil {
fmt.Println(err)
return
}
for _, availableRoute := range *availableRoutes {
err = h.EnableNodeRoute(n, args[0], availableRoute.String())
if err != nil {
fmt.Println(err)
return
}
if strings.HasPrefix(o, "json") {
JsonOutput(availableRoute, err, o)
} else {
fmt.Printf("Enabled route %s\n", availableRoute)
}
}
} else {
err = h.EnableNodeRoute(n, args[0], args[1])
if strings.HasPrefix(o, "json") {
JsonOutput(args[1], err, o)
return
}
if err != nil {
fmt.Println(err)
return
}
fmt.Printf("Enabled route %s\n", args[1])
} }
fmt.Printf("Enabled route %s\n", route)
}, },
} }

View File

@@ -6,7 +6,11 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ServeCmd = &cobra.Command{ func init() {
rootCmd.AddCommand(serveCmd)
}
var serveCmd = &cobra.Command{
Use: "serve", Use: "serve",
Short: "Launches the headscale server", Short: "Launches the headscale server",
Args: func(cmd *cobra.Command, args []string) error { Args: func(cmd *cobra.Command, args []string) error {
@@ -17,7 +21,7 @@ var ServeCmd = &cobra.Command{
if err != nil { if err != nil {
log.Fatalf("Error initializing: %s", err) log.Fatalf("Error initializing: %s", err)
} }
go h.ExpireEphemeralNodes(5000)
err = h.Serve() err = h.Serve()
if err != nil { if err != nil {
log.Fatalf("Error initializing: %s", err) log.Fatalf("Error initializing: %s", err)

View File

@@ -5,16 +5,18 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"github.com/juanfont/headscale" "github.com/juanfont/headscale"
"github.com/rs/zerolog/log"
"github.com/spf13/viper" "github.com/spf13/viper"
"gopkg.in/yaml.v2" "gopkg.in/yaml.v2"
"inet.af/netaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
) )
type ErrorOutput struct { type ErrorOutput struct {
@@ -36,6 +38,12 @@ func LoadConfig(path string) error {
viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache") viper.SetDefault("tls_letsencrypt_cache_dir", "/var/www/.cache")
viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01") viper.SetDefault("tls_letsencrypt_challenge_type", "HTTP-01")
viper.SetDefault("ip_prefix", "100.64.0.0/10")
viper.SetDefault("log_level", "info")
viper.SetDefault("dns_config", nil)
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
return fmt.Errorf("Fatal error reading config file: %s \n", err) return fmt.Errorf("Fatal error reading config file: %s \n", err)
@@ -48,7 +56,9 @@ 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")) { 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.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") {
@@ -63,6 +73,89 @@ func LoadConfig(path string) error {
} else { } else {
return nil return nil
} }
}
func GetDNSConfig() (*tailcfg.DNSConfig, string) {
if viper.IsSet("dns_config") {
dnsConfig := &tailcfg.DNSConfig{}
if viper.IsSet("dns_config.nameservers") {
nameserversStr := viper.GetStringSlice("dns_config.nameservers")
nameservers := make([]netaddr.IP, len(nameserversStr))
resolvers := make([]dnstype.Resolver, len(nameserversStr))
for index, nameserverStr := range nameserversStr {
nameserver, err := netaddr.ParseIP(nameserverStr)
if err != nil {
log.Error().
Str("func", "getDNSConfig").
Err(err).
Msgf("Could not parse nameserver IP: %s", nameserverStr)
}
nameservers[index] = nameserver
resolvers[index] = dnstype.Resolver{
Addr: nameserver.String(),
}
}
dnsConfig.Nameservers = nameservers
dnsConfig.Resolvers = resolvers
}
if viper.IsSet("dns_config.restricted_nameservers") {
if len(dnsConfig.Nameservers) > 0 {
dnsConfig.Routes = make(map[string][]dnstype.Resolver)
restrictedDNS := viper.GetStringMapStringSlice("dns_config.restricted_nameservers")
for domain, restrictedNameservers := range restrictedDNS {
restrictedResolvers := make([]dnstype.Resolver, len(restrictedNameservers))
for index, nameserverStr := range restrictedNameservers {
nameserver, err := netaddr.ParseIP(nameserverStr)
if err != nil {
log.Error().
Str("func", "getDNSConfig").
Err(err).
Msgf("Could not parse restricted nameserver IP: %s", nameserverStr)
}
restrictedResolvers[index] = dnstype.Resolver{
Addr: nameserver.String(),
}
}
dnsConfig.Routes[domain] = restrictedResolvers
}
} else {
log.Warn().
Msg("Warning: dns_config.restricted_nameservers is set, but no nameservers are configured. Ignoring restricted_nameservers.")
}
}
if viper.IsSet("dns_config.domains") {
dnsConfig.Domains = viper.GetStringSlice("dns_config.domains")
}
if viper.IsSet("dns_config.magic_dns") {
magicDNS := viper.GetBool("dns_config.magic_dns")
if len(dnsConfig.Nameservers) > 0 {
dnsConfig.Proxied = magicDNS
} else if magicDNS {
log.Warn().
Msg("Warning: dns_config.magic_dns is set, but no nameservers are configured. Ignoring magic_dns.")
}
}
var baseDomain string
if viper.IsSet("dns_config.base_domain") {
baseDomain = viper.GetString("dns_config.base_domain")
} else {
baseDomain = "headscale.net" // does not really matter when MagicDNS is not enabled
}
return dnsConfig, baseDomain
}
return nil, ""
} }
func absPath(path string) string { func absPath(path string) string {
@@ -78,9 +171,13 @@ func absPath(path string) string {
} }
func getHeadscaleApp() (*headscale.Headscale, error) { func getHeadscaleApp() (*headscale.Headscale, error) {
derpMap, err := loadDerpMap(absPath(viper.GetString("derp_map_path"))) derpPath := absPath(viper.GetString("derp_map_path"))
derpMap, err := loadDerpMap(derpPath)
if err != nil { if err != nil {
log.Printf("Could not load DERP servers map file: %s", err) 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 // Minimum inactivity time out is keepalive timeout (60s) plus a few seconds
@@ -91,11 +188,15 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
return nil, err return nil, err
} }
dnsConfig, baseDomain := GetDNSConfig()
cfg := headscale.Config{ cfg := headscale.Config{
ServerURL: viper.GetString("server_url"), ServerURL: viper.GetString("server_url"),
Addr: viper.GetString("listen_addr"), Addr: viper.GetString("listen_addr"),
PrivateKeyPath: absPath(viper.GetString("private_key_path")), PrivateKeyPath: absPath(viper.GetString("private_key_path")),
DerpMap: derpMap, DerpMap: derpMap,
IPPrefix: netaddr.MustParseIPPrefix(viper.GetString("ip_prefix")),
BaseDomain: baseDomain,
EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"), EphemeralNodeInactivityTimeout: viper.GetDuration("ephemeral_node_inactivity_timeout"),
@@ -108,17 +209,37 @@ func getHeadscaleApp() (*headscale.Headscale, error) {
DBpass: viper.GetString("db_pass"), DBpass: viper.GetString("db_pass"),
TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"), TLSLetsEncryptHostname: viper.GetString("tls_letsencrypt_hostname"),
TLSLetsEncryptListen: viper.GetString("tls_letsencrypt_listen"),
TLSLetsEncryptCacheDir: absPath(viper.GetString("tls_letsencrypt_cache_dir")), TLSLetsEncryptCacheDir: absPath(viper.GetString("tls_letsencrypt_cache_dir")),
TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"), TLSLetsEncryptChallengeType: viper.GetString("tls_letsencrypt_challenge_type"),
TLSCertPath: absPath(viper.GetString("tls_cert_path")), TLSCertPath: absPath(viper.GetString("tls_cert_path")),
TLSKeyPath: absPath(viper.GetString("tls_key_path")), TLSKeyPath: absPath(viper.GetString("tls_key_path")),
DNSConfig: dnsConfig,
ACMEEmail: viper.GetString("acme_email"),
ACMEURL: viper.GetString("acme_url"),
} }
h, err := headscale.NewHeadscale(cfg) h, err := headscale.NewHeadscale(cfg)
if err != nil { if err != nil {
return nil, err 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") != "" {
aclPath := absPath(viper.GetString("acl_policy_path"))
err = h.LoadACLPolicy(aclPath)
if err != nil {
log.Error().
Str("path", aclPath).
Err(err).
Msg("Could not load the ACL policy")
}
}
return h, nil return h, nil
} }
@@ -145,26 +266,35 @@ func JsonOutput(result interface{}, errResult error, outputFormat string) {
if errResult != nil { if errResult != nil {
j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t") j, err = json.MarshalIndent(ErrorOutput{errResult.Error()}, "", "\t")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatal().Err(err)
} }
} else { } else {
j, err = json.MarshalIndent(result, "", "\t") j, err = json.MarshalIndent(result, "", "\t")
if err != nil { if err != nil {
log.Fatalln(err) log.Fatal().Err(err)
} }
} }
case "json-line": case "json-line":
if errResult != nil { if errResult != nil {
j, err = json.Marshal(ErrorOutput{errResult.Error()}) j, err = json.Marshal(ErrorOutput{errResult.Error()})
if err != nil { if err != nil {
log.Fatalln(err) log.Fatal().Err(err)
} }
} else { } else {
j, err = json.Marshal(result) j, err = json.Marshal(result)
if err != nil { if err != nil {
log.Fatalln(err) log.Fatal().Err(err)
} }
} }
} }
fmt.Println(string(j)) fmt.Println(string(j))
} }
func HasJsonOutputFlag() bool {
for _, arg := range os.Args {
if arg == "json" || arg == "json-line" {
return true
}
}
return false
}

View File

@@ -0,0 +1,28 @@
package cli
import (
"fmt"
"strings"
"github.com/spf13/cobra"
)
var Version = "dev"
func init() {
rootCmd.AddCommand(versionCmd)
}
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version.",
Long: "The version of headscale.",
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
if strings.HasPrefix(o, "json") {
JsonOutput(map[string]string{"version": Version}, nil, o)
return
}
fmt.Println(Version)
},
}

View File

@@ -2,92 +2,80 @@ package main
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"strings" "runtime"
"time"
"github.com/efekarakus/termcolor"
"github.com/juanfont/headscale/cmd/headscale/cli" "github.com/juanfont/headscale/cmd/headscale/cli"
"github.com/spf13/cobra" "github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"github.com/tcnksm/go-latest"
) )
var version = "dev"
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version.",
Long: "The version of headscale.",
Run: func(cmd *cobra.Command, args []string) {
o, _ := cmd.Flags().GetString("output")
if strings.HasPrefix(o, "json") {
cli.JsonOutput(map[string]string{"version": version}, nil, o)
return
}
fmt.Println(version)
},
}
var headscaleCmd = &cobra.Command{
Use: "headscale",
Short: "headscale - a Tailscale control server",
Long: `
headscale is an open source implementation of the Tailscale control server
Juan Font Alonso <juanfontalonso@gmail.com> - 2021
https://gitlab.com/juanfont/headscale`,
}
func main() { func main() {
var colors bool
switch l := termcolor.SupportLevel(os.Stderr); l {
case termcolor.Level16M:
colors = true
case termcolor.Level256:
colors = true
case termcolor.LevelBasic:
colors = true
default:
// no color, return text as is.
colors = false
}
// Adhere to no-color.org manifesto of allowing users to
// turn off color in cli/services
if _, noColorIsSet := os.LookupEnv("NO_COLOR"); noColorIsSet {
colors = false
}
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
log.Logger = log.Output(zerolog.ConsoleWriter{
Out: os.Stdout,
TimeFormat: time.RFC3339,
NoColor: !colors,
})
err := cli.LoadConfig("") err := cli.LoadConfig("")
if err != nil { if err != nil {
log.Fatalf(err.Error()) log.Fatal().Err(err)
} }
headscaleCmd.AddCommand(cli.NamespaceCmd) logLevel := viper.GetString("log_level")
headscaleCmd.AddCommand(cli.NodeCmd) switch logLevel {
headscaleCmd.AddCommand(cli.PreauthkeysCmd) case "trace":
headscaleCmd.AddCommand(cli.RoutesCmd) zerolog.SetGlobalLevel(zerolog.TraceLevel)
headscaleCmd.AddCommand(cli.ServeCmd) case "debug":
headscaleCmd.AddCommand(versionCmd) zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
cli.NodeCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace") zerolog.SetGlobalLevel(zerolog.InfoLevel)
err = cli.NodeCmd.MarkPersistentFlagRequired("namespace") case "warn":
if err != nil { zerolog.SetGlobalLevel(zerolog.WarnLevel)
log.Fatalf(err.Error()) case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
default:
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} }
cli.PreauthkeysCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace") jsonOutput := cli.HasJsonOutputFlag()
err = cli.PreauthkeysCmd.MarkPersistentFlagRequired("namespace") if !viper.GetBool("disable_check_updates") && !jsonOutput {
if err != nil { if (runtime.GOOS == "linux" || runtime.GOOS == "darwin") && cli.Version != "dev" {
log.Fatalf(err.Error()) githubTag := &latest.GithubTag{
Owner: "juanfont",
Repository: "headscale",
}
res, err := latest.Check(githubTag, cli.Version)
if err == nil && res.Outdated {
fmt.Printf("An updated version of Headscale has been found (%s vs. your current %s). Check it out https://github.com/juanfont/headscale/releases\n",
res.Current, cli.Version)
}
}
} }
cli.RoutesCmd.PersistentFlags().StringP("namespace", "n", "", "Namespace") cli.Execute()
err = cli.RoutesCmd.MarkPersistentFlagRequired("namespace")
if err != nil {
log.Fatalf(err.Error())
}
cli.NamespaceCmd.AddCommand(cli.CreateNamespaceCmd)
cli.NamespaceCmd.AddCommand(cli.ListNamespacesCmd)
cli.NamespaceCmd.AddCommand(cli.DestroyNamespaceCmd)
cli.NodeCmd.AddCommand(cli.ListNodesCmd)
cli.NodeCmd.AddCommand(cli.RegisterCmd)
cli.RoutesCmd.AddCommand(cli.ListRoutesCmd)
cli.RoutesCmd.AddCommand(cli.EnableRouteCmd)
cli.PreauthkeysCmd.AddCommand(cli.ListPreAuthKeys)
cli.PreauthkeysCmd.AddCommand(cli.CreatePreAuthKeyCmd)
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("reusable", false, "Make the preauthkey reusable")
cli.CreatePreAuthKeyCmd.PersistentFlags().Bool("ephemeral", false, "Preauthkey for ephemeral nodes")
cli.CreatePreAuthKeyCmd.Flags().StringP("expiration", "e", "", "Human-readable expiration of the key (30m, 24h, 365d...)")
headscaleCmd.PersistentFlags().StringP("output", "o", "", "Output format. Empty for human-readable, 'json' or 'json-line'")
if err := headscaleCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(-1)
}
} }

View File

@@ -51,13 +51,14 @@ func (*Suite) TestPostgresConfigLoading(c *check.C) {
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
// Test that config file was interpreted correctly // Test that config file was interpreted correctly
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8000") 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:8000") 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("derp_map_path"), check.Equals, "derp.yaml")
c.Assert(viper.GetString("db_type"), check.Equals, "postgres") c.Assert(viper.GetString("db_type"), check.Equals, "postgres")
c.Assert(viper.GetString("db_port"), check.Equals, "5432") c.Assert(viper.GetString("db_port"), check.Equals, "5432")
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01") 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) { func (*Suite) TestSqliteConfigLoading(c *check.C) {
@@ -83,13 +84,45 @@ func (*Suite) TestSqliteConfigLoading(c *check.C) {
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
// Test that config file was interpreted correctly // Test that config file was interpreted correctly
c.Assert(viper.GetString("server_url"), check.Equals, "http://127.0.0.1:8000") 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:8000") 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("derp_map_path"), check.Equals, "derp.yaml")
c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3") c.Assert(viper.GetString("db_type"), check.Equals, "sqlite3")
c.Assert(viper.GetString("db_path"), check.Equals, "db.sqlite") c.Assert(viper.GetString("db_path"), check.Equals, "db.sqlite")
c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "") c.Assert(viper.GetString("tls_letsencrypt_hostname"), check.Equals, "")
c.Assert(viper.GetString("tls_letsencrypt_listen"), check.Equals, ":http")
c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01") c.Assert(viper.GetString("tls_letsencrypt_challenge_type"), check.Equals, "HTTP-01")
c.Assert(viper.GetStringSlice("dns_config.nameservers")[0], check.Equals, "1.1.1.1")
}
func (*Suite) TestDNSConfigLoading(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)
dnsConfig, baseDomain := cli.GetDNSConfig()
c.Assert(dnsConfig.Nameservers[0].String(), check.Equals, "1.1.1.1")
c.Assert(dnsConfig.Resolvers[0].Addr, check.Equals, "1.1.1.1")
c.Assert(dnsConfig.Proxied, check.Equals, true)
c.Assert(baseDomain, check.Equals, "example.com")
} }
func writeConfig(c *check.C, tmpDir string, configYaml []byte) { func writeConfig(c *check.C, tmpDir string, configYaml []byte) {
@@ -123,9 +156,8 @@ func (*Suite) TestTLSConfigValidation(c *check.C) {
fmt.Println(tmp) fmt.Println(tmp)
// Check configuration validation errors (2) // Check configuration validation errors (2)
configYaml = []byte("---\nserver_url: \"http://127.0.0.1:8000\"\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) writeConfig(c, tmpDir, configYaml)
err = cli.LoadConfig(tmpDir) err = cli.LoadConfig(tmpDir)
c.Assert(err, check.NotNil) c.Assert(err, check.IsNil)
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.*")
} }

View File

@@ -1,6 +1,6 @@
{ {
"server_url": "http://127.0.0.1:8000", "server_url": "http://127.0.0.1:8080",
"listen_addr": "0.0.0.0:8000", "listen_addr": "0.0.0.0:8080",
"private_key_path": "private.key", "private_key_path": "private.key",
"derp_map_path": "derp.yaml", "derp_map_path": "derp.yaml",
"ephemeral_node_inactivity_timeout": "30m", "ephemeral_node_inactivity_timeout": "30m",
@@ -10,9 +10,21 @@
"db_name": "headscale", "db_name": "headscale",
"db_user": "foo", "db_user": "foo",
"db_pass": "bar", "db_pass": "bar",
"acme_url": "https://acme-v02.api.letsencrypt.org/directory",
"acme_email": "",
"tls_letsencrypt_hostname": "", "tls_letsencrypt_hostname": "",
"tls_letsencrypt_listen": ":http",
"tls_letsencrypt_cache_dir": ".cache", "tls_letsencrypt_cache_dir": ".cache",
"tls_letsencrypt_challenge_type": "HTTP-01", "tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "", "tls_cert_path": "",
"tls_key_path": "" "tls_key_path": "",
"acl_policy_path": "",
"dns_config": {
"nameservers": [
"1.1.1.1"
],
"domains": [],
"magic_dns": true,
"base_domain": "example.com"
}
} }

View File

@@ -1,14 +1,26 @@
{ {
"server_url": "http://127.0.0.1:8000", "server_url": "http://127.0.0.1:8080",
"listen_addr": "0.0.0.0:8000", "listen_addr": "0.0.0.0:8080",
"private_key_path": "private.key", "private_key_path": "private.key",
"derp_map_path": "derp.yaml", "derp_map_path": "derp.yaml",
"ephemeral_node_inactivity_timeout": "30m", "ephemeral_node_inactivity_timeout": "30m",
"db_type": "sqlite3", "db_type": "sqlite3",
"db_path": "db.sqlite", "db_path": "db.sqlite",
"acme_url": "https://acme-v02.api.letsencrypt.org/directory",
"acme_email": "",
"tls_letsencrypt_hostname": "", "tls_letsencrypt_hostname": "",
"tls_letsencrypt_listen": ":http",
"tls_letsencrypt_cache_dir": ".cache", "tls_letsencrypt_cache_dir": ".cache",
"tls_letsencrypt_challenge_type": "HTTP-01", "tls_letsencrypt_challenge_type": "HTTP-01",
"tls_cert_path": "", "tls_cert_path": "",
"tls_key_path": "" "tls_key_path": "",
"acl_policy_path": "",
"dns_config": {
"nameservers": [
"1.1.1.1"
],
"domains": [],
"magic_dns": true,
"base_domain": "example.com"
}
} }

88
db.go
View File

@@ -3,9 +3,10 @@ package headscale
import ( import (
"errors" "errors"
"github.com/jinzhu/gorm" "gorm.io/driver/postgres"
_ "github.com/jinzhu/gorm/dialects/postgres" // sql driver "gorm.io/driver/sqlite"
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver "gorm.io/gorm"
"gorm.io/gorm/logger"
) )
const dbVersion = "1" const dbVersion = "1"
@@ -17,63 +18,94 @@ type KV struct {
} }
func (h *Headscale) initDB() error { func (h *Headscale) initDB() error {
db, err := gorm.Open(h.dbType, h.dbString) db, err := h.openDB()
if err != nil { if err != nil {
return err return err
} }
h.db = db
if h.dbType == "postgres" { if h.dbType == "postgres" {
db.Exec("create extension if not exists \"uuid-ossp\";") db.Exec("create extension if not exists \"uuid-ossp\";")
} }
db.AutoMigrate(&Machine{}) err = db.AutoMigrate(&Machine{})
db.AutoMigrate(&KV{}) if err != nil {
db.AutoMigrate(&Namespace{}) return err
db.AutoMigrate(&PreAuthKey{}) }
db.Close() 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 = db.AutoMigrate(&SharedMachine{})
if err != nil {
return err
}
err = h.setValue("db_version", dbVersion) err = h.setValue("db_version", dbVersion)
return err return err
} }
func (h *Headscale) db() (*gorm.DB, error) { func (h *Headscale) openDB() (*gorm.DB, error) {
db, err := gorm.Open(h.dbType, h.dbString) 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 { if err != nil {
return nil, err return nil, err
} }
if h.dbDebug {
db.LogMode(true)
}
return db, nil return db, nil
} }
// getValue returns the value for the given key in KV
func (h *Headscale) getValue(key string) (string, error) { func (h *Headscale) getValue(key string) (string, error) {
db, err := h.db()
if err != nil {
return "", err
}
defer db.Close()
var row KV 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 "", errors.New("not found")
} }
return row.Value, nil return row.Value, nil
} }
// setValue sets value for the given key in KV
func (h *Headscale) setValue(key string, value string) error { func (h *Headscale) setValue(key string, value string) error {
kv := KV{ kv := KV{
Key: key, Key: key,
Value: value, Value: value,
} }
db, err := h.db()
if err != nil { _, err := h.getValue(key)
return err
}
defer db.Close()
_, err = h.getValue(key)
if err == nil { if err == nil {
db.Model(&kv).Where("key = ?", key).Update("value", value) h.db.Model(&kv).Where("key = ?", key).Update("value", value)
return nil return nil
} }
db.Create(kv) h.db.Create(kv)
return nil return nil
} }

View File

@@ -1,7 +1,7 @@
# This file contains some of the official Tailscale DERP servers, # 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 # If you plan to somehow use headscale, please deploy your own DERP infra: https://tailscale.com/kb/1118/custom-derp-servers/
regions: regions:
1: 1:
regionid: 1 regionid: 1
@@ -16,6 +16,14 @@ regions:
stunport: 0 stunport: 0
stunonly: false stunonly: false
derptestport: 0 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: 2:
regionid: 2 regionid: 2
regioncode: sfo regioncode: sfo
@@ -29,6 +37,14 @@ regions:
stunport: 0 stunport: 0
stunonly: false stunonly: false
derptestport: 0 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: 3:
regionid: 3 regionid: 3
regioncode: sin regioncode: sin
@@ -54,4 +70,77 @@ regions:
ipv6: "2a03:b0c0:3:e0::36e:900" ipv6: "2a03:b0c0:3:e0::36e:900"
stunport: 0 stunport: 0
stunonly: false 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

92
dns.go Normal file
View File

@@ -0,0 +1,92 @@
package headscale
import (
"fmt"
"strings"
"github.com/fatih/set"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/util/dnsname"
)
// generateMagicDNSRootDomains generates a list of DNS entries to be included in `Routes` in `MapResponse`.
// This list of reverse DNS entries instructs the OS on what subnets and domains the Tailscale embedded DNS
// server (listening in 100.100.100.100 udp/53) should be used for.
//
// Tailscale.com includes in the list:
// - the `BaseDomain` of the user
// - the reverse DNS entry for IPv6 (0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa., see below more on IPv6)
// - the reverse DNS entries for the IPv4 subnets covered by the user's `IPPrefix`.
// In the public SaaS this is [64-127].100.in-addr.arpa.
//
// The main purpose of this function is then generating the list of IPv4 entries. For the 100.64.0.0/10, this
// is clear, and could be hardcoded. But we are allowing any range as `IPPrefix`, so we need to find out the
// subnets when we have 172.16.0.0/16 (i.e., [0-255].16.172.in-addr.arpa.), or any other subnet.
//
// How IN-ADDR.ARPA domains work is defined in RFC1035 (section 3.5). Tailscale.com seems to adhere to this,
// and do not make use of RFC2317 ("Classless IN-ADDR.ARPA delegation") - hence generating the entries for the next
// class block only.
// From the netmask we can find out the wildcard bits (the bits that are not set in the netmask).
// This allows us to then calculate the subnets included in the subsequent class block and generate the entries.
func generateMagicDNSRootDomains(ipPrefix netaddr.IPPrefix, baseDomain string) ([]dnsname.FQDN, error) {
// TODO(juanfont): we are not handing out IPv6 addresses yet
// and in fact this is Tailscale.com's range (note the fd7a:115c:a1e0: range in the fc00::/7 network)
ipv6base := dnsname.FQDN("0.e.1.a.c.5.1.1.a.7.d.f.ip6.arpa.")
fqdns := []dnsname.FQDN{ipv6base}
// Conversion to the std lib net.IPnet, a bit easier to operate
netRange := ipPrefix.IPNet()
maskBits, _ := netRange.Mask.Size()
// lastOctet is the last IP byte covered by the mask
lastOctet := maskBits / 8
// wildcardBits is the number of bits not under the mask in the lastOctet
wildcardBits := 8 - maskBits%8
// min is the value in the lastOctet byte of the IP
// max is basically 2^wildcardBits - i.e., the value when all the wildcardBits are set to 1
min := uint(netRange.IP[lastOctet])
max := uint((min + 1<<uint(wildcardBits)) - 1)
// here we generate the base domain (e.g., 100.in-addr.arpa., 16.172.in-addr.arpa., etc.)
rdnsSlice := []string{}
for i := lastOctet - 1; i >= 0; i-- {
rdnsSlice = append(rdnsSlice, fmt.Sprintf("%d", netRange.IP[i]))
}
rdnsSlice = append(rdnsSlice, "in-addr.arpa.")
rdnsBase := strings.Join(rdnsSlice, ".")
for i := min; i <= max; i++ {
fqdn, err := dnsname.ToFQDN(fmt.Sprintf("%d.%s", i, rdnsBase))
if err != nil {
continue
}
fqdns = append(fqdns, fqdn)
}
return fqdns, nil
}
func getMapResponseDNSConfig(dnsConfigOrig *tailcfg.DNSConfig, baseDomain string, m Machine, peers Machines) (*tailcfg.DNSConfig, error) {
var dnsConfig *tailcfg.DNSConfig
if dnsConfigOrig != nil && dnsConfigOrig.Proxied { // if MagicDNS is enabled
// Only inject the Search Domain of the current namespace - shared nodes should use their full FQDN
dnsConfig = dnsConfigOrig.Clone()
dnsConfig.Domains = append(dnsConfig.Domains, fmt.Sprintf("%s.%s", m.Namespace.Name, baseDomain))
namespaceSet := set.New(set.ThreadSafe)
namespaceSet.Add(m.Namespace)
for _, p := range peers {
namespaceSet.Add(p.Namespace)
}
for _, namespace := range namespaceSet.List() {
dnsRoute := fmt.Sprintf("%s.%s", namespace.(Namespace).Name, baseDomain)
dnsConfig.Routes[dnsRoute] = nil
}
} else {
dnsConfig = dnsConfigOrig
}
return dnsConfig, nil
}

306
dns_test.go Normal file
View File

@@ -0,0 +1,306 @@
package headscale
import (
"fmt"
"gopkg.in/check.v1"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/dnstype"
)
func (s *Suite) TestMagicDNSRootDomains100(c *check.C) {
prefix := netaddr.MustParseIPPrefix("100.64.0.0/10")
domains, err := generateMagicDNSRootDomains(prefix, "foobar.headscale.net")
c.Assert(err, check.IsNil)
found := false
for _, domain := range domains {
if domain == "64.100.in-addr.arpa." {
found = true
break
}
}
c.Assert(found, check.Equals, true)
found = false
for _, domain := range domains {
if domain == "100.100.in-addr.arpa." {
found = true
break
}
}
c.Assert(found, check.Equals, true)
found = false
for _, domain := range domains {
if domain == "127.100.in-addr.arpa." {
found = true
break
}
}
c.Assert(found, check.Equals, true)
}
func (s *Suite) TestMagicDNSRootDomains172(c *check.C) {
prefix := netaddr.MustParseIPPrefix("172.16.0.0/16")
domains, err := generateMagicDNSRootDomains(prefix, "headscale.net")
c.Assert(err, check.IsNil)
found := false
for _, domain := range domains {
if domain == "0.16.172.in-addr.arpa." {
found = true
break
}
}
c.Assert(found, check.Equals, true)
found = false
for _, domain := range domains {
if domain == "255.16.172.in-addr.arpa." {
found = true
break
}
}
c.Assert(found, check.Equals, true)
}
func (s *Suite) TestDNSConfigMapResponseWithMagicDNS(c *check.C) {
n1, err := h.CreateNamespace("shared1")
c.Assert(err, check.IsNil)
n2, err := h.CreateNamespace("shared2")
c.Assert(err, check.IsNil)
n3, err := h.CreateNamespace("shared3")
c.Assert(err, check.IsNil)
pak1n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak2n2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak3n3, err := h.CreatePreAuthKey(n3.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak4n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
c.Assert(err, check.NotNil)
m1 := &Machine{
ID: 1,
MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
Name: "test_get_shared_nodes_1",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.1",
AuthKeyID: uint(pak1n1.ID),
}
h.db.Save(m1)
_, err = h.GetMachine(n1.Name, m1.Name)
c.Assert(err, check.IsNil)
m2 := &Machine{
ID: 2,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_2",
NamespaceID: n2.ID,
Namespace: *n2,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.2",
AuthKeyID: uint(pak2n2.ID),
}
h.db.Save(m2)
_, err = h.GetMachine(n2.Name, m2.Name)
c.Assert(err, check.IsNil)
m3 := &Machine{
ID: 3,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_3",
NamespaceID: n3.ID,
Namespace: *n3,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.3",
AuthKeyID: uint(pak3n3.ID),
}
h.db.Save(m3)
_, err = h.GetMachine(n3.Name, m3.Name)
c.Assert(err, check.IsNil)
m4 := &Machine{
ID: 4,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_4",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.4",
AuthKeyID: uint(pak4n1.ID),
}
h.db.Save(m4)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
baseDomain := "foobar.headscale.net"
dnsConfigOrig := tailcfg.DNSConfig{
Routes: make(map[string][]dnstype.Resolver),
Domains: []string{baseDomain},
Proxied: true,
}
m1peers, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
dnsConfig, err := getMapResponseDNSConfig(&dnsConfigOrig, baseDomain, *m1, m1peers)
c.Assert(err, check.IsNil)
c.Assert(dnsConfig, check.NotNil)
c.Assert(len(dnsConfig.Routes), check.Equals, 2)
routeN1 := fmt.Sprintf("%s.%s", n1.Name, baseDomain)
_, ok := dnsConfig.Routes[routeN1]
c.Assert(ok, check.Equals, true)
routeN2 := fmt.Sprintf("%s.%s", n2.Name, baseDomain)
_, ok = dnsConfig.Routes[routeN2]
c.Assert(ok, check.Equals, true)
routeN3 := fmt.Sprintf("%s.%s", n3.Name, baseDomain)
_, ok = dnsConfig.Routes[routeN3]
c.Assert(ok, check.Equals, false)
}
func (s *Suite) TestDNSConfigMapResponseWithoutMagicDNS(c *check.C) {
n1, err := h.CreateNamespace("shared1")
c.Assert(err, check.IsNil)
n2, err := h.CreateNamespace("shared2")
c.Assert(err, check.IsNil)
n3, err := h.CreateNamespace("shared3")
c.Assert(err, check.IsNil)
pak1n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak2n2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak3n3, err := h.CreatePreAuthKey(n3.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak4n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
c.Assert(err, check.NotNil)
m1 := &Machine{
ID: 1,
MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
Name: "test_get_shared_nodes_1",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.1",
AuthKeyID: uint(pak1n1.ID),
}
h.db.Save(m1)
_, err = h.GetMachine(n1.Name, m1.Name)
c.Assert(err, check.IsNil)
m2 := &Machine{
ID: 2,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_2",
NamespaceID: n2.ID,
Namespace: *n2,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.2",
AuthKeyID: uint(pak2n2.ID),
}
h.db.Save(m2)
_, err = h.GetMachine(n2.Name, m2.Name)
c.Assert(err, check.IsNil)
m3 := &Machine{
ID: 3,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_3",
NamespaceID: n3.ID,
Namespace: *n3,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.3",
AuthKeyID: uint(pak3n3.ID),
}
h.db.Save(m3)
_, err = h.GetMachine(n3.Name, m3.Name)
c.Assert(err, check.IsNil)
m4 := &Machine{
ID: 4,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_4",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.4",
AuthKeyID: uint(pak4n1.ID),
}
h.db.Save(m4)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
baseDomain := "foobar.headscale.net"
dnsConfigOrig := tailcfg.DNSConfig{
Routes: make(map[string][]dnstype.Resolver),
Domains: []string{baseDomain},
Proxied: false,
}
m1peers, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
dnsConfig, err := getMapResponseDNSConfig(&dnsConfigOrig, baseDomain, *m1, m1peers)
c.Assert(err, check.IsNil)
c.Assert(dnsConfig, check.NotNil)
c.Assert(len(dnsConfig.Routes), check.Equals, 0)
c.Assert(len(dnsConfig.Domains), check.Equals, 1)
}

View File

@@ -1,62 +0,0 @@
FROM golang:alpine
# Set necessary environmet variables needed for our image
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64
ENV PATH /usr/lib/postgresql/$PG_MAJOR/bin:$PATH
ENV PGDATA /var/lib/postgresql/data
ENV POSTGRES_DB headscale
ENV POSTGRES_USER admin
ENV LANG en_US.utf8
RUN apk update && \
apk add git su-exec tzdata libpq postgresql-client postgresql postgresql-contrib gnupg supervisor inotify-tools wireguard-tools openssh && \
mkdir /docker-entrypoint-initdb.d && \
rm -rf /var/cache/apk/*
RUN gpg --keyserver ipv4.pool.sks-keyservers.net --recv-keys B42F6819007F00F88E364FD4036A9C25BF357DD4
RUN gpg --list-keys --fingerprint --with-colons | sed -E -n -e 's/^fpr:::::::::([0-9A-F]+):$/\1:6:/p' | gpg --import-ownertrust
RUN wget -O /usr/local/bin/gosu "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64" && \
wget -O /usr/local/bin/gosu.asc "https://github.com/tianon/gosu/releases/download/1.7/gosu-amd64.asc" && \
gpg --verify /usr/local/bin/gosu.asc && \
rm /usr/local/bin/gosu.asc && \
chmod +x /usr/local/bin/gosu
RUN apk --purge del gnupg ca-certificates
VOLUME /var/lib/postgresql/data
RUN rm -rf /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_dsa_key
WORKDIR /build
RUN git clone https://github.com/juanfont/headscale.git
WORKDIR /build/headscale
RUN go build cmd/headscale/headscale.go
COPY headscale.sh /headscale.sh
COPY postgres.sh /postgres.sh
COPY supervisord.conf /etc/supervisord.conf
WORKDIR /
RUN mkdir -p /run/postgresql
RUN chown postgres:postgres /run/postgresql
RUN adduser -S headscale
#ENV GIN_MODE release
EXPOSE 8000
CMD ["supervisord","--nodaemon", "--configuration", "/etc/supervisord.conf"]

View File

@@ -1,28 +0,0 @@
#!/bin/bash
cd /build/headscale
echo 'Writing config...'
echo '''
{
"server_url": "$SERVER_URL",
"listen_addr": "0.0.0.0:8000",
"private_key_path": "private.key",
"public_key_path": "public.key",
"db_host": "localhost",
"db_port": 5432,
"db_name": "headscale",
"db_user": "admin",
"db_pass": "$POSTGRES_PASSWORD"
}
''' > config.json
# Wait until PostgreSQL started and listens on port 5432.
while [ -z "`netstat -tln | grep 5432`" ]; do
echo 'Waiting for PostgreSQL to start ...'
sleep 1
done
echo 'PostgreSQL started.'
# Start server.
echo 'Starting server...'
./headscale

View File

@@ -1,58 +0,0 @@
#!/bin/sh
chown -R postgres "$PGDATA"
if [ -z "$(ls -A "$PGDATA")" ]; then
gosu postgres initdb
sed -ri "s/^#(listen_addresses\s*=\s*)\S+/\1'*'/" "$PGDATA"/postgresql.conf
: ${POSTGRES_USER:="postgres"}
: ${POSTGRES_DB:=$POSTGRES_USER}
if [ "$POSTGRES_PASSWORD" ]; then
pass="PASSWORD '$POSTGRES_PASSWORD'"
authMethod=md5
else
echo "==============================="
echo "!!! NO PASSWORD SET !!! (Use \$POSTGRES_PASSWORD env var)"
echo "==============================="
pass=
authMethod=trust
fi
echo
if [ "$POSTGRES_DB" != 'postgres' ]; then
createSql="CREATE DATABASE $POSTGRES_DB;"
echo $createSql | gosu postgres postgres --single -jE
echo
fi
if [ "$POSTGRES_USER" != 'postgres' ]; then
op=CREATE
else
op=ALTER
fi
userSql="$op USER $POSTGRES_USER WITH SUPERUSER $pass;"
echo $userSql | gosu postgres postgres --single -jE
echo
gosu postgres pg_ctl -D "$PGDATA" \
-o "-c listen_addresses=''" \
-w start
echo
for f in /docker-entrypoint-initdb.d/*; do
case "$f" in
*.sh) echo "$0: running $f"; . "$f" ;;
*.sql) echo "$0: running $f"; psql --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" < "$f" && echo ;;
*) echo "$0: ignoring $f" ;;
esac
echo
done
gosu postgres pg_ctl -D "$PGDATA" -m fast -w stop
{ echo; echo "host all all 0.0.0.0/0 $authMethod"; } >> "$PGDATA"/pg_hba.conf
fi
exec gosu postgres postgres

View File

@@ -1,4 +0,0 @@
# Example of how to user the docker image
POSTGRES_PASSWORD=
docker build . -t headscale-docker
docker run -p 8000:8000 -v $(pwd)/pgdata:/var/lib/postgresql/data -v "$(pwd)/private.key:/build/headscale/private.key" -v "$(pwd)/public.key:/build/headscale/public.key" -e SERVER_URL=127.0.0.1:8000 -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD -ti headscale-docker

View File

@@ -1,13 +0,0 @@
[supervisord]
nodaemon=true
user = root
[program:headscale]
command=/bin/bash -c "/headscale.sh"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
[program:postgres]
command=/bin/bash -c "/postgres.sh"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0

39
docs/DNS.md Normal file
View File

@@ -0,0 +1,39 @@
# DNS in Headscale
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.
## Configuration reference
The setup is done via the `config.yaml` file, under the `dns_config` key.
```yaml
server_url: http://127.0.0.1:8001
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
domains: []
magic_dns: true
base_domain: example.com
```
- `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.

57
go.mod
View File

@@ -3,26 +3,43 @@ module github.com/juanfont/headscale
go 1.16 go 1.16
require ( require (
github.com/fsnotify/fsnotify v1.4.9 // indirect github.com/AlecAivazis/survey/v2 v2.3.2
github.com/gin-gonic/gin v1.7.1 github.com/Microsoft/go-winio v0.5.0 // indirect
github.com/hako/durafmt v0.0.0-20210316092057-3a2c319c1acd github.com/cenkalti/backoff/v4 v4.1.1 // indirect
github.com/jinzhu/gorm v1.9.16 github.com/containerd/continuity v0.1.0 // indirect
github.com/json-iterator/go v1.1.11 // indirect github.com/docker/cli v20.10.8+incompatible // indirect
github.com/klauspost/compress v1.12.2 github.com/docker/docker v20.10.8+incompatible // indirect
github.com/kr/text v0.2.0 // indirect github.com/efekarakus/termcolor v1.0.1
github.com/lib/pq v1.10.1 // indirect github.com/fatih/set v0.2.1 // indirect
github.com/mattn/go-sqlite3 v1.14.7 // indirect github.com/gin-gonic/gin v1.7.4
github.com/spf13/cobra v1.1.3 github.com/gofrs/uuid v4.0.0+incompatible
github.com/spf13/viper v1.7.1 github.com/google/go-github v17.0.0+incompatible // indirect
github.com/stretchr/testify v1.7.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a github.com/hako/durafmt v0.0.0-20210608085754-5c1018a4e16b
golang.org/x/text v0.3.6 // indirect github.com/klauspost/compress v1.13.5
golang.org/x/time v0.0.0-20210220033141-f8bda1e9f3ba // indirect github.com/lib/pq v1.10.3 // indirect
google.golang.org/appengine v1.6.7 // indirect github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6 // indirect
github.com/opencontainers/runc v1.0.2 // indirect
github.com/ory/dockertest/v3 v3.7.0
github.com/prometheus/client_golang v1.11.0
github.com/pterm/pterm v0.12.30
github.com/rs/zerolog v1.25.0
github.com/spf13/cobra v1.2.1
github.com/spf13/viper v1.8.1
github.com/stretchr/testify v1.7.0
github.com/tailscale/hujson v0.0.0-20210818175511-7360507a6e88
github.com/tcnksm/go-latest v0.0.0-20170313132115-e3007ae9052e
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/zsais/go-gin-prometheus v0.1.0
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/net v0.0.0-20210913180222-943fd674d43e // indirect
golang.org/x/sys v0.0.0-20210910150752-751e447fb3d0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c
gopkg.in/yaml.v2 v2.4.0 gopkg.in/yaml.v2 v2.4.0
gorm.io/datatypes v1.0.1 gorm.io/datatypes v1.0.2
inet.af/netaddr v0.0.0-20210511181906-37180328850c gorm.io/driver/postgres v1.1.1
tailscale.com v1.6.0 gorm.io/driver/sqlite v1.1.5
gorm.io/gorm v1.21.15
inet.af/netaddr v0.0.0-20210903134321-85fa6c94624e
tailscale.com v1.14.2
) )

1143
go.sum

File diff suppressed because it is too large Load Diff

739
integration_test.go Normal file
View File

@@ -0,0 +1,739 @@
//go:build integration
// +build integration
package headscale
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"path"
"strings"
"testing"
"time"
"github.com/ory/dockertest/v3"
"github.com/ory/dockertest/v3/docker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/suite"
"tailscale.com/client/tailscale/apitype"
"tailscale.com/ipn/ipnstate"
"inet.af/netaddr"
)
var (
integrationTmpDir string
ih Headscale
)
var (
pool dockertest.Pool
network dockertest.Network
headscale dockertest.Resource
)
var tailscaleVersions = []string{"1.14.3", "1.12.3"}
type TestNamespace struct {
count int
tailscales map[string]dockertest.Resource
}
type IntegrationTestSuite struct {
suite.Suite
stats *suite.SuiteInformation
namespaces map[string]TestNamespace
}
func TestIntegrationTestSuite(t *testing.T) {
s := new(IntegrationTestSuite)
s.namespaces = map[string]TestNamespace{
"main": {
count: 20,
tailscales: make(map[string]dockertest.Resource),
},
"shared": {
count: 5,
tailscales: make(map[string]dockertest.Resource),
},
}
suite.Run(t, s)
// HandleStats, which allows us to check if we passed and save logs
// is called after TearDown, so we cannot tear down containers before
// we have potentially saved the logs.
for _, scales := range s.namespaces {
for _, tailscale := range scales.tailscales {
if err := pool.Purge(&tailscale); err != nil {
log.Printf("Could not purge resource: %s\n", err)
}
}
}
if !s.stats.Passed() {
err := saveLog(&headscale, "test_output")
if err != nil {
log.Printf("Could not save log: %s\n", err)
}
}
if err := pool.Purge(&headscale); err != nil {
log.Printf("Could not purge resource: %s\n", err)
}
if err := network.Close(); err != nil {
log.Printf("Could not close network: %s\n", err)
}
}
func executeCommand(resource *dockertest.Resource, cmd []string, env []string) (string, error) {
var stdout bytes.Buffer
var stderr bytes.Buffer
exitCode, err := resource.Exec(
cmd,
dockertest.ExecOptions{
Env: env,
StdOut: &stdout,
StdErr: &stderr,
},
)
if err != nil {
return "", err
}
if exitCode != 0 {
fmt.Println("Command: ", cmd)
fmt.Println("stdout: ", stdout.String())
fmt.Println("stderr: ", stderr.String())
return "", fmt.Errorf("command failed with: %s", stderr.String())
}
return stdout.String(), nil
}
func saveLog(resource *dockertest.Resource, basePath string) error {
err := os.MkdirAll(basePath, os.ModePerm)
if err != nil {
return err
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err = pool.Client.Logs(
docker.LogsOptions{
Context: context.TODO(),
Container: resource.Container.ID,
OutputStream: &stdout,
ErrorStream: &stderr,
Tail: "all",
RawTerminal: false,
Stdout: true,
Stderr: true,
Follow: false,
Timestamps: false,
},
)
if err != nil {
return err
}
fmt.Printf("Saving logs for %s to %s\n", resource.Container.Name, basePath)
err = ioutil.WriteFile(path.Join(basePath, resource.Container.Name+".stdout.log"), []byte(stdout.String()), 0o644)
if err != nil {
return err
}
err = ioutil.WriteFile(path.Join(basePath, resource.Container.Name+".stderr.log"), []byte(stdout.String()), 0o644)
if err != nil {
return err
}
return nil
}
func dockerRestartPolicy(config *docker.HostConfig) {
// set AutoRemove to true so that stopped container goes away by itself
config.AutoRemove = true
config.RestartPolicy = docker.RestartPolicy{
Name: "no",
}
}
func tailscaleContainer(namespace, identifier, version string) (string, *dockertest.Resource) {
tailscaleBuildOptions := &dockertest.BuildOptions{
Dockerfile: "Dockerfile.tailscale",
ContextDir: ".",
BuildArgs: []docker.BuildArg{
{
Name: "TAILSCALE_VERSION",
Value: version,
},
},
}
hostname := fmt.Sprintf("%s-tailscale-%s-%s", namespace, strings.Replace(version, ".", "-", -1), identifier)
tailscaleOptions := &dockertest.RunOptions{
Name: hostname,
Networks: []*dockertest.Network{&network},
Cmd: []string{"tailscaled", "--tun=userspace-networking", "--socks5-server=localhost:1055"},
}
pts, err := pool.BuildAndRunWithBuildOptions(tailscaleBuildOptions, tailscaleOptions, dockerRestartPolicy)
if err != nil {
log.Fatalf("Could not start resource: %s", err)
}
fmt.Printf("Created %s container\n", hostname)
return hostname, pts
}
func (s *IntegrationTestSuite) SetupSuite() {
var err error
h = Headscale{
dbType: "sqlite3",
dbString: "integration_test_db.sqlite3",
}
if ppool, err := dockertest.NewPool(""); err == nil {
pool = *ppool
} else {
log.Fatalf("Could not connect to docker: %s", err)
}
if pnetwork, err := pool.CreateNetwork("headscale-test"); err == nil {
network = *pnetwork
} else {
log.Fatalf("Could not create network: %s", err)
}
headscaleBuildOptions := &dockertest.BuildOptions{
Dockerfile: "Dockerfile",
ContextDir: ".",
}
currentPath, err := os.Getwd()
if err != nil {
log.Fatalf("Could not determine current path: %s", err)
}
headscaleOptions := &dockertest.RunOptions{
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"},
}
fmt.Println("Creating headscale container")
if pheadscale, err := pool.BuildAndRunWithBuildOptions(headscaleBuildOptions, headscaleOptions, dockerRestartPolicy); err == nil {
headscale = *pheadscale
} else {
log.Fatalf("Could not start resource: %s", err)
}
fmt.Println("Created headscale container")
fmt.Println("Creating tailscale containers")
for namespace, scales := range s.namespaces {
for i := 0; i < scales.count; i++ {
version := tailscaleVersions[i%len(tailscaleVersions)]
hostname, container := tailscaleContainer(namespace, fmt.Sprint(i), version)
scales.tailscales[hostname] = *container
}
}
fmt.Println("Waiting for headscale to be ready")
hostEndpoint := fmt.Sprintf("localhost:%s", headscale.GetPort("8080/tcp"))
if err := pool.Retry(func() error {
url := fmt.Sprintf("http://%s/health", hostEndpoint)
resp, err := http.Get(url)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status code not OK")
}
return nil
}); err != nil {
// TODO(kradalby): If we cannot access headscale, or any other fatal error during
// test setup, we need to abort and tear down. However, testify does not seem to
// support that at the moment:
// https://github.com/stretchr/testify/issues/849
return // fmt.Errorf("Could not connect to headscale: %s", err)
}
fmt.Println("headscale container is ready")
for namespace, scales := range s.namespaces {
fmt.Printf("Creating headscale namespace: %s\n", namespace)
result, err := executeCommand(
&headscale,
[]string{"headscale", "namespaces", "create", namespace},
[]string{},
)
assert.Nil(s.T(), err)
fmt.Println("headscale create namespace result: ", result)
fmt.Printf("Creating pre auth key for %s\n", namespace)
authKey, err := executeCommand(
&headscale,
[]string{"headscale", "--namespace", namespace, "preauthkeys", "create", "--reusable", "--expiration", "24h"},
[]string{},
)
assert.Nil(s.T(), err)
headscaleEndpoint := "http://headscale:8080"
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}
fmt.Println("Join command:", command)
fmt.Printf("Running join command for %s\n", hostname)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
fmt.Println("tailscale result: ", result)
assert.Nil(s.T(), err)
fmt.Printf("%s joined\n", hostname)
}
}
// The nodes need a bit of time to get their updated maps from headscale
// TODO: See if we can have a more deterministic wait here.
time.Sleep(60 * time.Second)
}
func (s *IntegrationTestSuite) TearDownSuite() {
}
func (s *IntegrationTestSuite) HandleStats(suiteName string, stats *suite.SuiteInformation) {
s.stats = stats
}
func (s *IntegrationTestSuite) TestListNodes() {
for namespace, scales := range s.namespaces {
fmt.Println("Listing nodes")
result, err := executeCommand(
&headscale,
[]string{"headscale", "--namespace", namespace, "nodes", "list"},
[]string{},
)
assert.Nil(s.T(), err)
fmt.Printf("List nodes: \n%s\n", result)
// Chck that the correct count of host is present in node list
lines := strings.Split(result, "\n")
assert.Equal(s.T(), len(scales.tailscales), len(lines)-2)
for hostname := range scales.tailscales {
assert.Contains(s.T(), result, hostname)
}
}
}
func (s *IntegrationTestSuite) TestGetIpAddresses() {
for _, scales := range s.namespaces {
ipPrefix := netaddr.MustParseIPPrefix("100.64.0.0/10")
ips, err := getIPs(scales.tailscales)
assert.Nil(s.T(), err)
for hostname := range scales.tailscales {
s.T().Run(hostname, func(t *testing.T) {
ip, ok := ips[hostname]
assert.True(t, ok)
assert.NotNil(t, ip)
fmt.Printf("IP for %s: %s\n", hostname, ip)
// c.Assert(ip.Valid(), check.IsTrue)
assert.True(t, ip.Is4())
assert.True(t, ipPrefix.Contains(ip))
})
}
}
}
func (s *IntegrationTestSuite) TestStatus() {
for _, scales := range s.namespaces {
ips, err := getIPs(scales.tailscales)
assert.Nil(s.T(), err)
for hostname, tailscale := range scales.tailscales {
s.T().Run(hostname, func(t *testing.T) {
command := []string{"tailscale", "status", "--json"}
fmt.Printf("Getting status for %s\n", hostname)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(t, err)
var status ipnstate.Status
err = json.Unmarshal([]byte(result), &status)
assert.Nil(s.T(), err)
// TODO(kradalby): Replace this check with peer length of SAME namespace
// Check if we have as many nodes in status
// as we have IPs/tailscales
// lines := strings.Split(result, "\n")
// assert.Equal(t, len(ips), len(lines)-1)
// assert.Equal(t, len(scales.tailscales), len(lines)-1)
peerIps := getIPsfromIPNstate(status)
// Check that all hosts is present in all hosts status
for ipHostname, ip := range ips {
if hostname != ipHostname {
assert.Contains(t, peerIps, ip)
}
}
})
}
}
}
func getIPsfromIPNstate(status ipnstate.Status) []netaddr.IP {
ips := make([]netaddr.IP, 0)
for _, peer := range status.Peer {
ips = append(ips, peer.TailscaleIPs...)
}
return ips
}
func (s *IntegrationTestSuite) TestPingAllPeers() {
for _, scales := range s.namespaces {
ips, err := getIPs(scales.tailscales)
assert.Nil(s.T(), err)
for hostname, tailscale := range scales.tailscales {
for peername, ip := range ips {
s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
// We currently cant ping ourselves, so skip that.
if peername != hostname {
// We are only interested in "direct ping" which means what we
// might need a couple of more attempts before reaching the node.
command := []string{
"tailscale", "ping",
"--timeout=1s",
"--c=10",
"--until-direct=true",
ip.String(),
}
fmt.Printf("Pinging from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(t, err)
fmt.Printf("Result for %s: %s\n", hostname, result)
assert.Contains(t, result, "pong")
}
})
}
}
}
}
func (s *IntegrationTestSuite) TestSharedNodes() {
main := s.namespaces["main"]
shared := s.namespaces["shared"]
result, err := executeCommand(
&headscale,
[]string{"headscale", "nodes", "list", "-o", "json", "--namespace", "shared"},
[]string{},
)
assert.Nil(s.T(), err)
var machineList []Machine
err = json.Unmarshal([]byte(result), &machineList)
assert.Nil(s.T(), err)
for _, machine := range machineList {
result, err := executeCommand(
&headscale,
[]string{"headscale", "nodes", "share", "--namespace", "shared", fmt.Sprint(machine.ID), "main"},
[]string{},
)
assert.Nil(s.T(), err)
fmt.Println("Shared node with result: ", result)
}
result, err = executeCommand(
&headscale,
[]string{"headscale", "nodes", "list", "--namespace", "main"},
[]string{},
)
assert.Nil(s.T(), err)
fmt.Println("Nodelist after sharing", result)
// Chck that the correct count of host is present in node list
lines := strings.Split(result, "\n")
assert.Equal(s.T(), len(main.tailscales)+len(shared.tailscales), len(lines)-2)
for hostname := range main.tailscales {
assert.Contains(s.T(), result, hostname)
}
for hostname := range shared.tailscales {
assert.Contains(s.T(), result, hostname)
}
// TODO(juanfont): We have to find out why do we need to wait
time.Sleep(100 * time.Second) // Wait for the nodes to receive updates
mainIps, err := getIPs(main.tailscales)
assert.Nil(s.T(), err)
sharedIps, err := getIPs(shared.tailscales)
assert.Nil(s.T(), err)
for hostname, tailscale := range main.tailscales {
for peername, ip := range sharedIps {
s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
// We currently cant ping ourselves, so skip that.
if peername != hostname {
// We are only interested in "direct ping" which means what we
// might need a couple of more attempts before reaching the node.
command := []string{
"tailscale", "ping",
"--timeout=15s",
"--c=20",
"--until-direct=true",
ip.String(),
}
fmt.Printf("Pinging from %s (%s) to %s (%s)\n", hostname, mainIps[hostname], peername, ip)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(t, err)
fmt.Printf("Result for %s: %s\n", hostname, result)
assert.Contains(t, result, "pong")
}
})
}
}
}
func (s *IntegrationTestSuite) TestTailDrop() {
for _, scales := range s.namespaces {
ips, err := getIPs(scales.tailscales)
assert.Nil(s.T(), err)
apiURLs, err := getAPIURLs(scales.tailscales)
assert.Nil(s.T(), err)
for hostname, tailscale := range scales.tailscales {
command := []string{"touch", fmt.Sprintf("/tmp/file_from_%s", hostname)}
_, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(s.T(), err)
for peername, ip := range ips {
s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
if peername != hostname {
// Under normal circumstances, we should be able to send a file
// using `tailscale file cp` - but not in userspace networking mode
// So curl!
peerAPI, ok := apiURLs[ip]
assert.True(t, ok)
// TODO(juanfont): We still have some issues with the test infrastructure, so
// lets run curl multiple times until it works.
attempts := 0
var err error
for {
command := []string{
"curl",
"--retry-connrefused",
"--retry-delay",
"30",
"--retry",
"10",
"--connect-timeout",
"60",
"-X",
"PUT",
"--upload-file",
fmt.Sprintf("/tmp/file_from_%s", hostname),
fmt.Sprintf("%s/v0/put/file_from_%s", peerAPI, hostname),
}
fmt.Printf("Sending file from %s (%s) to %s (%s)\n", hostname, ips[hostname], peername, ip)
_, err = executeCommand(
&tailscale,
command,
[]string{"ALL_PROXY=socks5://localhost:1055"},
)
if err == nil {
break
} else {
time.Sleep(10 * time.Second)
attempts++
if attempts > 10 {
break
}
}
}
assert.Nil(t, err)
}
})
}
}
for hostname, tailscale := range scales.tailscales {
command := []string{
"tailscale", "file",
"get",
"/tmp/",
}
_, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(s.T(), err)
for peername, ip := range ips {
s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
if peername != hostname {
command := []string{
"ls",
fmt.Sprintf("/tmp/file_from_%s", peername),
}
fmt.Printf("Checking file in %s (%s) from %s (%s)\n", hostname, ips[hostname], peername, ip)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(t, err)
fmt.Printf("Result for %s: %s\n", peername, result)
assert.Equal(t, result, fmt.Sprintf("/tmp/file_from_%s\n", peername))
}
})
}
}
}
}
func (s *IntegrationTestSuite) TestMagicDNS() {
for namespace, scales := range s.namespaces {
ips, err := getIPs(scales.tailscales)
assert.Nil(s.T(), err)
for hostname, tailscale := range scales.tailscales {
for peername, ip := range ips {
s.T().Run(fmt.Sprintf("%s-%s", hostname, peername), func(t *testing.T) {
if peername != hostname {
command := []string{
"tailscale", "ping",
"--timeout=10s",
"--c=20",
"--until-direct=true",
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)
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
assert.Nil(t, err)
fmt.Printf("Result for %s: %s\n", hostname, result)
assert.Contains(t, result, "pong")
}
})
}
}
}
}
func getIPs(tailscales map[string]dockertest.Resource) (map[string]netaddr.IP, error) {
ips := make(map[string]netaddr.IP)
for hostname, tailscale := range tailscales {
command := []string{"tailscale", "ip"}
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
if err != nil {
return nil, err
}
ip, err := netaddr.ParseIP(strings.TrimSuffix(result, "\n"))
if err != nil {
return nil, err
}
ips[hostname] = ip
}
return ips, nil
}
func getAPIURLs(tailscales map[string]dockertest.Resource) (map[netaddr.IP]string, error) {
fts := make(map[netaddr.IP]string)
for _, tailscale := range tailscales {
command := []string{
"curl",
"--unix-socket",
"/run/tailscale/tailscaled.sock",
"http://localhost/localapi/v0/file-targets",
}
result, err := executeCommand(
&tailscale,
command,
[]string{},
)
if err != nil {
return nil, err
}
var pft []apitype.FileTarget
if err := json.Unmarshal([]byte(result), &pft); err != nil {
return nil, fmt.Errorf("invalid JSON: %w", err)
}
for _, ft := range pft {
n := ft.Node
for _, a := range n.Addresses { // just add all the addresses
if _, ok := fts[a.IP()]; !ok {
if ft.PeerAPIURL == "" {
return nil, errors.New("api url is empty")
}
fts[a.IP()] = ft.PeerAPIURL
}
}
}
}
return fts, nil
}

3
integration_test/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
derp.yaml
*.sqlite
*.sqlite3

View File

@@ -0,0 +1,19 @@
{
"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"
}
}

View File

@@ -0,0 +1 @@
SEmQwCu+tGywQWEUsf93TpTRUvlB7WhnCdHgWrSXjEA=

2
k8s/.gitignore vendored Normal file
View File

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

97
k8s/README.md Normal file
View File

@@ -0,0 +1,97 @@
# 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
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
- 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

@@ -3,15 +3,18 @@ package headscale
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"sort" "sort"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/fatih/set"
"github.com/rs/zerolog/log"
"gorm.io/datatypes" "gorm.io/datatypes"
"inet.af/netaddr" "inet.af/netaddr"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg" "tailscale.com/types/wgkey"
) )
// Machine is a Headscale client // Machine is a Headscale client
@@ -23,15 +26,16 @@ type Machine struct {
IPAddress string IPAddress string
Name string Name string
NamespaceID uint NamespaceID uint
Namespace Namespace Namespace Namespace `gorm:"foreignKey:NamespaceID"`
Registered bool // temp Registered bool // temp
RegisterMethod string RegisterMethod string
AuthKeyID uint AuthKeyID uint
AuthKey *PreAuthKey AuthKey *PreAuthKey
LastSeen *time.Time LastSeen *time.Time
Expiry *time.Time LastSuccessfulUpdate *time.Time
Expiry *time.Time
HostInfo datatypes.JSON HostInfo datatypes.JSON
Endpoints datatypes.JSON Endpoints datatypes.JSON
@@ -42,24 +46,316 @@ type Machine struct {
DeletedAt *time.Time DeletedAt *time.Time
} }
type (
Machines []Machine
MachinesP []*Machine
)
// For the time being this method is rather naive // For the time being this method is rather naive
func (m Machine) isAlreadyRegistered() bool { func (m Machine) isAlreadyRegistered() bool {
return m.Registered return m.Registered
} }
func (m Machine) toNode() (*tailcfg.Node, error) { func (h *Headscale) getDirectPeers(m *Machine) (Machines, error) {
nKey, err := wgcfg.ParseHexKey(m.NodeKey) log.Trace().
Str("func", "getDirectPeers").
Str("machine", m.Name).
Msg("Finding direct peers")
machines := Machines{}
if err := h.db.Preload("Namespace").Where("namespace_id = ? AND machine_key <> ? AND registered",
m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil {
log.Error().Err(err).Msg("Error accessing db")
return Machines{}, err
}
sort.Slice(machines, func(i, j int) bool { return machines[i].ID < machines[j].ID })
log.Trace().
Str("func", "getDirectmachines").
Str("machine", m.Name).
Msgf("Found direct machines: %s", machines.String())
return machines, nil
}
// getShared fetches machines that are shared to the `Namespace` of the machine we are getting peers for
func (h *Headscale) getShared(m *Machine) (Machines, error) {
log.Trace().
Str("func", "getShared").
Str("machine", m.Name).
Msg("Finding shared peers")
sharedMachines := []SharedMachine{}
if err := h.db.Preload("Namespace").Preload("Machine").Preload("Machine.Namespace").Where("namespace_id = ?",
m.NamespaceID).Find(&sharedMachines).Error; err != nil {
return Machines{}, err
}
peers := make(Machines, 0)
for _, sharedMachine := range sharedMachines {
peers = append(peers, sharedMachine.Machine)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].ID < peers[j].ID })
log.Trace().
Str("func", "getShared").
Str("machine", m.Name).
Msgf("Found shared peers: %s", peers.String())
return peers, nil
}
// getSharedTo fetches the machines of the namespaces this machine is shared in
func (h *Headscale) getSharedTo(m *Machine) (Machines, error) {
log.Trace().
Str("func", "getSharedTo").
Str("machine", m.Name).
Msg("Finding peers in namespaces this machine is shared with")
sharedMachines := []SharedMachine{}
if err := h.db.Preload("Namespace").Preload("Machine").Preload("Machine.Namespace").Where("machine_id = ?",
m.ID).Find(&sharedMachines).Error; err != nil {
return Machines{}, err
}
peers := make(Machines, 0)
for _, sharedMachine := range sharedMachines {
namespaceMachines, err := h.ListMachinesInNamespace(sharedMachine.Namespace.Name)
if err != nil {
return Machines{}, err
}
peers = append(peers, *namespaceMachines...)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].ID < peers[j].ID })
log.Trace().
Str("func", "getSharedTo").
Str("machine", m.Name).
Msgf("Found peers we are shared with: %s", peers.String())
return peers, nil
}
func (h *Headscale) getPeers(m *Machine) (Machines, error) {
direct, err := h.getDirectPeers(m)
if err != nil {
log.Error().
Str("func", "getPeers").
Err(err).
Msg("Cannot fetch peers")
return Machines{}, err
}
shared, err := h.getShared(m)
if err != nil {
log.Error().
Str("func", "getShared").
Err(err).
Msg("Cannot fetch peers")
return Machines{}, err
}
sharedTo, err := h.getSharedTo(m)
if err != nil {
log.Error().
Str("func", "sharedTo").
Err(err).
Msg("Cannot fetch peers")
return Machines{}, err
}
peers := append(direct, shared...)
peers = append(peers, sharedTo...)
sort.Slice(peers, func(i, j int) bool { return peers[i].ID < peers[j].ID })
log.Trace().
Str("func", "getShared").
Str("machine", m.Name).
Msgf("Found total peers: %s", peers.String())
return peers, nil
}
// GetMachine finds a Machine by name and namespace and returns the Machine struct
func (h *Headscale) GetMachine(namespace string, name string) (*Machine, error) {
machines, err := h.ListMachinesInNamespace(namespace)
if err != nil { if err != nil {
return nil, err return nil, err
} }
mKey, err := wgcfg.ParseHexKey(m.MachineKey)
for _, m := range *machines {
if m.Name == name {
return &m, nil
}
}
return nil, fmt.Errorf("machine not found")
}
// GetMachineByID finds a Machine by ID and returns the Machine struct
func (h *Headscale) GetMachineByID(id uint64) (*Machine, error) {
m := Machine{}
if result := h.db.Preload("Namespace").Find(&Machine{ID: id}).First(&m); result.Error != nil {
return nil, result.Error
}
return &m, nil
}
// GetMachineByMachineKey finds a Machine by ID and returns the Machine struct
func (h *Headscale) GetMachineByMachineKey(mKey string) (*Machine, error) {
m := Machine{}
if result := h.db.Preload("Namespace").First(&m, "machine_key = ?", mKey); result.Error != nil {
return nil, result.Error
}
return &m, nil
}
// UpdateMachine takes a Machine struct pointer (typically already loaded from database
// and updates it with the latest data from the database.
func (h *Headscale) UpdateMachine(m *Machine) error {
if result := h.db.Find(m).First(&m); result.Error != nil {
return result.Error
}
return nil
}
// DeleteMachine softs deletes a Machine from the database
func (h *Headscale) DeleteMachine(m *Machine) error {
err := h.RemoveSharedMachineFromAllNamespaces(m)
if err != nil && err != errorMachineNotShared {
return err
}
m.Registered = false
namespaceID := m.NamespaceID
h.db.Save(&m) // we mark it as unregistered, just in case
if err := h.db.Delete(&m).Error; err != nil {
return err
}
return h.RequestMapUpdates(namespaceID)
}
// HardDeleteMachine hard deletes a Machine from the database
func (h *Headscale) HardDeleteMachine(m *Machine) error {
err := h.RemoveSharedMachineFromAllNamespaces(m)
if err != nil && err != errorMachineNotShared {
return err
}
namespaceID := m.NamespaceID
if err := h.db.Unscoped().Delete(&m).Error; err != nil {
return err
}
return h.RequestMapUpdates(namespaceID)
}
// GetHostInfo returns a Hostinfo struct for the machine
func (m *Machine) GetHostInfo() (*tailcfg.Hostinfo, error) {
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
}
}
return &hostinfo, nil
}
func (h *Headscale) isOutdated(m *Machine) bool {
err := h.UpdateMachine(m)
if err != nil {
// It does not seem meaningful to propagate this error as the end result
// will have to be that the machine has to be considered outdated.
return true
}
sharedMachines, _ := h.getShared(m)
namespaceSet := set.New(set.ThreadSafe)
namespaceSet.Add(m.Namespace.Name)
// Check if any of our shared namespaces has updates that we have
// not propagated.
for _, sharedMachine := range sharedMachines {
namespaceSet.Add(sharedMachine.Namespace.Name)
}
namespaces := make([]string, namespaceSet.Size())
for index, namespace := range namespaceSet.List() {
namespaces[index] = namespace.(string)
}
lastChange := h.getLastStateChange(namespaces...)
log.Trace().
Str("func", "keepAlive").
Str("machine", m.Name).
Time("last_successful_update", *m.LastSuccessfulUpdate).
Time("last_state_change", lastChange).
Msgf("Checking if %s is missing updates", m.Name)
return m.LastSuccessfulUpdate.Before(lastChange)
}
func (m Machine) String() string {
return m.Name
}
func (ms Machines) String() string {
temp := make([]string, len(ms))
for index, machine := range ms {
temp[index] = machine.Name
}
return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
}
// TODO(kradalby): Remove when we have generics...
func (ms MachinesP) String() string {
temp := make([]string, len(ms))
for index, machine := range ms {
temp[index] = machine.Name
}
return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
}
func (ms Machines) toNodes(baseDomain string, dnsConfig *tailcfg.DNSConfig, includeRoutes bool) ([]*tailcfg.Node, error) {
nodes := make([]*tailcfg.Node, len(ms))
for index, machine := range ms {
node, err := machine.toNode(baseDomain, dnsConfig, includeRoutes)
if err != nil {
return nil, err
}
nodes[index] = node
}
return nodes, nil
}
// toNode converts a Machine into a Tailscale Node. includeRoutes is false for shared nodes
// as per the expected behaviour in the official SaaS
func (m Machine) toNode(baseDomain string, dnsConfig *tailcfg.DNSConfig, includeRoutes bool) (*tailcfg.Node, error) {
nKey, err := wgkey.ParseHex(m.NodeKey)
if err != nil {
return nil, err
}
mKey, err := wgkey.ParseHex(m.MachineKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var discoKey tailcfg.DiscoKey var discoKey tailcfg.DiscoKey
if m.DiscoKey != "" { if m.DiscoKey != "" {
dKey, err := wgcfg.ParseHexKey(m.DiscoKey) dKey, err := wgkey.ParseHex(m.DiscoKey)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -71,6 +367,10 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
addrs := []netaddr.IPPrefix{} addrs := []netaddr.IPPrefix{}
ip, err := netaddr.ParseIPPrefix(fmt.Sprintf("%s/32", m.IPAddress)) ip, err := netaddr.ParseIPPrefix(fmt.Sprintf("%s/32", m.IPAddress))
if err != nil { if err != nil {
log.Trace().
Str("func", "toNode").
Str("ip", m.IPAddress).
Msgf("Failed to parse IP Prefix from IP: %s", m.IPAddress)
return nil, err return nil, err
} }
addrs = append(addrs, ip) // missing the ipv6 ? addrs = append(addrs, ip) // missing the ipv6 ?
@@ -78,24 +378,26 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
allowedIPs := []netaddr.IPPrefix{} allowedIPs := []netaddr.IPPrefix{}
allowedIPs = append(allowedIPs, ip) // we append the node own IP, as it is required by the clients allowedIPs = append(allowedIPs, ip) // we append the node own IP, as it is required by the clients
routesStr := []string{} if includeRoutes {
if len(m.EnabledRoutes) != 0 { routesStr := []string{}
allwIps, err := m.EnabledRoutes.MarshalJSON() if len(m.EnabledRoutes) != 0 {
if err != nil { allwIps, err := m.EnabledRoutes.MarshalJSON()
return nil, err if err != nil {
return nil, err
}
err = json.Unmarshal(allwIps, &routesStr)
if err != nil {
return nil, err
}
} }
err = json.Unmarshal(allwIps, &routesStr)
if err != nil {
return nil, err
}
}
for _, aip := range routesStr { for _, routeStr := range routesStr {
ip, err := netaddr.ParseIPPrefix(aip) ip, err := netaddr.ParseIPPrefix(routeStr)
if err != nil { if err != nil {
return nil, err return nil, err
}
allowedIPs = append(allowedIPs, ip)
} }
allowedIPs = append(allowedIPs, ip)
} }
endpoints := []string{} endpoints := []string{}
@@ -129,13 +431,27 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
derp = "127.3.3.40:0" // Zero means disconnected or unknown. derp = "127.3.3.40:0" // Zero means disconnected or unknown.
} }
var keyExpiry time.Time
if m.Expiry != nil {
keyExpiry = *m.Expiry
} else {
keyExpiry = time.Time{}
}
var hostname string
if dnsConfig != nil && dnsConfig.Proxied { // MagicDNS
hostname = fmt.Sprintf("%s.%s.%s", m.Name, m.Namespace.Name, baseDomain)
} else {
hostname = m.Name
}
n := tailcfg.Node{ n := tailcfg.Node{
ID: tailcfg.NodeID(m.ID), // this is the actual ID ID: tailcfg.NodeID(m.ID), // this is the actual ID
StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)), // in headscale, unlike tailcontrol server, IDs are permanent StableID: tailcfg.StableNodeID(strconv.FormatUint(m.ID, 10)), // in headscale, unlike tailcontrol server, IDs are permanent
Name: hostinfo.Hostname, Name: hostname,
User: tailcfg.UserID(m.NamespaceID), User: tailcfg.UserID(m.NamespaceID),
Key: tailcfg.NodeKey(nKey), Key: tailcfg.NodeKey(nKey),
KeyExpiry: *m.Expiry, KeyExpiry: keyExpiry,
Machine: tailcfg.MachineKey(mKey), Machine: tailcfg.MachineKey(mKey),
DiscoKey: discoKey, DiscoKey: discoKey,
Addresses: addrs, Addresses: addrs,
@@ -149,64 +465,7 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
KeepAlive: true, KeepAlive: true,
MachineAuthorized: m.Registered, MachineAuthorized: m.Registered,
Capabilities: []string{tailcfg.CapabilityFileSharing},
} }
return &n, nil return &n, nil
} }
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",
m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil {
log.Printf("Error accessing db: %s", err)
return nil, err
}
peers := []*tailcfg.Node{}
for _, mn := range machines {
peer, err := mn.toNode()
if err != nil {
return nil, err
}
peers = append(peers, peer)
}
sort.Slice(peers, func(i, j int) bool { return peers[i].ID < peers[j].ID })
return &peers, nil
}
// GetMachine finds a Machine by name and namespace and returns the Machine struct
func (h *Headscale) GetMachine(namespace string, name string) (*Machine, error) {
machines, err := h.ListMachinesInNamespace(namespace)
if err != nil {
return nil, err
}
for _, m := range *machines {
if m.Name == name {
return &m, nil
}
}
return nil, fmt.Errorf("not found")
}
// GetHostInfo returns a Hostinfo struct for the machine
func (m *Machine) GetHostInfo() (*tailcfg.Hostinfo, error) {
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
}
}
return &hostinfo, nil
}

View File

@@ -1,6 +1,9 @@
package headscale package headscale
import ( import (
"encoding/json"
"strconv"
"gopkg.in/check.v1" "gopkg.in/check.v1"
) )
@@ -11,15 +14,39 @@ func (s *Suite) TestGetMachine(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil) pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine") _, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil) c.Assert(err, check.NotNil)
m := &Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
h.db.Save(m)
m1, err := h.GetMachine("test", "testmachine")
c.Assert(err, check.IsNil)
_, err = m1.GetHostInfo()
c.Assert(err, check.IsNil)
}
func (s *Suite) TestGetMachineByID(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachineByID(0)
c.Assert(err, check.NotNil)
m := Machine{ m := Machine{
ID: 0, ID: 0,
MachineKey: "foo", MachineKey: "foo",
@@ -31,12 +58,102 @@ func (s *Suite) TestGetMachine(c *check.C) {
RegisterMethod: "authKey", RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID), AuthKeyID: uint(pak.ID),
} }
db.Save(&m) h.db.Save(&m)
m1, err := h.GetMachine("test", "testmachine") m1, err := h.GetMachineByID(0)
c.Assert(err, check.IsNil)
_, err = m1.GetHostInfo()
c.Assert(err, check.IsNil)
}
func (s *Suite) TestDeleteMachine(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(1),
}
h.db.Save(&m)
err = h.DeleteMachine(&m)
c.Assert(err, check.IsNil)
v, err := h.getValue("namespaces_pending_updates")
c.Assert(err, check.IsNil)
names := []string{}
err = json.Unmarshal([]byte(v), &names)
c.Assert(err, check.IsNil)
c.Assert(names, check.DeepEquals, []string{n.Name})
h.checkForNamespacesPendingUpdates()
v, _ = h.getValue("namespaces_pending_updates")
c.Assert(v, check.Equals, "")
_, err = h.GetMachine(n.Name, "testmachine")
c.Assert(err, check.NotNil)
}
func (s *Suite) TestHardDeleteMachine(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine3",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(1),
}
h.db.Save(&m)
err = h.HardDeleteMachine(&m)
c.Assert(err, check.IsNil)
_, err = h.GetMachine(n.Name, "testmachine3")
c.Assert(err, check.NotNil)
}
func (s *Suite) TestGetDirectPeers(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachineByID(0)
c.Assert(err, check.NotNil)
for i := 0; i <= 10; i++ {
m := Machine{
ID: uint64(i),
MachineKey: "foo" + strconv.Itoa(i),
NodeKey: "bar" + strconv.Itoa(i),
DiscoKey: "faa" + strconv.Itoa(i),
Name: "testmachine" + strconv.Itoa(i),
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
h.db.Save(&m)
}
m1, err := h.GetMachineByID(0)
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
_, err = m1.GetHostInfo() _, err = m1.GetHostInfo()
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
peers, err := h.getDirectPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(peers), check.Equals, 9)
c.Assert(peers[0].Name, check.Equals, "testmachine2")
c.Assert(peers[5].Name, check.Equals, "testmachine7")
c.Assert(peers[8].Name, check.Equals, "testmachine10")
} }

41
metrics.go Normal file
View File

@@ -0,0 +1,41 @@
package headscale
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
const prometheusNamespace = "headscale"
var (
// This is a high cardinality metric (namespace x machines), we might want to make this
// configurable/opt-in in the future.
lastStateUpdate = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: prometheusNamespace,
Name: "last_update_seconds",
Help: "Time stamp in unix time when a machine or headscale was updated",
}, []string{"namespace", "machine"})
machineRegistrations = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "machine_registrations_total",
Help: "The total amount of registered machine attempts",
}, []string{"action", "auth", "status", "namespace"})
updateRequestsFromNode = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "update_request_from_node_total",
Help: "The number of updates requested by a node/update function",
}, []string{"namespace", "machine", "state"})
updateRequestsSentToNode = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "update_request_sent_to_node_total",
Help: "The number of calls/messages issued on a specific nodes update channel",
}, []string{"namespace", "machine", "status"})
//TODO(kradalby): This is very debugging, we might want to remove it.
updateRequestsReceivedOnChannel = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: prometheusNamespace,
Name: "update_request_received_on_channel_total",
Help: "The number of update requests received on an update channel",
}, []string{"namespace", "machine"})
)

View File

@@ -1,10 +1,13 @@
package headscale package headscale
import ( import (
"log" "encoding/json"
"errors"
"fmt"
"time" "time"
"github.com/jinzhu/gorm" "github.com/rs/zerolog/log"
"gorm.io/gorm"
"tailscale.com/tailcfg" "tailscale.com/tailcfg"
) )
@@ -24,20 +27,16 @@ type Namespace struct {
// CreateNamespace creates a new Namespace. Returns error if could not be created // CreateNamespace creates a new Namespace. Returns error if could not be created
// or another namespace already exists // or another namespace already exists
func (h *Headscale) CreateNamespace(name string) (*Namespace, error) { 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{} 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 return nil, errorNamespaceExists
} }
n.Name = name 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) log.Error().
Str("func", "CreateNamespace").
Err(err).
Msg("Could not create row")
return nil, err return nil, err
} }
return &n, nil return &n, nil
@@ -46,13 +45,6 @@ func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
// DestroyNamespace destroys a Namespace. Returns error if the Namespace does // DestroyNamespace destroys a Namespace. Returns error if the Namespace does
// not exist or if there are machines associated with it. // not exist or if there are machines associated with it.
func (h *Headscale) DestroyNamespace(name string) error { 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) n, err := h.GetNamespace(name)
if err != nil { if err != nil {
return errorNamespaceNotFound return errorNamespaceNotFound
@@ -66,7 +58,35 @@ func (h *Headscale) DestroyNamespace(name string) error {
return errorNamespaceNotEmpty return errorNamespaceNotEmpty
} }
err = db.Unscoped().Delete(&n).Error if result := h.db.Unscoped().Delete(&n); result.Error != nil {
return result.Error
}
return nil
}
// RenameNamespace renames a Namespace. Returns error if the Namespace does
// not exist or if another Namespace exists with the new name.
func (h *Headscale) RenameNamespace(oldName, newName string) error {
n, err := h.GetNamespace(oldName)
if err != nil {
return err
}
_, err = h.GetNamespace(newName)
if err == nil {
return errorNamespaceExists
}
if !errors.Is(err, errorNamespaceNotFound) {
return err
}
n.Name = newName
if result := h.db.Save(&n); result.Error != nil {
return result.Error
}
err = h.RequestMapUpdates(n.ID)
if err != nil { if err != nil {
return err return err
} }
@@ -76,15 +96,8 @@ func (h *Headscale) DestroyNamespace(name string) error {
// GetNamespace fetches a namespace by name // GetNamespace fetches a namespace by name
func (h *Headscale) GetNamespace(name string) (*Namespace, error) { 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{} 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 nil, errorNamespaceNotFound
} }
return &n, nil return &n, nil
@@ -92,14 +105,8 @@ func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
// ListNamespaces gets all the existing namespaces // ListNamespaces gets all the existing namespaces
func (h *Headscale) ListNamespaces() (*[]Namespace, error) { 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{} namespaces := []Namespace{}
if err := db.Find(&namespaces).Error; err != nil { if err := h.db.Find(&namespaces).Error; err != nil {
return nil, err return nil, err
} }
return &namespaces, nil return &namespaces, nil
@@ -111,47 +118,149 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
if err != nil { if err != nil {
return nil, err 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{} machines := []Machine{}
if err := db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil { if err := h.db.Preload("AuthKey").Preload("AuthKey.Namespace").Preload("Namespace").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
return nil, err return nil, err
} }
return &machines, nil return &machines, nil
} }
// ListSharedMachinesInNamespace returns all the machines that are shared to the specified namespace
func (h *Headscale) ListSharedMachinesInNamespace(name string) (*[]Machine, error) {
namespace, err := h.GetNamespace(name)
if err != nil {
return nil, err
}
sharedMachines := []SharedMachine{}
if err := h.db.Preload("Namespace").Where(&SharedMachine{NamespaceID: namespace.ID}).Find(&sharedMachines).Error; err != nil {
return nil, err
}
machines := []Machine{}
for _, sharedMachine := range sharedMachines {
machine, err := h.GetMachineByID(sharedMachine.MachineID) // otherwise not everything comes filled
if err != nil {
return nil, err
}
machines = append(machines, *machine)
}
return &machines, nil
}
// SetMachineNamespace assigns a Machine to a namespace // SetMachineNamespace assigns a Machine to a namespace
func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error { func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error {
n, err := h.GetNamespace(namespaceName) n, err := h.GetNamespace(namespaceName)
if err != nil { if err != nil {
return err return err
} }
db, err := h.db() m.NamespaceID = n.ID
if err != nil { h.db.Save(&m)
log.Printf("Cannot open DB: %s", err) return nil
}
// RequestMapUpdates signals the KV worker to update the maps for this namespace
func (h *Headscale) RequestMapUpdates(namespaceID uint) error {
namespace := Namespace{}
if err := h.db.First(&namespace, namespaceID).Error; err != nil {
return err return err
} }
defer db.Close()
m.NamespaceID = n.ID v, err := h.getValue("namespaces_pending_updates")
db.Save(&m) if err != nil || v == "" {
return nil err = h.setValue("namespaces_pending_updates", fmt.Sprintf(`["%s"]`, namespace.Name))
if err != nil {
return err
}
return nil
}
names := []string{}
err = json.Unmarshal([]byte(v), &names)
if err != nil {
err = h.setValue("namespaces_pending_updates", fmt.Sprintf(`["%s"]`, namespace.Name))
if err != nil {
return err
}
return nil
}
names = append(names, namespace.Name)
data, err := json.Marshal(names)
if err != nil {
log.Error().
Str("func", "RequestMapUpdates").
Err(err).
Msg("Could not marshal namespaces_pending_updates")
return err
}
return h.setValue("namespaces_pending_updates", string(data))
}
func (h *Headscale) checkForNamespacesPendingUpdates() {
v, err := h.getValue("namespaces_pending_updates")
if err != nil {
return
}
if v == "" {
return
}
namespaces := []string{}
err = json.Unmarshal([]byte(v), &namespaces)
if err != nil {
return
}
for _, namespace := range namespaces {
log.Trace().
Str("func", "RequestMapUpdates").
Str("machine", namespace).
Msg("Sending updates to nodes in namespacespace")
h.setLastStateChangeToNow(namespace)
}
newV, err := h.getValue("namespaces_pending_updates")
if err != nil {
return
}
if v == newV { // only clear when no changes, so we notified everybody
err = h.setValue("namespaces_pending_updates", "")
if err != nil {
log.Error().
Str("func", "checkForNamespacesPendingUpdates").
Err(err).
Msg("Could not save to KV")
return
}
}
} }
func (n *Namespace) toUser() *tailcfg.User { func (n *Namespace) toUser() *tailcfg.User {
u := tailcfg.User{ u := tailcfg.User{
ID: tailcfg.UserID(n.ID), ID: tailcfg.UserID(n.ID),
LoginName: "", LoginName: n.Name,
DisplayName: n.Name, DisplayName: n.Name,
ProfilePicURL: "", ProfilePicURL: "",
Domain: "", Domain: "headscale.net",
Logins: []tailcfg.LoginID{}, Logins: []tailcfg.LoginID{},
Roles: []tailcfg.RoleID{},
Created: time.Time{}, Created: time.Time{},
} }
return &u return &u
} }
func getMapResponseUserProfiles(m Machine, peers Machines) []tailcfg.UserProfile {
namespaceMap := make(map[string]Namespace)
namespaceMap[m.Namespace.Name] = m.Namespace
for _, p := range peers {
namespaceMap[p.Namespace.Name] = p.Namespace // not worth checking if already is there
}
profiles := []tailcfg.UserProfile{}
for _, namespace := range namespaceMap {
profiles = append(profiles,
tailcfg.UserProfile{
ID: tailcfg.UserID(namespace.ID),
LoginName: namespace.Name,
DisplayName: namespace.Name,
})
}
return profiles
}

View File

@@ -1,6 +1,7 @@
package headscale package headscale
import ( import (
"github.com/rs/zerolog/log"
"gopkg.in/check.v1" "gopkg.in/check.v1"
) )
@@ -30,11 +31,6 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil) pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
db, err := h.db()
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{ m := Machine{
ID: 0, ID: 0,
MachineKey: "foo", MachineKey: "foo",
@@ -46,8 +42,160 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
RegisterMethod: "authKey", RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID), AuthKeyID: uint(pak.ID),
} }
db.Save(&m) h.db.Save(&m)
err = h.DestroyNamespace("test") err = h.DestroyNamespace("test")
c.Assert(err, check.Equals, errorNamespaceNotEmpty) c.Assert(err, check.Equals, errorNamespaceNotEmpty)
} }
func (s *Suite) TestRenameNamespace(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil)
c.Assert(n.Name, check.Equals, "test")
ns, err := h.ListNamespaces()
c.Assert(err, check.IsNil)
c.Assert(len(*ns), check.Equals, 1)
err = h.RenameNamespace("test", "test_renamed")
c.Assert(err, check.IsNil)
_, err = h.GetNamespace("test")
c.Assert(err, check.Equals, errorNamespaceNotFound)
_, err = h.GetNamespace("test_renamed")
c.Assert(err, check.IsNil)
err = h.RenameNamespace("test_does_not_exit", "test")
c.Assert(err, check.Equals, errorNamespaceNotFound)
n2, err := h.CreateNamespace("test2")
c.Assert(err, check.IsNil)
c.Assert(n2.Name, check.Equals, "test2")
err = h.RenameNamespace("test2", "test_renamed")
c.Assert(err, check.Equals, errorNamespaceExists)
}
func (s *Suite) TestGetMapResponseUserProfiles(c *check.C) {
n1, err := h.CreateNamespace("shared1")
c.Assert(err, check.IsNil)
n2, err := h.CreateNamespace("shared2")
c.Assert(err, check.IsNil)
n3, err := h.CreateNamespace("shared3")
c.Assert(err, check.IsNil)
pak1n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak2n2, err := h.CreatePreAuthKey(n2.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak3n3, err := h.CreatePreAuthKey(n3.Name, false, false, nil)
c.Assert(err, check.IsNil)
pak4n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine(n1.Name, "test_get_shared_nodes_1")
c.Assert(err, check.NotNil)
m1 := &Machine{
ID: 1,
MachineKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
NodeKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
DiscoKey: "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66",
Name: "test_get_shared_nodes_1",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.1",
AuthKeyID: uint(pak1n1.ID),
}
h.db.Save(m1)
_, err = h.GetMachine(n1.Name, m1.Name)
c.Assert(err, check.IsNil)
m2 := &Machine{
ID: 2,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_2",
NamespaceID: n2.ID,
Namespace: *n2,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.2",
AuthKeyID: uint(pak2n2.ID),
}
h.db.Save(m2)
_, err = h.GetMachine(n2.Name, m2.Name)
c.Assert(err, check.IsNil)
m3 := &Machine{
ID: 3,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_3",
NamespaceID: n3.ID,
Namespace: *n3,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.3",
AuthKeyID: uint(pak3n3.ID),
}
h.db.Save(m3)
_, err = h.GetMachine(n3.Name, m3.Name)
c.Assert(err, check.IsNil)
m4 := &Machine{
ID: 4,
MachineKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
NodeKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
DiscoKey: "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863",
Name: "test_get_shared_nodes_4",
NamespaceID: n1.ID,
Namespace: *n1,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.4",
AuthKeyID: uint(pak4n1.ID),
}
h.db.Save(m4)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
m1peers, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
userProfiles := getMapResponseUserProfiles(*m1, m1peers)
log.Trace().Msgf("userProfiles %#v", userProfiles)
c.Assert(len(userProfiles), check.Equals, 2)
found := false
for _, up := range userProfiles {
if up.DisplayName == n1.Name {
found = true
break
}
}
c.Assert(found, check.Equals, true)
found = false
for _, up := range userProfiles {
if up.DisplayName == n2.Name {
found = true
break
}
}
c.Assert(found, check.Equals, true)
}

490
poll.go Normal file
View File

@@ -0,0 +1,490 @@
package headscale
import (
"encoding/json"
"errors"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
"gorm.io/datatypes"
"gorm.io/gorm"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
)
// PollNetMapHandler takes care of /machine/:id/map
//
// This is the busiest endpoint, as it keeps the HTTP long poll that updates
// the clients when something in the network changes.
//
// The clients POST stuff like HostInfo and their Endpoints here, but
// only after their first request (marked with the ReadOnly field).
//
// At this moment the updates are sent in a quite horrendous way, but they kinda work.
func (h *Headscale) PollNetMapHandler(c *gin.Context) {
log.Trace().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Msg("PollNetMapHandler called")
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
log.Error().
Str("handler", "PollNetMap").
Err(err).
Msg("Cannot parse client key")
c.String(http.StatusBadRequest, "")
return
}
req := tailcfg.MapRequest{}
err = decode(body, &req, &mKey, h.privateKey)
if err != nil {
log.Error().
Str("handler", "PollNetMap").
Err(err).
Msg("Cannot decode message")
c.String(http.StatusBadRequest, "")
return
}
m, err := h.GetMachineByMachineKey(mKey.HexString())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
log.Warn().
Str("handler", "PollNetMap").
Msgf("Ignoring request, cannot find machine with key %s", mKey.HexString())
c.String(http.StatusUnauthorized, "")
return
}
log.Error().
Str("handler", "PollNetMap").
Msgf("Failed to fetch machine from the database with Machine key: %s", mKey.HexString())
c.String(http.StatusInternalServerError, "")
}
log.Trace().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Str("machine", m.Name).
Msg("Found machine in database")
hostinfo, _ := json.Marshal(req.Hostinfo)
m.Name = req.Hostinfo.Hostname
m.HostInfo = datatypes.JSON(hostinfo)
m.DiscoKey = wgkey.Key(req.DiscoKey).HexString()
now := time.Now().UTC()
// From Tailscale client:
//
// ReadOnly is whether the client just wants to fetch the MapResponse,
// without updating their Endpoints. The Endpoints field will be ignored and
// LastSeen will not be updated and peers will not be notified of changes.
//
// The intended use is for clients to discover the DERP map at start-up
// before their first real endpoint update.
if !req.ReadOnly {
endpoints, _ := json.Marshal(req.Endpoints)
m.Endpoints = datatypes.JSON(endpoints)
m.LastSeen = &now
}
h.db.Save(&m)
data, err := h.getMapResponse(mKey, req, m)
if err != nil {
log.Error().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Str("machine", m.Name).
Err(err).
Msg("Failed to get Map response")
c.String(http.StatusInternalServerError, ":(")
return
}
// We update our peers if the client is not sending ReadOnly in the MapRequest
// so we don't distribute its initial request (it comes with
// empty endpoints to peers)
// Details on the protocol can be found in https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L696
log.Debug().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Str("machine", m.Name).
Bool("readOnly", req.ReadOnly).
Bool("omitPeers", req.OmitPeers).
Bool("stream", req.Stream).
Msg("Client map request processed")
if req.ReadOnly {
log.Info().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Client is starting up. Probably interested in a DERP map")
c.Data(200, "application/json; charset=utf-8", data)
return
}
// There has been an update to _any_ of the nodes that the other nodes would
// need to know about
h.setLastStateChangeToNow(m.Namespace.Name)
// The request is not ReadOnly, so we need to set up channels for updating
// peers via longpoll
// Only create update channel if it has not been created
log.Trace().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Str("machine", m.Name).
Msg("Loading or creating update channel")
updateChan := make(chan struct{})
pollDataChan := make(chan []byte)
keepAliveChan := make(chan []byte)
cancelKeepAlive := make(chan struct{})
defer close(cancelKeepAlive)
if req.OmitPeers && !req.Stream {
log.Info().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Client sent endpoint update and is ok with a response without peer list")
c.Data(200, "application/json; charset=utf-8", data)
// It sounds like we should update the nodes when we have received a endpoint update
// even tho the comments in the tailscale code dont explicitly say so.
updateRequestsFromNode.WithLabelValues(m.Name, m.Namespace.Name, "endpoint-update").Inc()
go func() { updateChan <- struct{}{} }()
return
} else if req.OmitPeers && req.Stream {
log.Warn().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Ignoring request, don't know how to handle it")
c.String(http.StatusBadRequest, "")
return
}
log.Info().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Client is ready to access the tailnet")
log.Info().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Sending initial map")
go func() { pollDataChan <- data }()
log.Info().
Str("handler", "PollNetMap").
Str("machine", m.Name).
Msg("Notifying peers")
updateRequestsFromNode.WithLabelValues(m.Name, m.Namespace.Name, "full-update").Inc()
go func() { updateChan <- struct{}{} }()
h.PollNetMapStream(c, m, req, mKey, pollDataChan, keepAliveChan, updateChan, cancelKeepAlive)
log.Trace().
Str("handler", "PollNetMap").
Str("id", c.Param("id")).
Str("machine", m.Name).
Msg("Finished stream, closing PollNetMap session")
}
// PollNetMapStream takes care of /machine/:id/map
// stream logic, ensuring we communicate updates and data
// to the connected clients.
func (h *Headscale) PollNetMapStream(
c *gin.Context,
m *Machine,
req tailcfg.MapRequest,
mKey wgkey.Key,
pollDataChan chan []byte,
keepAliveChan chan []byte,
updateChan chan struct{},
cancelKeepAlive chan struct{},
) {
go h.scheduledPollWorker(cancelKeepAlive, updateChan, keepAliveChan, mKey, req, m)
c.Stream(func(w io.Writer) bool {
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Msg("Waiting for data to stream...")
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Msgf("pollData is %#v, keepAliveChan is %#v, updateChan is %#v", pollDataChan, keepAliveChan, updateChan)
select {
case data := <-pollDataChan:
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "pollData").
Int("bytes", len(data)).
Msg("Sending data received via pollData channel")
_, err := w.Write(data)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "pollData").
Err(err).
Msg("Cannot write data")
return false
}
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "pollData").
Int("bytes", len(data)).
Msg("Data from pollData channel written successfully")
// TODO(kradalby): Abstract away all the database calls, this can cause race conditions
// when an outdated machine object is kept alive, e.g. db is update from
// command line, but then overwritten.
err = h.UpdateMachine(m)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "pollData").
Err(err).
Msg("Cannot update machine from database")
}
now := time.Now().UTC()
m.LastSeen = &now
lastStateUpdate.WithLabelValues(m.Namespace.Name, m.Name).Set(float64(now.Unix()))
m.LastSuccessfulUpdate = &now
h.db.Save(&m)
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "pollData").
Int("bytes", len(data)).
Msg("Machine entry in database updated successfully after sending pollData")
return true
case data := <-keepAliveChan:
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "keepAlive").
Int("bytes", len(data)).
Msg("Sending keep alive message")
_, err := w.Write(data)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "keepAlive").
Err(err).
Msg("Cannot write keep alive message")
return false
}
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "keepAlive").
Int("bytes", len(data)).
Msg("Keep alive sent successfully")
// TODO(kradalby): Abstract away all the database calls, this can cause race conditions
// when an outdated machine object is kept alive, e.g. db is update from
// command line, but then overwritten.
err = h.UpdateMachine(m)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "keepAlive").
Err(err).
Msg("Cannot update machine from database")
}
now := time.Now().UTC()
m.LastSeen = &now
h.db.Save(&m)
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "keepAlive").
Int("bytes", len(data)).
Msg("Machine updated successfully after sending keep alive")
return true
case <-updateChan:
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "update").
Msg("Received a request for update")
updateRequestsReceivedOnChannel.WithLabelValues(m.Name, m.Namespace.Name).Inc()
if h.isOutdated(m) {
log.Debug().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Time("last_successful_update", *m.LastSuccessfulUpdate).
Time("last_state_change", h.getLastStateChange(m.Namespace.Name)).
Msgf("There has been updates since the last successful update to %s", m.Name)
data, err := h.getMapResponse(mKey, req, m)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "update").
Err(err).
Msg("Could not get the map update")
}
_, err = w.Write(data)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "update").
Err(err).
Msg("Could not write the map response")
updateRequestsSentToNode.WithLabelValues(m.Name, m.Namespace.Name, "failed").Inc()
return false
}
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "update").
Msg("Updated Map has been sent")
updateRequestsSentToNode.WithLabelValues(m.Name, m.Namespace.Name, "success").Inc()
// Keep track of the last successful update,
// we sometimes end in a state were the update
// is not picked up by a client and we use this
// to determine if we should "force" an update.
// TODO(kradalby): Abstract away all the database calls, this can cause race conditions
// when an outdated machine object is kept alive, e.g. db is update from
// command line, but then overwritten.
err = h.UpdateMachine(m)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "update").
Err(err).
Msg("Cannot update machine from database")
}
now := time.Now().UTC()
lastStateUpdate.WithLabelValues(m.Namespace.Name, m.Name).Set(float64(now.Unix()))
m.LastSuccessfulUpdate = &now
h.db.Save(&m)
} else {
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Time("last_successful_update", *m.LastSuccessfulUpdate).
Time("last_state_change", h.getLastStateChange(m.Namespace.Name)).
Msgf("%s is up to date", m.Name)
}
return true
case <-c.Request.Context().Done():
log.Info().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Msg("The client has closed the connection")
// TODO: Abstract away all the database calls, this can cause race conditions
// when an outdated machine object is kept alive, e.g. db is update from
// command line, but then overwritten.
err := h.UpdateMachine(m)
if err != nil {
log.Error().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "Done").
Err(err).
Msg("Cannot update machine from database")
}
now := time.Now().UTC()
m.LastSeen = &now
h.db.Save(&m)
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "Done").
Msg("Cancelling keepAlive channel")
cancelKeepAlive <- struct{}{}
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "Done").
Msg("Closing update channel")
//h.closeUpdateChannel(m)
close(updateChan)
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "Done").
Msg("Closing pollData channel")
close(pollDataChan)
log.Trace().
Str("handler", "PollNetMapStream").
Str("machine", m.Name).
Str("channel", "Done").
Msg("Closing keepAliveChan channel")
close(keepAliveChan)
return false
}
})
}
func (h *Headscale) scheduledPollWorker(
cancelChan <-chan struct{},
updateChan chan<- struct{},
keepAliveChan chan<- []byte,
mKey wgkey.Key,
req tailcfg.MapRequest,
m *Machine,
) {
keepAliveTicker := time.NewTicker(60 * time.Second)
updateCheckerTicker := time.NewTicker(10 * time.Second)
for {
select {
case <-cancelChan:
return
case <-keepAliveTicker.C:
data, err := h.getMapKeepAliveResponse(mKey, req, m)
if err != nil {
log.Error().
Str("func", "keepAlive").
Err(err).
Msg("Error generating the keep alive msg")
return
}
log.Debug().
Str("func", "keepAlive").
Str("machine", m.Name).
Msg("Sending keepalive")
keepAliveChan <- data
case <-updateCheckerTicker.C:
log.Debug().
Str("func", "scheduledPollWorker").
Str("machine", m.Name).
Msg("Sending update request")
updateRequestsFromNode.WithLabelValues(m.Name, m.Namespace.Name, "scheduled-update").Inc()
updateChan <- struct{}{}
}
}
}

View File

@@ -3,13 +3,15 @@ package headscale
import ( import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"log" "errors"
"time" "time"
"gorm.io/gorm"
) )
const errorAuthKeyNotFound = Error("AuthKey not found") const errorAuthKeyNotFound = Error("AuthKey not found")
const errorAuthKeyExpired = Error("AuthKey expired") const errorAuthKeyExpired = Error("AuthKey expired")
const errorAuthKeyNotReusableAlreadyUsed = Error("AuthKey not reusable already used") const errSingleUseAuthKeyHasBeenUsed = Error("AuthKey has already been used")
// PreAuthKey describes a pre-authorization key usable in a particular namespace // PreAuthKey describes a pre-authorization key usable in a particular namespace
type PreAuthKey struct { type PreAuthKey struct {
@@ -19,6 +21,7 @@ type PreAuthKey struct {
Namespace Namespace Namespace Namespace
Reusable bool Reusable bool
Ephemeral bool `gorm:"default:false"` Ephemeral bool `gorm:"default:false"`
Used bool `gorm:"default:false"`
CreatedAt *time.Time CreatedAt *time.Time
Expiration *time.Time Expiration *time.Time
@@ -31,13 +34,6 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
return nil, err 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() now := time.Now().UTC()
kstr, err := h.generateKey() kstr, err := h.generateKey()
if err != nil { if err != nil {
@@ -53,7 +49,7 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
CreatedAt: &now, CreatedAt: &now,
Expiration: expiration, Expiration: expiration,
} }
db.Save(&k) h.db.Save(&k)
return &k, nil return &k, nil
} }
@@ -64,31 +60,41 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
if err != nil { if err != nil {
return nil, err 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{} 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 nil, err
} }
return &keys, nil return &keys, nil
} }
// checkKeyValidity does the heavy lifting for validation of the PreAuthKey coming from a node // GetPreAuthKey returns a PreAuthKey for a given key
// If returns no error and a PreAuthKey, it can be used func (h *Headscale) GetPreAuthKey(namespace string, key string) (*PreAuthKey, error) {
func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) { pak, err := h.checkKeyValidity(key)
db, err := h.db()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer db.Close()
if pak.Namespace.Name != namespace {
return nil, errors.New("Namespace mismatch")
}
return pak, nil
}
// MarkExpirePreAuthKey marks a PreAuthKey as expired
func (h *Headscale) MarkExpirePreAuthKey(k *PreAuthKey) error {
if err := h.db.Model(&k).Update("Expiration", time.Now()).Error; err != nil {
return err
}
return nil
}
// 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) {
pak := PreAuthKey{} 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 return nil, errorAuthKeyNotFound
} }
@@ -101,15 +107,14 @@ func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
} }
machines := []Machine{} 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 return nil, err
} }
if len(machines) != 0 { if len(machines) != 0 || pak.Used {
return nil, errorAuthKeyNotReusableAlreadyUsed return nil, errSingleUseAuthKeyHasBeenUsed
} }
// missing here validation on current usage
return &pak, nil return &pak, nil
} }

View File

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

144
routes.go
View File

@@ -2,72 +2,142 @@ package headscale
import ( import (
"encoding/json" "encoding/json"
"errors" "fmt"
"log" "strconv"
"github.com/pterm/pterm"
"gorm.io/datatypes" "gorm.io/datatypes"
"inet.af/netaddr" "inet.af/netaddr"
) )
// GetNodeRoutes returns the subnet routes advertised by a node (identified by // GetAdvertisedNodeRoutes returns the subnet routes advertised by a node (identified by
// namespace and node name) // namespace and node name)
func (h *Headscale) GetNodeRoutes(namespace string, nodeName string) (*[]netaddr.IPPrefix, error) { func (h *Headscale) GetAdvertisedNodeRoutes(namespace string, nodeName string) (*[]netaddr.IPPrefix, error) {
m, err := h.GetMachine(namespace, nodeName) m, err := h.GetMachine(namespace, nodeName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
hi, err := m.GetHostInfo() hostInfo, err := m.GetHostInfo()
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &hi.RoutableIPs, nil return &hostInfo.RoutableIPs, nil
}
// GetEnabledNodeRoutes returns the subnet routes enabled by a node (identified by
// namespace and node name)
func (h *Headscale) GetEnabledNodeRoutes(namespace string, nodeName string) ([]netaddr.IPPrefix, error) {
m, err := h.GetMachine(namespace, nodeName)
if err != nil {
return nil, err
}
data, err := m.EnabledRoutes.MarshalJSON()
if err != nil {
return nil, err
}
routesStr := []string{}
err = json.Unmarshal(data, &routesStr)
if err != nil {
return nil, err
}
routes := make([]netaddr.IPPrefix, len(routesStr))
for index, routeStr := range routesStr {
route, err := netaddr.ParseIPPrefix(routeStr)
if err != nil {
return nil, err
}
routes[index] = route
}
return routes, nil
}
// IsNodeRouteEnabled checks if a certain route has been enabled
func (h *Headscale) IsNodeRouteEnabled(namespace string, nodeName string, routeStr string) bool {
route, err := netaddr.ParseIPPrefix(routeStr)
if err != nil {
return false
}
enabledRoutes, err := h.GetEnabledNodeRoutes(namespace, nodeName)
if err != nil {
return false
}
for _, enabledRoute := range enabledRoutes {
if route == enabledRoute {
return true
}
}
return false
} }
// EnableNodeRoute enables a subnet route advertised by a node (identified by // EnableNodeRoute enables a subnet route advertised by a node (identified by
// namespace and node name) // namespace and node name)
func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr string) (*netaddr.IPPrefix, error) { func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr string) error {
m, err := h.GetMachine(namespace, nodeName) m, err := h.GetMachine(namespace, nodeName)
if err != nil { if err != nil {
return nil, err return err
}
hi, err := m.GetHostInfo()
if err != nil {
return nil, err
} }
route, err := netaddr.ParseIPPrefix(routeStr) route, err := netaddr.ParseIPPrefix(routeStr)
if err != nil { if err != nil {
return nil, err return err
} }
for _, rIP := range hi.RoutableIPs { availableRoutes, err := h.GetAdvertisedNodeRoutes(namespace, nodeName)
if rIP == route { if err != nil {
db, err := h.db() return err
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 enabledRoutes, err := h.GetEnabledNodeRoutes(namespace, nodeName)
m.EnabledRoutes = datatypes.JSON(routes) if err != nil {
db.Save(&m) return err
db.Close() }
// THIS IS COMPLETELY USELESS. available := false
// The peers map is stored in memory in the server process. for _, availableRoute := range *availableRoutes {
// Definetely not accessible from the CLI tool. // If the route is available, and not yet enabled, add it to the new routing table
// We need RPC to the server - or some kind of 'needsUpdate' field in the DB if route == availableRoute {
peers, _ := h.getPeers(*m) available = true
h.pollMu.Lock() if !h.IsNodeRouteEnabled(namespace, nodeName, routeStr) {
for _, p := range *peers { enabledRoutes = append(enabledRoutes, route)
if pUp, ok := h.clientsPolling[uint64(p.ID)]; ok {
pUp <- []byte{}
}
} }
h.pollMu.Unlock()
return &rIP, nil
} }
} }
return nil, errors.New("could not find routable range") if !available {
return fmt.Errorf("route (%s) is not available on node %s", nodeName, routeStr)
}
routes, err := json.Marshal(enabledRoutes)
if err != nil {
return err
}
m.EnabledRoutes = datatypes.JSON(routes)
h.db.Save(&m)
err = h.RequestMapUpdates(m.NamespaceID)
if err != nil {
return err
}
return nil
}
// RoutesToPtables converts the list of routes to a nice table
func (h *Headscale) RoutesToPtables(namespace string, nodeName string, availableRoutes []netaddr.IPPrefix) pterm.TableData {
d := pterm.TableData{{"Route", "Enabled"}}
for _, route := range availableRoutes {
enabled := h.IsNodeRouteEnabled(namespace, nodeName, route.String())
d = append(d, []string{route.String(), strconv.FormatBool(enabled)})
}
return d
} }

View File

@@ -16,13 +16,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil) pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
db, err := h.db() _, err = h.GetMachine("test", "test_get_route_machine")
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil) c.Assert(err, check.NotNil)
route, err := netaddr.ParseIPPrefix("10.0.0.0/24") route, err := netaddr.ParseIPPrefix("10.0.0.0/24")
@@ -39,23 +33,96 @@ func (s *Suite) TestGetRoutes(c *check.C) {
MachineKey: "foo", MachineKey: "foo",
NodeKey: "bar", NodeKey: "bar",
DiscoKey: "faa", DiscoKey: "faa",
Name: "testmachine", Name: "test_get_route_machine",
NamespaceID: n.ID, NamespaceID: n.ID,
Registered: true, Registered: true,
RegisterMethod: "authKey", RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID), AuthKeyID: uint(pak.ID),
HostInfo: datatypes.JSON(hostinfo), HostInfo: datatypes.JSON(hostinfo),
} }
db.Save(&m) h.db.Save(&m)
r, err := h.GetNodeRoutes("test", "testmachine") r, err := h.GetAdvertisedNodeRoutes("test", "test_get_route_machine")
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
c.Assert(len(*r), check.Equals, 1) c.Assert(len(*r), check.Equals, 1)
_, err = h.EnableNodeRoute("test", "testmachine", "192.168.0.0/24") err = h.EnableNodeRoute("test", "test_get_route_machine", "192.168.0.0/24")
c.Assert(err, check.NotNil) c.Assert(err, check.NotNil)
_, err = h.EnableNodeRoute("test", "testmachine", "10.0.0.0/24") err = h.EnableNodeRoute("test", "test_get_route_machine", "10.0.0.0/24")
c.Assert(err, check.IsNil)
}
func (s *Suite) TestGetEnableRoutes(c *check.C) {
n, err := h.CreateNamespace("test")
c.Assert(err, check.IsNil) c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("test", "test_enable_route_machine")
c.Assert(err, check.NotNil)
route, err := netaddr.ParseIPPrefix(
"10.0.0.0/24",
)
c.Assert(err, check.IsNil)
route2, err := netaddr.ParseIPPrefix(
"150.0.10.0/25",
)
c.Assert(err, check.IsNil)
hi := tailcfg.Hostinfo{
RoutableIPs: []netaddr.IPPrefix{route, route2},
}
hostinfo, err := json.Marshal(hi)
c.Assert(err, check.IsNil)
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "test_enable_route_machine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
HostInfo: datatypes.JSON(hostinfo),
}
h.db.Save(&m)
availableRoutes, err := h.GetAdvertisedNodeRoutes("test", "test_enable_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(*availableRoutes), check.Equals, 2)
enabledRoutes, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(enabledRoutes), check.Equals, 0)
err = h.EnableNodeRoute("test", "test_enable_route_machine", "192.168.0.0/24")
c.Assert(err, check.NotNil)
err = h.EnableNodeRoute("test", "test_enable_route_machine", "10.0.0.0/24")
c.Assert(err, check.IsNil)
enabledRoutes1, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(enabledRoutes1), check.Equals, 1)
// Adding it twice will just let it pass through
err = h.EnableNodeRoute("test", "test_enable_route_machine", "10.0.0.0/24")
c.Assert(err, check.IsNil)
enabledRoutes2, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(enabledRoutes2), check.Equals, 1)
err = h.EnableNodeRoute("test", "test_enable_route_machine", "150.0.10.0/25")
c.Assert(err, check.IsNil)
enabledRoutes3, err := h.GetEnabledNodeRoutes("test", "test_enable_route_machine")
c.Assert(err, check.IsNil)
c.Assert(len(enabledRoutes3), check.Equals, 2)
} }

View File

@@ -1,4 +1,4 @@
#!/bin/bash #!/usr/bin/env bash
set -e -o pipefail set -e -o pipefail
commit="$1" commit="$1"

75
sharing.go Normal file
View File

@@ -0,0 +1,75 @@
package headscale
import "gorm.io/gorm"
const errorSameNamespace = Error("Destination namespace same as origin")
const errorMachineAlreadyShared = Error("Node already shared to this namespace")
const errorMachineNotShared = Error("Machine not shared to this namespace")
// SharedMachine is a join table to support sharing nodes between namespaces
type SharedMachine struct {
gorm.Model
MachineID uint64
Machine Machine
NamespaceID uint
Namespace Namespace
}
// AddSharedMachineToNamespace adds a machine as a shared node to a namespace
func (h *Headscale) AddSharedMachineToNamespace(m *Machine, ns *Namespace) error {
if m.NamespaceID == ns.ID {
return errorSameNamespace
}
sharedMachines := []SharedMachine{}
if err := h.db.Where("machine_id = ? AND namespace_id = ?", m.ID, ns.ID).Find(&sharedMachines).Error; err != nil {
return err
}
if len(sharedMachines) > 0 {
return errorMachineAlreadyShared
}
sharedMachine := SharedMachine{
MachineID: m.ID,
Machine: *m,
NamespaceID: ns.ID,
Namespace: *ns,
}
h.db.Save(&sharedMachine)
return nil
}
// RemoveSharedMachineFromNamespace removes a shared machine from a namespace
func (h *Headscale) RemoveSharedMachineFromNamespace(m *Machine, ns *Namespace) error {
if m.NamespaceID == ns.ID {
return errorSameNamespace
}
sharedMachine := SharedMachine{}
result := h.db.Where("machine_id = ? AND namespace_id = ?", m.ID, ns.ID).Unscoped().Delete(&sharedMachine)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errorMachineNotShared
}
err := h.RequestMapUpdates(ns.ID)
if err != nil {
return err
}
return nil
}
// RemoveSharedMachineFromAllNamespaces removes a machine as a shared node from all namespaces
func (h *Headscale) RemoveSharedMachineFromAllNamespaces(m *Machine) error {
sharedMachine := SharedMachine{}
if result := h.db.Where("machine_id = ?", m.ID).Unscoped().Delete(&sharedMachine); result.Error != nil {
return result.Error
}
return nil
}

234
sharing_test.go Normal file
View File

@@ -0,0 +1,234 @@
package headscale
import (
"gopkg.in/check.v1"
)
func CreateNodeNamespace(c *check.C, namespace, node, key, IP string) (*Namespace, *Machine) {
n1, err := h.CreateNamespace(namespace)
c.Assert(err, check.IsNil)
pak1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine(n1.Name, node)
c.Assert(err, check.NotNil)
m1 := &Machine{
ID: 0,
MachineKey: key,
NodeKey: key,
DiscoKey: key,
Name: node,
NamespaceID: n1.ID,
Registered: true,
RegisterMethod: "authKey",
IPAddress: IP,
AuthKeyID: uint(pak1.ID),
}
h.db.Save(m1)
_, err = h.GetMachine(n1.Name, m1.Name)
c.Assert(err, check.IsNil)
return n1, m1
}
func (s *Suite) TestBasicSharedNodesInNamespace(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_get_shared_nodes_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1sAfter, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1sAfter), check.Equals, 1)
c.Assert(p1sAfter[0].ID, check.Equals, m2.ID)
}
func (s *Suite) TestSameNamespace(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.AddSharedMachineToNamespace(m1, n1)
c.Assert(err, check.Equals, errorSameNamespace)
}
func (s *Suite) TestUnshare(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_unshare_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_unshare_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1s, err = h.getShared(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 1)
err = h.RemoveSharedMachineFromNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1s, err = h.getShared(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.RemoveSharedMachineFromNamespace(m2, n1)
c.Assert(err, check.Equals, errorMachineNotShared)
}
func (s *Suite) TestAlreadyShared(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_get_shared_nodes_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.Equals, errorMachineAlreadyShared)
}
func (s *Suite) TestDoNotIncludeRoutesOnShared(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_get_shared_nodes_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 0)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1sAfter, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1sAfter), check.Equals, 1)
c.Assert(p1sAfter[0].Name, check.Equals, "test_get_shared_nodes_2")
}
func (s *Suite) TestComplexSharingAcrossNamespaces(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_get_shared_nodes_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
_, m3 := CreateNodeNamespace(c, "shared3", "test_get_shared_nodes_3", "6e704bee83eb93db6fc2c417d7882964cd3f8cc87082cbb645982e34020c76c8", "100.64.0.3")
pak4, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
m4 := &Machine{
ID: 4,
MachineKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
NodeKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
DiscoKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
Name: "test_get_shared_nodes_4",
NamespaceID: n1.ID,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.4",
AuthKeyID: uint(pak4.ID),
}
h.db.Save(m4)
_, err = h.GetMachine(n1.Name, m4.Name)
c.Assert(err, check.IsNil)
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 1) // node1 can see node4
c.Assert(p1s[0].Name, check.Equals, m4.Name)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1sAfter, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1sAfter), check.Equals, 2) // node1 can see node2 (shared) and node4 (same namespace)
c.Assert(p1sAfter[0].Name, check.Equals, m2.Name)
c.Assert(p1sAfter[1].Name, check.Equals, m4.Name)
node1shared, err := h.getShared(m1)
c.Assert(err, check.IsNil)
c.Assert(len(node1shared), check.Equals, 1) // node1 can see node2 as shared
c.Assert(node1shared[0].Name, check.Equals, m2.Name)
pAlone, err := h.getPeers(m3)
c.Assert(err, check.IsNil)
c.Assert(len(pAlone), check.Equals, 0) // node3 is alone
pSharedTo, err := h.getPeers(m2)
c.Assert(err, check.IsNil)
c.Assert(len(pSharedTo), check.Equals, 2) // node2 should see node1 (sharedTo) and node4 (sharedTo), as is shared in namespace1
c.Assert(pSharedTo[0].Name, check.Equals, m1.Name)
c.Assert(pSharedTo[1].Name, check.Equals, m4.Name)
}
func (s *Suite) TestDeleteSharedMachine(c *check.C) {
n1, m1 := CreateNodeNamespace(c, "shared1", "test_get_shared_nodes_1", "686824e749f3b7f2a5927ee6c1e422aee5292592d9179a271ed7b3e659b44a66", "100.64.0.1")
_, m2 := CreateNodeNamespace(c, "shared2", "test_get_shared_nodes_2", "dec46ef9dc45c7d2f03bfcd5a640d9e24e3cc68ce3d9da223867c9bc6d5e9863", "100.64.0.2")
_, m3 := CreateNodeNamespace(c, "shared3", "test_get_shared_nodes_3", "6e704bee83eb93db6fc2c417d7882964cd3f8cc87082cbb645982e34020c76c8", "100.64.0.3")
pak4n1, err := h.CreatePreAuthKey(n1.Name, false, false, nil)
c.Assert(err, check.IsNil)
m4 := &Machine{
ID: 4,
MachineKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
NodeKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
DiscoKey: "4c3e07c3ecd40e9c945bb6797557c451850691c0409740578325e17009dd298f",
Name: "test_get_shared_nodes_4",
NamespaceID: n1.ID,
Registered: true,
RegisterMethod: "authKey",
IPAddress: "100.64.0.4",
AuthKeyID: uint(pak4n1.ID),
}
h.db.Save(m4)
_, err = h.GetMachine(n1.Name, m4.Name)
c.Assert(err, check.IsNil)
p1s, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1s), check.Equals, 1) // nodes 1 and 4
c.Assert(p1s[0].Name, check.Equals, m4.Name)
err = h.AddSharedMachineToNamespace(m2, n1)
c.Assert(err, check.IsNil)
p1sAfter, err := h.getPeers(m1)
c.Assert(err, check.IsNil)
c.Assert(len(p1sAfter), check.Equals, 2) // nodes 1, 2, 4
c.Assert(p1sAfter[0].Name, check.Equals, m2.Name)
c.Assert(p1sAfter[1].Name, check.Equals, m4.Name)
node1shared, err := h.getShared(m1)
c.Assert(err, check.IsNil)
c.Assert(len(node1shared), check.Equals, 1) // nodes 1, 2, 4
c.Assert(node1shared[0].Name, check.Equals, m2.Name)
pAlone, err := h.getPeers(m3)
c.Assert(err, check.IsNil)
c.Assert(len(pAlone), check.Equals, 0) // node 3 is alone
sharedMachines, err := h.ListSharedMachinesInNamespace(n1.Name)
c.Assert(err, check.IsNil)
c.Assert(len(*sharedMachines), check.Equals, 1)
err = h.DeleteMachine(m2)
c.Assert(err, check.IsNil)
sharedMachines, err = h.ListSharedMachinesInNamespace(n1.Name)
c.Assert(err, check.IsNil)
c.Assert(len(*sharedMachines), check.Equals, 0)
}

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
}

125
utils.go
View File

@@ -7,18 +7,15 @@ package headscale
import ( import (
"crypto/rand" "crypto/rand"
"encoding/binary"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net" "strings"
"time"
mathrand "math/rand"
"golang.org/x/crypto/nacl/box" "golang.org/x/crypto/nacl/box"
"tailscale.com/wgengine/wgcfg" "inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/types/wgkey"
) )
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors // Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
@@ -26,11 +23,11 @@ type Error string
func (e Error) Error() string { return string(e) } 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) 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) decrypted, err := decryptMsg(msg, pubKey, privKey)
if err != nil { if err != nil {
return err return err
@@ -42,7 +39,7 @@ func decodeMsg(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.Priv
return nil 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 var nonce [24]byte
if len(msg) < len(nonce)+1 { if len(msg) < len(nonce)+1 {
return nil, fmt.Errorf("response missing nonce, len=%d", len(msg)) return nil, fmt.Errorf("response missing nonce, len=%d", len(msg))
@@ -58,15 +55,16 @@ func decryptMsg(msg []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byt
return decrypted, nil 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) b, err := json.Marshal(v)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeMsg(b, pubKey, privKey) 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 var nonce [24]byte
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil { if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
panic(err) panic(err)
@@ -76,52 +74,85 @@ func encodeMsg(b []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte,
return msg, nil return msg, nil
} }
func (h *Headscale) getAvailableIP() (*net.IP, error) { func (h *Headscale) getAvailableIP() (*netaddr.IP, error) {
db, err := h.db() ipPrefix := h.cfg.IPPrefix
usedIps, err := h.getUsedIPs()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer db.Close()
i := 0 // Get the first IP in our prefix
ip := ipPrefix.IP()
for { for {
ip, err := getRandomIP() if !ipPrefix.Contains(ip) {
if err != nil { return nil, fmt.Errorf("could not find any suitable IP in %s", ipPrefix)
return nil, err
} }
m := Machine{}
if db.First(&m, "ip_address = ?", ip.String()).RecordNotFound() { // Some OS (including Linux) does not like when IPs ends with 0 or 255, which
return ip, nil // is typically called network or broadcast. Lets avoid them and continue
// to look when we get one of those traditionally reserved IPs.
ipRaw := ip.As4()
if ipRaw[3] == 0 || ipRaw[3] == 255 {
ip = ip.Next()
continue
} }
i++
if i == 100 { // really random number if ip.IsZero() &&
break ip.IsLoopback() {
ip = ip.Next()
continue
} }
if !containsIPs(usedIps, ip) {
return &ip, nil
}
ip = ip.Next()
} }
return nil, errors.New("Could not find an available IP address in 100.64.0.0/10")
} }
func getRandomIP() (*net.IP, error) { func (h *Headscale) getUsedIPs() ([]netaddr.IP, error) {
mathrand.Seed(time.Now().Unix()) var addresses []string
ipo, ipnet, err := net.ParseCIDR("100.64.0.0/10") h.db.Model(&Machine{}).Pluck("ip_address", &addresses)
if err == nil {
ip := ipo.To4() ips := make([]netaddr.IP, len(addresses))
// fmt.Println("In Randomize IPAddr: IP ", ip, " IPNET: ", ipnet) for index, addr := range addresses {
// fmt.Println("Final address is ", ip) if addr != "" {
// fmt.Println("Broadcast address is ", ipb) ip, err := netaddr.ParseIP(addr)
// fmt.Println("Network address is ", ipn) if err != nil {
r := mathrand.Uint32() return nil, fmt.Errorf("failed to parse ip from database, %w", err)
ipRaw := make([]byte, 4) }
binary.LittleEndian.PutUint32(ipRaw, r)
// ipRaw[3] = 254 ips[index] = ip
// fmt.Println("ipRaw is ", ipRaw)
for i, v := range ipRaw {
// fmt.Println("IP Before: ", ip[i], " v is ", v, " Mask is: ", ipnet.Mask[i])
ip[i] = ip[i] + (v &^ ipnet.Mask[i])
// fmt.Println("IP After: ", ip[i])
} }
// fmt.Println("FINAL IP: ", ip.String())
return &ip, nil
} }
return nil, err return ips, nil
}
func containsIPs(ips []netaddr.IP, ip netaddr.IP) bool {
for _, v := range ips {
if v == ip {
return true
}
}
return false
}
func tailNodesToString(nodes []*tailcfg.Node) string {
temp := make([]string, len(nodes))
for index, node := range nodes {
temp[index] = node.Name
}
return fmt.Sprintf("[ %s ](%d)", strings.Join(temp, ", "), len(temp))
}
func tailMapResponseToString(resp tailcfg.MapResponse) string {
return fmt.Sprintf("{ Node: %s, Peers: %s }", resp.Node.Name, tailNodesToString(resp.Peers))
} }

155
utils_test.go Normal file
View File

@@ -0,0 +1,155 @@
package headscale
import (
"gopkg.in/check.v1"
"inet.af/netaddr"
)
func (s *Suite) TestGetAvailableIp(c *check.C) {
ip, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
expected := netaddr.MustParseIP("10.27.0.1")
c.Assert(ip.String(), check.Equals, expected.String())
}
func (s *Suite) TestGetUsedIps(c *check.C) {
ip, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
n, err := h.CreateNamespace("test_ip")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
IPAddress: ip.String(),
}
h.db.Save(&m)
ips, err := h.getUsedIPs()
c.Assert(err, check.IsNil)
expected := netaddr.MustParseIP("10.27.0.1")
c.Assert(ips[0], check.Equals, expected)
m1, err := h.GetMachineByID(0)
c.Assert(err, check.IsNil)
c.Assert(m1.IPAddress, check.Equals, expected.String())
}
func (s *Suite) TestGetMultiIp(c *check.C) {
n, err := h.CreateNamespace("test-ip-multi")
c.Assert(err, check.IsNil)
for i := 1; i <= 350; i++ {
ip, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)
m := Machine{
ID: uint64(i),
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
IPAddress: ip.String(),
}
h.db.Save(&m)
}
ips, err := h.getUsedIPs()
c.Assert(err, check.IsNil)
c.Assert(len(ips), check.Equals, 350)
c.Assert(ips[0], check.Equals, netaddr.MustParseIP("10.27.0.1"))
c.Assert(ips[9], check.Equals, netaddr.MustParseIP("10.27.0.10"))
c.Assert(ips[300], check.Equals, netaddr.MustParseIP("10.27.1.47"))
// Check that we can read back the IPs
m1, err := h.GetMachineByID(1)
c.Assert(err, check.IsNil)
c.Assert(m1.IPAddress, check.Equals, netaddr.MustParseIP("10.27.0.1").String())
m50, err := h.GetMachineByID(50)
c.Assert(err, check.IsNil)
c.Assert(m50.IPAddress, check.Equals, netaddr.MustParseIP("10.27.0.50").String())
expectedNextIP := netaddr.MustParseIP("10.27.1.97")
nextIP, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
c.Assert(nextIP.String(), check.Equals, expectedNextIP.String())
// If we call get Available again, we should receive
// the same IP, as it has not been reserved.
nextIP2, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
c.Assert(nextIP2.String(), check.Equals, expectedNextIP.String())
}
func (s *Suite) TestGetAvailableIpMachineWithoutIP(c *check.C) {
ip, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
expected := netaddr.MustParseIP("10.27.0.1")
c.Assert(ip.String(), check.Equals, expected.String())
n, err := h.CreateNamespace("test_ip")
c.Assert(err, check.IsNil)
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
c.Assert(err, check.IsNil)
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)
m := Machine{
ID: 0,
MachineKey: "foo",
NodeKey: "bar",
DiscoKey: "faa",
Name: "testmachine",
NamespaceID: n.ID,
Registered: true,
RegisterMethod: "authKey",
AuthKeyID: uint(pak.ID),
}
h.db.Save(&m)
ip2, err := h.getAvailableIP()
c.Assert(err, check.IsNil)
c.Assert(ip2.String(), check.Equals, expected.String())
}