Compare commits

..

18 Commits

Author SHA1 Message Date
Juan Font
95fee5aa6f Merge pull request #43 from juanfont/use-plurals-for-cmds
Change all commands to plural words
2021-06-29 23:38:03 +02:00
Juan Font Alonso
f5b8a3f710 Make all commands a plural word 2021-06-28 20:04:05 +02:00
Juan Font
ba87ade9c5 Merge pull request #42 from juanfont/tailscale-1.8.x
Update Headscale to Tailscale 1.10
2021-06-26 18:36:46 +02:00
Juan Font Alonso
aa27709e60 Update code to Tailscale 1.10 2021-06-25 18:57:08 +02:00
Juan Font Alonso
736182f651 Update dependencies, including Tailscale 1.10.x 2021-06-25 18:56:49 +02:00
Juan Font
c4aa9d8aed Merge pull request #41 from juanfont/gorm2
Migrate to GORM 2.0
2021-06-25 10:00:13 +02:00
Juan Font Alonso
d8e0b16512 Do not apply the FK migrations on startup 2021-06-24 23:05:26 +02:00
Juan Font Alonso
d67be9ef58 go.mod updates 2021-06-24 15:49:27 +02:00
Juan Font Alonso
69ba750b38 Update Headscale to depend on gorm v2 2021-06-24 15:44:19 +02:00
Juan Font
df0d214faf Merge pull request #38 from cmars/k8s
Add k8s deployment, standalone app Dockerfile.
2021-06-21 21:18:41 +02:00
Juan Font
73186eeb2f Merge pull request #40 from cmars/upstream-fix-nodes-nil-lastseen
Fix nil dereference in nodes list command.
2021-06-20 11:12:10 +02:00
Casey Marshall
fdcd3bb574 Fix nil dereference in nodes list command.
Fixes a nil pointer dereference observed when listing nodes that have
not yet connected.

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

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

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

View File

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

12
Dockerfile Normal file
View File

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

View File

@@ -1,6 +1,6 @@
# Headscale
[![Join the chat at https://gitter.im/headscale-dev/community](https://badges.gitter.im/headscale-dev/community.svg)](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ![ci](https://github.com/juanfont/headscale/actions/workflows/ci.yml/badge.svg)
[![Join the chat at https://gitter.im/headscale-dev/community](https://badges.gitter.im/headscale-dev/community.svg)](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) ![ci](https://github.com/juanfont/headscale/actions/workflows/test.yml/badge.svg)
An open source implementation of the Tailscale coordination server.
@@ -39,10 +39,8 @@ Suggestions/PRs welcomed!
## Running it
1. Compile the headscale binary
```shell
make
```
1. Download the Headscale binary https://github.com/juanfont/headscale/releases, and place it somewhere in your PATH
2. (Optional, you can also use SQLite) Get yourself a PostgreSQL DB running
@@ -63,33 +61,40 @@ Suggestions/PRs welcomed!
cp config.json.sqlite.example config.json
```
4. Create a namespace (equivalent to a user in tailscale.com)
4. Create a namespace (a namespace is a 'tailnet', a group of Tailscale nodes that can talk to each other)
```shell
./headscale namespace create myfirstnamespace
headscale namespace create myfirstnamespace
```
5. Run the server
```shell
./headscale serve
headscale serve
```
6. Add your first machine
6. If you used tailscale.com before in your nodes, make sure you clear the tailscaled data folder
```shell
systemctl stop tailscaled
rm -fr /var/lib/tailscale
systemctl start tailscaled
```
7. Add your first machine
```shell
tailscale up -login-server YOUR_HEADSCALE_URL
```
7. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
8. Navigate to the URL you will get with `tailscale up`, where you'll find your machine key.
8. In the server, register your machine to a namespace with the CLI
9. In the server, register your machine to a namespace with the CLI
```shell
./headscale -n myfirstnamespace node register YOURMACHINEKEY
headscale -n myfirstnamespace node register YOURMACHINEKEY
```
Alternatively, you can use Auth Keys to register your machines:
1. Create an authkey
```shell
./headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
headscale -n myfirstnamespace preauthkey create --reusable --expiration 24h
```
2. Use the authkey from your machine to register it
@@ -162,7 +167,6 @@ To get a certificate automatically via [Let's Encrypt](https://letsencrypt.org/)
1. We have nothing to do with Tailscale, or Tailscale Inc.
2. The purpose of writing this was to learn how Tailscale works.
3. ~~I don't use Headscale myself.~~

36
api.go
View File

@@ -3,6 +3,7 @@ package headscale
import (
"encoding/binary"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -10,12 +11,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/gorm"
"github.com/klauspost/compress/zstd"
"gorm.io/datatypes"
"gorm.io/gorm"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// KeyHandler provides the Headscale pub key
@@ -60,7 +61,7 @@ func (h *Headscale) RegisterWebAPI(c *gin.Context) {
func (h *Headscale) RegistrationHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr)
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
log.Printf("Cannot parse machine key: %s", err)
c.String(http.StatusInternalServerError, "Sad!")
@@ -80,16 +81,15 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
c.String(http.StatusInternalServerError, ":(")
return
}
defer db.Close()
var m Machine
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Println("New Machine!")
m = Machine{
Expiry: &req.Expiry,
MachineKey: mKey.HexString(),
Name: req.Hostinfo.Hostname,
NodeKey: wgcfg.Key(req.NodeKey).HexString(),
NodeKey: wgkey.Key(req.NodeKey).HexString(),
}
if err := db.Create(&m).Error; err != nil {
log.Printf("Could not create row: %s", err)
@@ -105,7 +105,7 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
resp := tailcfg.RegisterResponse{}
// We have the updated key!
if m.NodeKey == wgcfg.Key(req.NodeKey).HexString() {
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
if m.Registered {
log.Printf("[%s] Client is registered and we have the current NodeKey. All clear to /map", m.Name)
resp.AuthURL = ""
@@ -135,9 +135,9 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
}
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
if m.NodeKey == wgcfg.Key(req.OldNodeKey).HexString() {
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
log.Printf("[%s] We have the OldNodeKey in the database. This is a key refresh", m.Name)
m.NodeKey = wgcfg.Key(req.NodeKey).HexString()
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
db.Save(&m)
resp.AuthURL = ""
@@ -192,7 +192,7 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
func (h *Headscale) PollNetMapHandler(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
mKeyStr := c.Param("id")
mKey, err := wgcfg.ParseHexKey(mKeyStr)
mKey, err := wgkey.ParseHex(mKeyStr)
if err != nil {
log.Printf("Cannot parse client key: %s", err)
return
@@ -209,9 +209,8 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
log.Printf("Cannot open DB: %s", err)
return
}
defer db.Close()
var m Machine
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
log.Printf("Ignoring request, cannot find machine with key %s", mKey.HexString())
return
}
@@ -219,7 +218,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
hostinfo, _ := json.Marshal(req.Hostinfo)
m.Name = req.Hostinfo.Hostname
m.HostInfo = datatypes.JSON(hostinfo)
m.DiscoKey = wgcfg.Key(req.DiscoKey).HexString()
m.DiscoKey = wgkey.Key(req.DiscoKey).HexString()
now := time.Now().UTC()
// From Tailscale client:
@@ -335,7 +334,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
})
}
func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) {
func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgkey.Key, req tailcfg.MapRequest, m Machine) {
for {
select {
case <-cancel:
@@ -356,7 +355,7 @@ func (h *Headscale) keepAlive(cancel chan []byte, pollData chan []byte, mKey wgc
}
}
func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
func (h *Headscale) getMapResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
node, err := m.toNode()
if err != nil {
log.Printf("Cannot convert to node: %s", err)
@@ -377,7 +376,6 @@ func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Mac
PacketFilter: tailcfg.FilterAllowAll,
DERPMap: h.cfg.DerpMap,
UserProfiles: []tailcfg.UserProfile{},
Roles: []tailcfg.Role{},
}
var respBody []byte
@@ -403,7 +401,7 @@ func (h *Headscale) getMapResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Mac
return &data, nil
}
func (h *Headscale) getMapKeepAliveResponse(mKey wgcfg.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
func (h *Headscale) getMapKeepAliveResponse(mKey wgkey.Key, req tailcfg.MapRequest, m Machine) (*[]byte, error) {
resp := tailcfg.MapResponse{
KeepAlive: true,
}
@@ -429,7 +427,7 @@ func (h *Headscale) getMapKeepAliveResponse(mKey wgcfg.Key, req tailcfg.MapReque
return &data, nil
}
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgcfg.Key, req tailcfg.RegisterRequest, m Machine) {
func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgkey.Key, req tailcfg.RegisterRequest, m Machine) {
resp := tailcfg.RegisterResponse{}
pak, err := h.checkKeyValidity(req.Auth.AuthKey)
if err != nil {
@@ -453,7 +451,7 @@ func (h *Headscale) handleAuthKey(c *gin.Context, db *gorm.DB, idKey wgcfg.Key,
m.AuthKeyID = uint(pak.ID)
m.IPAddress = ip.String()
m.NamespaceID = pak.NamespaceID
m.NodeKey = wgcfg.Key(req.NodeKey).HexString() // we update it just in case
m.NodeKey = wgkey.Key(req.NodeKey).HexString() // we update it just in case
m.Registered = true
m.RegisterMethod = "authKey"
db.Save(&m)

9
app.go
View File

@@ -13,7 +13,7 @@ import (
"github.com/gin-gonic/gin"
"golang.org/x/crypto/acme/autocert"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// Config contains the initial Headscale configuration
@@ -46,8 +46,8 @@ type Headscale struct {
dbString string
dbType string
dbDebug bool
publicKey *wgcfg.Key
privateKey *wgcfg.PrivateKey
publicKey *wgkey.Key
privateKey *wgkey.Private
pollMu sync.Mutex
clientsPolling map[uint64]chan []byte // this is by all means a hackity hack
@@ -59,7 +59,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
if err != nil {
return nil, err
}
privKey, err := wgcfg.ParsePrivateKey(string(content))
privKey, err := wgkey.ParsePrivate(string(content))
if err != nil {
return nil, err
}
@@ -112,7 +112,6 @@ func (h *Headscale) expireEphemeralNodesWorker() {
log.Printf("Cannot open DB: %s", err)
return
}
defer db.Close()
namespaces, err := h.ListNamespaces()
if err != nil {

View File

@@ -5,8 +5,6 @@ import (
"os"
"testing"
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
"gopkg.in/check.v1"
)

8
cli.go
View File

@@ -4,7 +4,8 @@ import (
"errors"
"log"
"tailscale.com/wgengine/wgcfg"
"gorm.io/gorm"
"tailscale.com/types/wgkey"
)
// RegisterMachine is executed from the CLI to register a new Machine using its MachineKey
@@ -13,7 +14,7 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
if err != nil {
return nil, err
}
mKey, err := wgcfg.ParseHexKey(key)
mKey, err := wgkey.ParseHex(key)
if err != nil {
return nil, err
}
@@ -22,9 +23,8 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
m := Machine{}
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
if result := db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errors.New("Machine not found")
}

View File

@@ -12,7 +12,6 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import (
)
var PreauthkeysCmd = &cobra.Command{
Use: "preauthkey",
Use: "preauthkeys",
Short: "Handle the preauthkeys in Headscale",
}

49
db.go
View File

@@ -3,9 +3,9 @@ package headscale
import (
"errors"
"github.com/jinzhu/gorm"
_ "github.com/jinzhu/gorm/dialects/postgres" // sql driver
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
const dbVersion = "1"
@@ -17,30 +17,53 @@ type KV struct {
}
func (h *Headscale) initDB() error {
db, err := gorm.Open(h.dbType, h.dbString)
db, err := h.db()
if err != nil {
return err
}
if h.dbType == "postgres" {
db.Exec("create extension if not exists \"uuid-ossp\";")
}
db.AutoMigrate(&Machine{})
db.AutoMigrate(&KV{})
db.AutoMigrate(&Namespace{})
db.AutoMigrate(&PreAuthKey{})
db.Close()
err = db.AutoMigrate(&Machine{})
if err != nil {
return err
}
err = db.AutoMigrate(&KV{})
if err != nil {
return err
}
err = db.AutoMigrate(&Namespace{})
if err != nil {
return err
}
err = db.AutoMigrate(&PreAuthKey{})
if err != nil {
return err
}
err = h.setValue("db_version", dbVersion)
return err
}
func (h *Headscale) db() (*gorm.DB, error) {
db, err := gorm.Open(h.dbType, h.dbString)
var db *gorm.DB
var err error
switch h.dbType {
case "sqlite3":
db, err = gorm.Open(sqlite.Open(h.dbString), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
case "postgres":
db, err = gorm.Open(postgres.Open(h.dbString), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
}
if err != nil {
return nil, err
}
if h.dbDebug {
db.LogMode(true)
db.Debug()
}
return db, nil
}
@@ -50,9 +73,8 @@ func (h *Headscale) getValue(key string) (string, error) {
if err != nil {
return "", err
}
defer db.Close()
var row KV
if db.First(&row, "key = ?", key).RecordNotFound() {
if result := db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return "", errors.New("not found")
}
return row.Value, nil
@@ -67,7 +89,6 @@ func (h *Headscale) setValue(key string, value string) error {
if err != nil {
return err
}
defer db.Close()
_, err = h.getValue(key)
if err == nil {
db.Model(&kv).Where("key = ?", key).Update("value", value)

28
go.mod
View File

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

907
go.sum

File diff suppressed because it is too large Load Diff

2
k8s/.gitignore vendored Normal file
View File

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

99
k8s/README.md Normal file
View File

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

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

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

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

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

View File

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

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

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

3
k8s/headscale.bash Executable file
View File

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

22
k8s/init.bash Executable file
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ import (
"gorm.io/datatypes"
"inet.af/netaddr"
"tailscale.com/tailcfg"
"tailscale.com/wgengine/wgcfg"
"tailscale.com/types/wgkey"
)
// Machine is a Headscale client
@@ -23,7 +23,7 @@ type Machine struct {
IPAddress string
Name string
NamespaceID uint
Namespace Namespace
Namespace Namespace `gorm:"foreignKey:NamespaceID"`
Registered bool // temp
RegisterMethod string
@@ -48,18 +48,18 @@ func (m Machine) isAlreadyRegistered() bool {
}
func (m Machine) toNode() (*tailcfg.Node, error) {
nKey, err := wgcfg.ParseHexKey(m.NodeKey)
nKey, err := wgkey.ParseHex(m.NodeKey)
if err != nil {
return nil, err
}
mKey, err := wgcfg.ParseHexKey(m.MachineKey)
mKey, err := wgkey.ParseHex(m.MachineKey)
if err != nil {
return nil, err
}
var discoKey tailcfg.DiscoKey
if m.DiscoKey != "" {
dKey, err := wgcfg.ParseHexKey(m.DiscoKey)
dKey, err := wgkey.ParseHex(m.DiscoKey)
if err != nil {
return nil, err
}
@@ -159,7 +159,6 @@ func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) {
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",

View File

@@ -15,7 +15,6 @@ func (s *Suite) TestGetMachine(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)

View File

@@ -1,10 +1,11 @@
package headscale
import (
"errors"
"log"
"time"
"github.com/jinzhu/gorm"
"gorm.io/gorm"
"tailscale.com/tailcfg"
)
@@ -29,7 +30,6 @@ func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
n := Namespace{}
if err := db.Where("name = ?", name).First(&n).Error; err == nil {
@@ -51,7 +51,6 @@ func (h *Headscale) DestroyNamespace(name string) error {
log.Printf("Cannot open DB: %s", err)
return err
}
defer db.Close()
n, err := h.GetNamespace(name)
if err != nil {
@@ -66,8 +65,7 @@ func (h *Headscale) DestroyNamespace(name string) error {
return errorNamespaceNotEmpty
}
err = db.Unscoped().Delete(&n).Error
if err != nil {
if result := db.Unscoped().Delete(&n); result.Error != nil {
return err
}
@@ -81,10 +79,9 @@ func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
n := Namespace{}
if db.First(&n, "name = ?", name).RecordNotFound() {
if result := db.First(&n, "name = ?", name); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errorNamespaceNotFound
}
return &n, nil
@@ -97,7 +94,6 @@ func (h *Headscale) ListNamespaces() (*[]Namespace, error) {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
namespaces := []Namespace{}
if err := db.Find(&namespaces).Error; err != nil {
return nil, err
@@ -116,7 +112,6 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
machines := []Machine{}
if err := db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
@@ -136,7 +131,6 @@ func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error
log.Printf("Cannot open DB: %s", err)
return err
}
defer db.Close()
m.NamespaceID = n.ID
db.Save(&m)
return nil
@@ -150,7 +144,6 @@ func (n *Namespace) toUser() *tailcfg.User {
ProfilePicURL: "",
Domain: "",
Logins: []tailcfg.LoginID{},
Roles: []tailcfg.RoleID{},
Created: time.Time{},
}
return &u

View File

@@ -34,7 +34,6 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,
MachineKey: "foo",

View File

@@ -3,8 +3,11 @@ package headscale
import (
"crypto/rand"
"encoding/hex"
"errors"
"log"
"time"
"gorm.io/gorm"
)
const errorAuthKeyNotFound = Error("AuthKey not found")
@@ -36,7 +39,6 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
now := time.Now().UTC()
kstr, err := h.generateKey()
@@ -69,7 +71,6 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
log.Printf("Cannot open DB: %s", err)
return nil, err
}
defer db.Close()
keys := []PreAuthKey{}
if err := db.Preload("Namespace").Where(&PreAuthKey{NamespaceID: n.ID}).Find(&keys).Error; err != nil {
@@ -85,10 +86,9 @@ func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
if err != nil {
return nil, err
}
defer db.Close()
pak := PreAuthKey{}
if db.Preload("Namespace").First(&pak, "key = ?", k).RecordNotFound() {
if result := db.Preload("Namespace").First(&pak, "key = ?", k); errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, errorAuthKeyNotFound
}

View File

@@ -77,7 +77,6 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 0,
MachineKey: "foo",
@@ -107,7 +106,6 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
m := Machine{
ID: 1,
MachineKey: "foo",
@@ -149,7 +147,6 @@ func (*Suite) TestEphemeralKey(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
now := time.Now()
m := Machine{
ID: 0,

View File

@@ -51,7 +51,6 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
routes, _ := json.Marshal([]string{routeStr}) // TODO: only one for the time being, so overwriting the rest
m.EnabledRoutes = datatypes.JSON(routes)
db.Save(&m)
db.Close()
// THIS IS COMPLETELY USELESS.
// The peers map is stored in memory in the server process.

View File

@@ -20,7 +20,6 @@ func (s *Suite) TestGetRoutes(c *check.C) {
if err != nil {
c.Fatal(err)
}
defer db.Close()
_, err = h.GetMachine("test", "testmachine")
c.Assert(err, check.NotNil)

View File

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