1
0
mirror of https://github.com/juanfont/headscale.git synced 2025-09-16 17:50:44 +02:00

Merge branch 'juanfont:main' into patch-1

This commit is contained in:
Dmitry Gordin 2025-01-12 21:22:21 +05:00 committed by GitHub
commit efee07247a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 125 additions and 46 deletions

View File

@ -34,4 +34,10 @@ jobs:
- name: Run tests - name: Run tests
if: steps.changed-files.outputs.files == 'true' if: steps.changed-files.outputs.files == 'true'
env:
# As of 2025-01-06, these env vars was not automatically
# set anymore which breaks the initdb for postgres on
# some of the database migration tests.
LC_ALL: "en_US.UTF-8"
LC_CTYPE: "en_US.UTF-8"
run: nix develop --command -- gotestsum run: nix develop --command -- gotestsum

View File

@ -153,7 +153,7 @@ This will also affect the way you
### Changes ### Changes
- Improved compatibilty of built-in DERP server with clients connecting over - Improved compatibility of built-in DERP server with clients connecting over
WebSocket [#2132](https://github.com/juanfont/headscale/pull/2132) WebSocket [#2132](https://github.com/juanfont/headscale/pull/2132)
- Allow nodes to use SSH agent forwarding - Allow nodes to use SSH agent forwarding
[#2145](https://github.com/juanfont/headscale/pull/2145) [#2145](https://github.com/juanfont/headscale/pull/2145)
@ -262,7 +262,7 @@ part of adopting [#1460](https://github.com/juanfont/headscale/pull/1460).
- `prefixes.allocation` can be set to assign IPs at `sequential` or `random`. - `prefixes.allocation` can be set to assign IPs at `sequential` or `random`.
[#1869](https://github.com/juanfont/headscale/pull/1869) [#1869](https://github.com/juanfont/headscale/pull/1869)
- MagicDNS domains no longer contain usernames []() - MagicDNS domains no longer contain usernames []()
- This is in preperation to fix Headscales implementation of tags which - This is in preparation to fix Headscales implementation of tags which
currently does not correctly remove the link between a tagged device and a currently does not correctly remove the link between a tagged device and a
user. As tagged devices will not have a user, this will require a change to user. As tagged devices will not have a user, this will require a change to
the DNS generation, removing the username, see the DNS generation, removing the username, see

View File

@ -1,13 +1,13 @@
# DNS # DNS
Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS releated settings can be configured Headscale supports [most DNS features](../about/features.md) from Tailscale. DNS related settings can be configured
within `dns` section of the [configuration file](./configuration.md). within `dns` section of the [configuration file](./configuration.md).
## Setting extra DNS records ## Setting extra DNS records
Headscale allows to set extra DNS records which are made available via Headscale allows to set extra DNS records which are made available via
[MagicDNS](https://tailscale.com/kb/1081/magicdns). Extra DNS records can be configured either via static entries in the [MagicDNS](https://tailscale.com/kb/1081/magicdns). Extra DNS records can be configured either via static entries in the
[configuration file](./configuration.md) or from a JSON file that Headscale continously watches for changes: [configuration file](./configuration.md) or from a JSON file that Headscale continuously watches for changes:
* Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and * Use the `dns.extra_records` option in the [configuration file](./configuration.md) for entries that are static and
don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the don't change while Headscale is running. Those entries are processed when Headscale is starting up and changes to the

View File

@ -11,7 +11,7 @@ Headscale doesn't provide a built-in web interface but users may pick one from t
| --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- | | --------------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| headscale-webui | [Github](https://github.com/ifargle/headscale-webui) | A simple headscale web UI for small-scale deployments. | | headscale-webui | [Github](https://github.com/ifargle/headscale-webui) | A simple headscale web UI for small-scale deployments. |
| headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server | | headscale-ui | [Github](https://github.com/gurucomputing/headscale-ui) | A web frontend for the headscale Tailscale-compatible coordination server |
| HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend enviroment required | | HeadscaleUi | [GitHub](https://github.com/simcu/headscale-ui) | A static headscale admin ui, no backend environment required |
| Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale | | Headplane | [GitHub](https://github.com/tale/headplane) | An advanced Tailscale inspired frontend for headscale |
| headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale | | headscale-admin | [Github](https://github.com/GoodiesHQ/headscale-admin) | Headscale-Admin is meant to be a simple, modern web interface for headscale |
| ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins | | ouroboros | [Github](https://github.com/yellowsink/ouroboros) | Ouroboros is designed for users to manage their own devices, rather than for admins |

View File

@ -16,7 +16,7 @@ README](https://github.com/juanfont/headscale#contributing) for more information
### Install from source ### Install from source
```shell ```shell
# Install prerequistes # Install prerequisites
pkg_add go pkg_add go
git clone https://github.com/juanfont/headscale.git git clone https://github.com/juanfont/headscale.git
@ -42,7 +42,7 @@ cp headscale /usr/local/sbin
### Install from source via cross compile ### Install from source via cross compile
```shell ```shell
# Install prerequistes # Install prerequisites
# 1. go v1.20+: headscale newer than 0.21 needs go 1.20+ to compile # 1. go v1.20+: headscale newer than 0.21 needs go 1.20+ to compile
# 2. gmake: Makefile in the headscale repo is written in GNU make syntax # 2. gmake: Makefile in the headscale repo is written in GNU make syntax

View File

@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1735268880, "lastModified": 1735915915,
"narHash": "sha256-7QEFnKkzD13SPxs+UFR5bUFN2fRw+GlL0am72ZjNre4=", "narHash": "sha256-Q4HuFAvoKAIiTRZTUxJ0ZXeTC7lLfC9/dggGHNXNlCw=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "7cc0bff31a3a705d3ac4fdceb030a17239412210", "rev": "a27871180d30ebee8aa6b11bf7fef8a52f024733",
"type": "github" "type": "github"
}, },
"original": { "original": {

View File

@ -31,7 +31,7 @@
checkFlags = ["-short"]; checkFlags = ["-short"];
# When updating go.mod or go.sum, a new sha will need to be calculated, # When updating go.mod or go.sum, a new sha will need to be calculated,
# update this if you have a mismatch after doing a change to thos files. # update this if you have a mismatch after doing a change to those files.
vendorHash = "sha256-SBfeixT8DQOrK2SWmHHSOBtzRdSZs+pwomHpw6Jd+qc="; vendorHash = "sha256-SBfeixT8DQOrK2SWmHHSOBtzRdSZs+pwomHpw6Jd+qc=";
subPackages = ["cmd/headscale"]; subPackages = ["cmd/headscale"];

View File

@ -245,7 +245,7 @@ func RenameNode(tx *gorm.DB,
return fmt.Errorf("renaming node: %w", err) return fmt.Errorf("renaming node: %w", err)
} }
uniq, err := isUnqiueName(tx, newName) uniq, err := isUniqueName(tx, newName)
if err != nil { if err != nil {
return fmt.Errorf("checking if name is unique: %w", err) return fmt.Errorf("checking if name is unique: %w", err)
} }
@ -630,7 +630,7 @@ func generateGivenName(suppliedName string, randomSuffix bool) (string, error) {
return suppliedName, nil return suppliedName, nil
} }
func isUnqiueName(tx *gorm.DB, name string) (bool, error) { func isUniqueName(tx *gorm.DB, name string) (bool, error) {
nodes := types.Nodes{} nodes := types.Nodes{}
if err := tx. if err := tx.
Where("given_name = ?", name).Find(&nodes).Error; err != nil { Where("given_name = ?", name).Find(&nodes).Error; err != nil {
@ -649,7 +649,7 @@ func ensureUniqueGivenName(
return "", err return "", err
} }
unique, err := isUnqiueName(tx, givenName) unique, err := isUniqueName(tx, givenName)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@ -417,10 +417,10 @@ func SaveNodeRoutes(tx *gorm.DB, node *types.Node) (bool, error) {
return sendUpdate, nil return sendUpdate, nil
} }
// FailoverNodeRoutesIfNeccessary takes a node and checks if the node's route // FailoverNodeRoutesIfNecessary takes a node and checks if the node's route
// need to be failed over to another host. // need to be failed over to another host.
// If needed, the failover will be attempted. // If needed, the failover will be attempted.
func FailoverNodeRoutesIfNeccessary( func FailoverNodeRoutesIfNecessary(
tx *gorm.DB, tx *gorm.DB,
isLikelyConnected *xsync.MapOf[types.NodeID, bool], isLikelyConnected *xsync.MapOf[types.NodeID, bool],
node *types.Node, node *types.Node,
@ -473,7 +473,7 @@ nodeRouteLoop:
return &types.StateUpdate{ return &types.StateUpdate{
Type: types.StatePeerChanged, Type: types.StatePeerChanged,
ChangeNodes: chng, ChangeNodes: chng,
Message: "called from db.FailoverNodeRoutesIfNeccessary", Message: "called from db.FailoverNodeRoutesIfNecessary",
}, nil }, nil
} }

View File

@ -342,7 +342,7 @@ func dbForTest(t *testing.T, testName string) *HSDatabase {
return db return db
} }
func TestFailoverNodeRoutesIfNeccessary(t *testing.T) { func TestFailoverNodeRoutesIfNecessary(t *testing.T) {
su := func(nids ...types.NodeID) *types.StateUpdate { su := func(nids ...types.NodeID) *types.StateUpdate {
return &types.StateUpdate{ return &types.StateUpdate{
ChangeNodes: nids, ChangeNodes: nids,
@ -648,7 +648,7 @@ func TestFailoverNodeRoutesIfNeccessary(t *testing.T) {
want := tt.want[step] want := tt.want[step]
got, err := Write(db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) { got, err := Write(db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return FailoverNodeRoutesIfNeccessary(tx, smap(isConnected), node) return FailoverNodeRoutesIfNecessary(tx, smap(isConnected), node)
}) })
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {

View File

@ -243,7 +243,7 @@ func (n *Notifier) sendAll(update types.StateUpdate) {
// has shut down the channel and is waiting for the lock held here in RemoveNode. // has shut down the channel and is waiting for the lock held here in RemoveNode.
// This means that there is potential for a deadlock which would stop all updates // This means that there is potential for a deadlock which would stop all updates
// going out to clients. This timeout prevents that from happening by moving on to the // going out to clients. This timeout prevents that from happening by moving on to the
// next node if the context is cancelled. Afther sendAll releases the lock, the add/remove // next node if the context is cancelled. After sendAll releases the lock, the add/remove
// call will succeed and the update will go to the correct nodes on the next call. // call will succeed and the update will go to the correct nodes on the next call.
ctx, cancel := context.WithTimeout(context.Background(), n.cfg.Tuning.NotifierSendTimeout) ctx, cancel := context.WithTimeout(context.Background(), n.cfg.Tuning.NotifierSendTimeout)
defer cancel() defer cancel()

View File

@ -3,9 +3,7 @@ package hscontrol
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/rand"
_ "embed" _ "embed"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"html/template" "html/template"
@ -157,13 +155,19 @@ func (a *AuthProviderOIDC) RegisterHandler(
return return
} }
randomBlob := make([]byte, randomByteSize) // Set the state and nonce cookies to protect against CSRF attacks
if _, err := rand.Read(randomBlob); err != nil { state, err := setCSRFCookie(writer, req, "state")
if err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError) http.Error(writer, "Internal server error", http.StatusInternalServerError)
return return
} }
stateStr := hex.EncodeToString(randomBlob)[:32] // Set the state and nonce cookies to protect against CSRF attacks
nonce, err := setCSRFCookie(writer, req, "nonce")
if err != nil {
http.Error(writer, "Internal server error", http.StatusInternalServerError)
return
}
// Initialize registration info with machine key // Initialize registration info with machine key
registrationInfo := RegistrationInfo{ registrationInfo := RegistrationInfo{
@ -191,11 +195,12 @@ func (a *AuthProviderOIDC) RegisterHandler(
for k, v := range a.cfg.ExtraParams { for k, v := range a.cfg.ExtraParams {
extras = append(extras, oauth2.SetAuthURLParam(k, v)) extras = append(extras, oauth2.SetAuthURLParam(k, v))
} }
extras = append(extras, oidc.Nonce(nonce))
// Cache the registration info // Cache the registration info
a.registrationCache.Set(stateStr, registrationInfo) a.registrationCache.Set(state, registrationInfo)
authURL := a.oauth2Config.AuthCodeURL(stateStr, extras...) authURL := a.oauth2Config.AuthCodeURL(state, extras...)
log.Debug().Msgf("Redirecting to %s for authentication", authURL) log.Debug().Msgf("Redirecting to %s for authentication", authURL)
http.Redirect(writer, req, authURL, http.StatusFound) http.Redirect(writer, req, authURL, http.StatusFound)
@ -228,11 +233,34 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
return return
} }
log.Debug().Interface("cookies", req.Cookies()).Msg("Received oidc callback")
cookieState, err := req.Cookie("state")
if err != nil {
http.Error(writer, "state not found", http.StatusBadRequest)
return
}
if state != cookieState.Value {
http.Error(writer, "state did not match", http.StatusBadRequest)
return
}
idToken, err := a.extractIDToken(req.Context(), code, state) idToken, err := a.extractIDToken(req.Context(), code, state)
if err != nil { if err != nil {
http.Error(writer, err.Error(), http.StatusBadRequest) http.Error(writer, err.Error(), http.StatusBadRequest)
return return
} }
nonce, err := req.Cookie("nonce")
if err != nil {
http.Error(writer, "nonce not found", http.StatusBadRequest)
return
}
if idToken.Nonce != nonce.Value {
http.Error(writer, "nonce did not match", http.StatusBadRequest)
return
}
nodeExpiry := a.determineNodeExpiry(idToken.Expiry) nodeExpiry := a.determineNodeExpiry(idToken.Expiry)
var claims types.OIDCClaims var claims types.OIDCClaims
@ -592,3 +620,22 @@ func getUserName(
return userName, nil return userName, nil
} }
func setCSRFCookie(w http.ResponseWriter, r *http.Request, name string) (string, error) {
val, err := util.GenerateRandomStringURLSafe(64)
if err != nil {
return val, err
}
c := &http.Cookie{
Path: "/oidc/callback",
Name: name,
Value: val,
MaxAge: int(time.Hour.Seconds()),
Secure: r.TLS != nil,
HttpOnly: true,
}
http.SetCookie(w, c)
return val, nil
}

View File

@ -62,7 +62,7 @@ func theInternet() *netipx.IPSet {
internetBuilder.RemovePrefix(tsaddr.CGNATRange()) internetBuilder.RemovePrefix(tsaddr.CGNATRange())
// Delete "cant find DHCP networks" // Delete "cant find DHCP networks"
internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-loca internetBuilder.RemovePrefix(netip.MustParsePrefix("fe80::/10")) // link-local
internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16")) internetBuilder.RemovePrefix(netip.MustParsePrefix("169.254.0.0/16"))
theInternetSet, _ := internetBuilder.IPSet() theInternetSet, _ := internetBuilder.IPSet()

View File

@ -387,7 +387,7 @@ func (m *mapSession) serveLongPoll() {
func (m *mapSession) pollFailoverRoutes(where string, node *types.Node) { func (m *mapSession) pollFailoverRoutes(where string, node *types.Node) {
update, err := db.Write(m.h.db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) { update, err := db.Write(m.h.db.DB, func(tx *gorm.DB) (*types.StateUpdate, error) {
return db.FailoverNodeRoutesIfNeccessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node) return db.FailoverNodeRoutesIfNecessary(tx, m.h.nodeNotifier.LikelyConnectedMap(), node)
}) })
if err != nil { if err != nil {
m.errf(err, fmt.Sprintf("failed to ensure failover routes, %s", where)) m.errf(err, fmt.Sprintf("failed to ensure failover routes, %s", where))
@ -453,7 +453,7 @@ func (m *mapSession) handleEndpointUpdate() {
// If there is no NetInfo, keep the previous one. // If there is no NetInfo, keep the previous one.
// From 1.66 the client only sends it if changed: // From 1.66 the client only sends it if changed:
// https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2 // https://github.com/tailscale/tailscale/commit/e1011f138737286ecf5123ff887a7a5800d129a2
// TODO(kradalby): evaulate if we need better comparing of hostinfo // TODO(kradalby): evaluate if we need better comparing of hostinfo
// before we take the changes. // before we take the changes.
if m.req.Hostinfo.NetInfo == nil && m.node.Hostinfo != nil { if m.req.Hostinfo.NetInfo == nil && m.node.Hostinfo != nil {
m.req.Hostinfo.NetInfo = m.node.Hostinfo.NetInfo m.req.Hostinfo.NetInfo = m.node.Hostinfo.NetInfo

View File

@ -617,7 +617,7 @@ func dns() (DNSConfig, error) {
// UnmarshalKey is compatible with Environment Variables. // UnmarshalKey is compatible with Environment Variables.
// err := viper.UnmarshalKey("dns", &dns) // err := viper.UnmarshalKey("dns", &dns)
// if err != nil { // if err != nil {
// return DNSConfig{}, fmt.Errorf("unmarshaling dns config: %w", err) // return DNSConfig{}, fmt.Errorf("unmarshalling dns config: %w", err)
// } // }
dns.MagicDNS = viper.GetBool("dns.magic_dns") dns.MagicDNS = viper.GetBool("dns.magic_dns")
@ -632,7 +632,7 @@ func dns() (DNSConfig, error) {
err := viper.UnmarshalKey("dns.extra_records", &extraRecords) err := viper.UnmarshalKey("dns.extra_records", &extraRecords)
if err != nil { if err != nil {
return DNSConfig{}, fmt.Errorf("unmarshaling dns extra records: %w", err) return DNSConfig{}, fmt.Errorf("unmarshalling dns extra records: %w", err)
} }
dns.ExtraRecords = extraRecords dns.ExtraRecords = extraRecords
} }

View File

@ -10,6 +10,8 @@ import (
"log" "log"
"net" "net"
"net/http" "net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip" "net/netip"
"sort" "sort"
"strconv" "strconv"
@ -747,6 +749,24 @@ func (s *AuthOIDCScenario) runMockOIDC(accessTTL time.Duration, users []mockoidc
}, nil }, nil
} }
type LoggingRoundTripper struct{}
func (t LoggingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
noTls := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint
}
resp, err := noTls.RoundTrip(req)
if err != nil {
return nil, err
}
log.Printf("---")
log.Printf("method: %s | url: %s", resp.Request.Method, resp.Request.URL.String())
log.Printf("status: %d | cookies: %+v", resp.StatusCode, resp.Cookies())
return resp, nil
}
func (s *AuthOIDCScenario) runTailscaleUp( func (s *AuthOIDCScenario) runTailscaleUp(
userStr, loginServer string, userStr, loginServer string,
) error { ) error {
@ -758,35 +778,39 @@ func (s *AuthOIDCScenario) runTailscaleUp(
log.Printf("running tailscale up for user %s", userStr) log.Printf("running tailscale up for user %s", userStr)
if user, ok := s.users[userStr]; ok { if user, ok := s.users[userStr]; ok {
for _, client := range user.Clients { for _, client := range user.Clients {
c := client tsc := client
user.joinWaitGroup.Go(func() error { user.joinWaitGroup.Go(func() error {
loginURL, err := c.LoginWithURL(loginServer) loginURL, err := tsc.LoginWithURL(loginServer)
if err != nil { if err != nil {
log.Printf("%s failed to run tailscale up: %s", c.Hostname(), err) log.Printf("%s failed to run tailscale up: %s", tsc.Hostname(), err)
} }
loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP()) loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetHostname())
loginURL.Scheme = "http" loginURL.Scheme = "http"
if len(headscale.GetCert()) > 0 { if len(headscale.GetCert()) > 0 {
loginURL.Scheme = "https" loginURL.Scheme = "https"
} }
insecureTransport := &http.Transport{ httptest.NewRecorder()
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // nolint hc := &http.Client{
Transport: LoggingRoundTripper{},
}
hc.Jar, err = cookiejar.New(nil)
if err != nil {
log.Printf("failed to create cookie jar: %s", err)
} }
log.Printf("%s login url: %s\n", c.Hostname(), loginURL.String()) log.Printf("%s login url: %s\n", tsc.Hostname(), loginURL.String())
log.Printf("%s logging in with url", c.Hostname()) log.Printf("%s logging in with url", tsc.Hostname())
httpClient := &http.Client{Transport: insecureTransport}
ctx := context.Background() ctx := context.Background()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil)
resp, err := httpClient.Do(req) resp, err := hc.Do(req)
if err != nil { if err != nil {
log.Printf( log.Printf(
"%s failed to login using url %s: %s", "%s failed to login using url %s: %s",
c.Hostname(), tsc.Hostname(),
loginURL, loginURL,
err, err,
) )
@ -794,8 +818,10 @@ func (s *AuthOIDCScenario) runTailscaleUp(
return err return err
} }
log.Printf("cookies: %+v", hc.Jar.Cookies(loginURL))
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
log.Printf("%s response code of oidc login request was %s", c.Hostname(), resp.Status) log.Printf("%s response code of oidc login request was %s", tsc.Hostname(), resp.Status)
body, _ := io.ReadAll(resp.Body) body, _ := io.ReadAll(resp.Body)
log.Printf("body: %s", body) log.Printf("body: %s", body)
@ -806,12 +832,12 @@ func (s *AuthOIDCScenario) runTailscaleUp(
_, err = io.ReadAll(resp.Body) _, err = io.ReadAll(resp.Body)
if err != nil { if err != nil {
log.Printf("%s failed to read response body: %s", c.Hostname(), err) log.Printf("%s failed to read response body: %s", tsc.Hostname(), err)
return err return err
} }
log.Printf("Finished request for %s to join tailnet", c.Hostname()) log.Printf("Finished request for %s to join tailnet", tsc.Hostname())
return nil return nil
}) })