mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-18 22:10:44 +09:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90e9ad9a0e | |||
| ff9d99b9ea | |||
| 31ea67bcaf | |||
| 874aa4277d | |||
| 95fee5aa6f | |||
| f5b8a3f710 | |||
| ba87ade9c5 | |||
| aa27709e60 | |||
| 736182f651 | |||
| c4aa9d8aed | |||
| d8e0b16512 | |||
| d67be9ef58 | |||
| 69ba750b38 | |||
| df0d214faf | |||
| 73186eeb2f | |||
| fdcd3bb574 | |||
| c64d756ea7 | |||
| a63fb6b007 |
+12
@@ -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"]
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[](https://gitter.im/headscale-dev/community?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 
|
||||
|
||||
An open source implementation of the Tailscale coordination server.
|
||||
An open source, self-hosted implementation of the Tailscale coordination server.
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -10,28 +10,27 @@ Tailscale is [a modern VPN](https://tailscale.com/) built on top of [Wireguard](
|
||||
|
||||
Everything in Tailscale is Open Source, except the GUI clients for proprietary OS (Windows and macOS/iOS), and the 'coordination/control server'.
|
||||
|
||||
The control server works as an exchange point of cryptographic public keys for the nodes in the Tailscale network. It also assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.
|
||||
The control server works as an exchange point of Wireguard public keys for the nodes in the Tailscale network. It also assigns the IP addresses of the clients, creates the boundaries between each user, enables sharing machines between users, and exposes the advertised routes of your nodes.
|
||||
|
||||
Headscale implements this coordination server.
|
||||
|
||||
## Status
|
||||
|
||||
- [x] Basic functionality (nodes can communicate with each other)
|
||||
- [x] Base functionality (nodes can communicate with each other)
|
||||
- [x] Node registration through the web flow
|
||||
- [x] Network changes are relied to the nodes
|
||||
- [x] ~~Multiuser~~ Namespace support
|
||||
- [x] Basic routing (advertise & accept)
|
||||
- [ ] Share nodes between ~~users~~ namespaces
|
||||
- [x] Node registration via pre-auth keys (including reusable keys and ephemeral node support)
|
||||
- [x] ~~Multiuser/multitailnet~~ Namespace support
|
||||
- [x] Routing (advertise & accept, including exit nodes)
|
||||
- [x] Node registration via pre-auth keys (including reusable keys, and ephemeral node support)
|
||||
- [X] JSON-formatted output
|
||||
- [ ] ACLs
|
||||
- [ ] (✨ WIP) ACLs
|
||||
- [ ] Share nodes between ~~users~~ namespaces
|
||||
- [ ] DNS
|
||||
|
||||
... and probably lots of stuff missing
|
||||
|
||||
## Roadmap 🤷
|
||||
|
||||
Basic multiuser support (multinamespace, actually) is now implemented. No node sharing or ACLs between namespaces yet though...
|
||||
We are now working on adding ACLs https://tailscale.com/kb/1018/acls
|
||||
|
||||
Suggestions/PRs welcomed!
|
||||
|
||||
@@ -71,14 +70,21 @@ Suggestions/PRs welcomed!
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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!")
|
||||
@@ -74,38 +75,30 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
c.String(http.StatusInternalServerError, ":(")
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
var m Machine
|
||||
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
|
||||
if result := h.db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
log.Println("New Machine!")
|
||||
m = Machine{
|
||||
Expiry: &req.Expiry,
|
||||
MachineKey: mKey.HexString(),
|
||||
Name: req.Hostinfo.Hostname,
|
||||
NodeKey: wgcfg.Key(req.NodeKey).HexString(),
|
||||
NodeKey: wgkey.Key(req.NodeKey).HexString(),
|
||||
}
|
||||
if err := db.Create(&m).Error; err != nil {
|
||||
if err := h.db.Create(&m).Error; err != nil {
|
||||
log.Printf("Could not create row: %s", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if !m.Registered && req.Auth.AuthKey != "" {
|
||||
h.handleAuthKey(c, db, mKey, req, m)
|
||||
h.handleAuthKey(c, h.db, mKey, req, m)
|
||||
return
|
||||
}
|
||||
|
||||
resp := tailcfg.RegisterResponse{}
|
||||
|
||||
// We have the updated key!
|
||||
if m.NodeKey == wgcfg.Key(req.NodeKey).HexString() {
|
||||
if m.NodeKey == wgkey.Key(req.NodeKey).HexString() {
|
||||
if m.Registered {
|
||||
log.Printf("[%s] Client is registered and we have the current NodeKey. All clear to /map", m.Name)
|
||||
resp.AuthURL = ""
|
||||
@@ -135,10 +128,10 @@ func (h *Headscale) RegistrationHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
// The NodeKey we have matches OldNodeKey, which means this is a refresh after an key expiration
|
||||
if m.NodeKey == wgcfg.Key(req.OldNodeKey).HexString() {
|
||||
if m.NodeKey == wgkey.Key(req.OldNodeKey).HexString() {
|
||||
log.Printf("[%s] We have the OldNodeKey in the database. This is a key refresh", m.Name)
|
||||
m.NodeKey = wgcfg.Key(req.NodeKey).HexString()
|
||||
db.Save(&m)
|
||||
m.NodeKey = wgkey.Key(req.NodeKey).HexString()
|
||||
h.db.Save(&m)
|
||||
|
||||
resp.AuthURL = ""
|
||||
resp.User = *m.Namespace.toUser()
|
||||
@@ -192,7 +185,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
|
||||
@@ -204,14 +197,8 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
var m Machine
|
||||
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
|
||||
if result := h.db.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 +206,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:
|
||||
@@ -235,7 +222,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
|
||||
m.Endpoints = datatypes.JSON(endpoints)
|
||||
m.LastSeen = &now
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
pollData := make(chan []byte, 1)
|
||||
update := make(chan []byte, 1)
|
||||
@@ -304,7 +291,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
m.LastSeen = &now
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
return true
|
||||
|
||||
case <-update:
|
||||
@@ -323,7 +310,7 @@ func (h *Headscale) PollNetMapHandler(c *gin.Context) {
|
||||
log.Printf("[%s] The client has closed the connection", m.Name)
|
||||
now := time.Now().UTC()
|
||||
m.LastSeen = &now
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
h.pollMu.Lock()
|
||||
cancelKeepAlive <- []byte{}
|
||||
delete(h.clientsPolling, m.ID)
|
||||
@@ -335,7 +322,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 +343,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 +364,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 +389,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 +415,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 +439,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)
|
||||
|
||||
@@ -12,8 +12,9 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/acme/autocert"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
// Config contains the initial Headscale configuration
|
||||
@@ -43,11 +44,12 @@ type Config struct {
|
||||
// Headscale represents the base app of the service
|
||||
type Headscale struct {
|
||||
cfg Config
|
||||
db *gorm.DB
|
||||
dbString string
|
||||
dbType string
|
||||
dbDebug bool
|
||||
publicKey *wgcfg.Key
|
||||
privateKey *wgcfg.PrivateKey
|
||||
publicKey *wgkey.Key
|
||||
privateKey *wgkey.Private
|
||||
|
||||
pollMu sync.Mutex
|
||||
clientsPolling map[uint64]chan []byte // this is by all means a hackity hack
|
||||
@@ -59,7 +61,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
|
||||
}
|
||||
@@ -87,6 +89,7 @@ func NewHeadscale(cfg Config) (*Headscale, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.clientsPolling = make(map[uint64]chan []byte)
|
||||
return &h, nil
|
||||
}
|
||||
@@ -107,13 +110,6 @@ func (h *Headscale) ExpireEphemeralNodes(milliSeconds int64) {
|
||||
}
|
||||
|
||||
func (h *Headscale) expireEphemeralNodesWorker() {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
namespaces, err := h.ListNamespaces()
|
||||
if err != nil {
|
||||
log.Printf("Error listing namespaces: %s", err)
|
||||
@@ -128,7 +124,7 @@ func (h *Headscale) expireEphemeralNodesWorker() {
|
||||
for _, m := range *machines {
|
||||
if m.AuthKey != nil && m.LastSeen != nil && m.AuthKey.Ephemeral && time.Now().After(m.LastSeen.Add(h.cfg.EphemeralNodeInactivityTimeout)) {
|
||||
log.Printf("[%s] Ephemeral client removed from database\n", m.Name)
|
||||
err = db.Unscoped().Delete(m).Error
|
||||
err = h.db.Unscoped().Delete(m).Error
|
||||
if err != nil {
|
||||
log.Printf("[%s] 🤮 Cannot delete ephemeral machine from the database: %s", m.Name, err)
|
||||
}
|
||||
|
||||
+5
-2
@@ -5,8 +5,6 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
_ "github.com/jinzhu/gorm/dialects/sqlite" // sql driver
|
||||
|
||||
"gopkg.in/check.v1"
|
||||
)
|
||||
|
||||
@@ -49,4 +47,9 @@ func (s *Suite) ResetDB(c *check.C) {
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
h.db = db
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ package headscale
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
// RegisterMachine is executed from the CLI to register a new Machine using its MachineKey
|
||||
@@ -13,18 +13,13 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mKey, err := wgcfg.ParseHexKey(key)
|
||||
mKey, err := wgkey.ParseHex(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
m := Machine{}
|
||||
if db.First(&m, "machine_key = ?", mKey.HexString()).RecordNotFound() {
|
||||
if result := h.db.First(&m, "machine_key = ?", mKey.HexString()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("Machine not found")
|
||||
}
|
||||
|
||||
@@ -40,6 +35,6 @@ func (h *Headscale) RegisterMachine(key string, namespace string) (*Machine, err
|
||||
m.NamespaceID = ns.ID
|
||||
m.Registered = true
|
||||
m.RegisterMethod = "cli"
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
+1
-7
@@ -8,12 +8,6 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
|
||||
n, err := h.CreateNamespace("test")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
m := Machine{
|
||||
ID: 0,
|
||||
MachineKey: "8ce002a935f8c394e55e78fbbb410576575ff8ec5cfa2e627e4b807f1be15b0e",
|
||||
@@ -22,7 +16,7 @@ func (s *Suite) TestRegisterMachine(c *check.C) {
|
||||
Name: "testmachine",
|
||||
NamespaceID: n.ID,
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
_, err = h.GetMachine("test", "testmachine")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
var NamespaceCmd = &cobra.Command{
|
||||
Use: "namespace",
|
||||
Use: "namespaces",
|
||||
Short: "Manage the namespaces of Headscale",
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
var PreauthkeysCmd = &cobra.Command{
|
||||
Use: "preauthkey",
|
||||
Use: "preauthkeys",
|
||||
Short: "Handle the preauthkeys in Headscale",
|
||||
}
|
||||
|
||||
|
||||
@@ -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,42 +17,62 @@ type KV struct {
|
||||
}
|
||||
|
||||
func (h *Headscale) initDB() error {
|
||||
db, err := gorm.Open(h.dbType, h.dbString)
|
||||
db, err := h.openDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h.db = db
|
||||
|
||||
if h.dbType == "postgres" {
|
||||
db.Exec("create extension if not exists \"uuid-ossp\";")
|
||||
}
|
||||
db.AutoMigrate(&Machine{})
|
||||
db.AutoMigrate(&KV{})
|
||||
db.AutoMigrate(&Namespace{})
|
||||
db.AutoMigrate(&PreAuthKey{})
|
||||
db.Close()
|
||||
err = db.AutoMigrate(&Machine{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&KV{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&Namespace{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = db.AutoMigrate(&PreAuthKey{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = h.setValue("db_version", dbVersion)
|
||||
return err
|
||||
}
|
||||
|
||||
func (h *Headscale) db() (*gorm.DB, error) {
|
||||
db, err := gorm.Open(h.dbType, h.dbString)
|
||||
func (h *Headscale) openDB() (*gorm.DB, error) {
|
||||
var db *gorm.DB
|
||||
var err error
|
||||
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
|
||||
}
|
||||
|
||||
func (h *Headscale) getValue(key string) (string, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer db.Close()
|
||||
var row KV
|
||||
if db.First(&row, "key = ?", key).RecordNotFound() {
|
||||
if result := h.db.First(&row, "key = ?", key); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return "", errors.New("not found")
|
||||
}
|
||||
return row.Value, nil
|
||||
@@ -63,17 +83,13 @@ func (h *Headscale) setValue(key string, value string) error {
|
||||
Key: key,
|
||||
Value: value,
|
||||
}
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = h.getValue(key)
|
||||
|
||||
_, err := h.getValue(key)
|
||||
if err == nil {
|
||||
db.Model(&kv).Where("key = ?", key).Update("value", value)
|
||||
h.db.Model(&kv).Where("key = ?", key).Update("value", value)
|
||||
return nil
|
||||
}
|
||||
|
||||
db.Create(kv)
|
||||
h.db.Create(kv)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/**/site
|
||||
/**/secrets
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eu
|
||||
exec kubectl -n headscale exec -ti pod/headscale-0 -- /go/bin/headscale "$@"
|
||||
Executable
+22
@@ -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)
|
||||
Executable
+3
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- production-issuer.yaml
|
||||
patches:
|
||||
- path: ingress-patch.yaml
|
||||
target:
|
||||
kind: Ingress
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- statefulset.yaml
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace: headscale
|
||||
bases:
|
||||
- ../base
|
||||
resources:
|
||||
- staging-issuer.yaml
|
||||
patches:
|
||||
- path: ingress-patch.yaml
|
||||
target:
|
||||
kind: Ingress
|
||||
@@ -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
|
||||
+6
-12
@@ -11,7 +11,7 @@ import (
|
||||
"gorm.io/datatypes"
|
||||
"inet.af/netaddr"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
// Machine is a Headscale client
|
||||
@@ -23,7 +23,7 @@ type Machine struct {
|
||||
IPAddress string
|
||||
Name string
|
||||
NamespaceID uint
|
||||
Namespace Namespace
|
||||
Namespace Namespace `gorm:"foreignKey:NamespaceID"`
|
||||
|
||||
Registered bool // temp
|
||||
RegisterMethod string
|
||||
@@ -48,18 +48,18 @@ func (m Machine) isAlreadyRegistered() bool {
|
||||
}
|
||||
|
||||
func (m Machine) toNode() (*tailcfg.Node, error) {
|
||||
nKey, err := wgcfg.ParseHexKey(m.NodeKey)
|
||||
nKey, err := wgkey.ParseHex(m.NodeKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mKey, err := wgcfg.ParseHexKey(m.MachineKey)
|
||||
mKey, err := wgkey.ParseHex(m.MachineKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var discoKey tailcfg.DiscoKey
|
||||
if m.DiscoKey != "" {
|
||||
dKey, err := wgcfg.ParseHexKey(m.DiscoKey)
|
||||
dKey, err := wgkey.ParseHex(m.DiscoKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -154,15 +154,9 @@ func (m Machine) toNode() (*tailcfg.Node, error) {
|
||||
}
|
||||
|
||||
func (h *Headscale) getPeers(m Machine) (*[]*tailcfg.Node, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
machines := []Machine{}
|
||||
if err = db.Where("namespace_id = ? AND machine_key <> ? AND registered",
|
||||
if err := h.db.Where("namespace_id = ? AND machine_key <> ? AND registered",
|
||||
m.NamespaceID, m.MachineKey).Find(&machines).Error; err != nil {
|
||||
log.Printf("Error accessing db: %s", err)
|
||||
return nil, err
|
||||
|
||||
+1
-7
@@ -11,12 +11,6 @@ func (s *Suite) TestGetMachine(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = h.GetMachine("test", "testmachine")
|
||||
c.Assert(err, check.NotNil)
|
||||
|
||||
@@ -31,7 +25,7 @@ func (s *Suite) TestGetMachine(c *check.C) {
|
||||
RegisterMethod: "authKey",
|
||||
AuthKeyID: uint(pak.ID),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
m1, err := h.GetMachine("test", "testmachine")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
+9
-49
@@ -1,10 +1,11 @@
|
||||
package headscale
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/jinzhu/gorm"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
@@ -24,19 +25,12 @@ type Namespace struct {
|
||||
// CreateNamespace creates a new Namespace. Returns error if could not be created
|
||||
// or another namespace already exists
|
||||
func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
n := Namespace{}
|
||||
if err := db.Where("name = ?", name).First(&n).Error; err == nil {
|
||||
if err := h.db.Where("name = ?", name).First(&n).Error; err == nil {
|
||||
return nil, errorNamespaceExists
|
||||
}
|
||||
n.Name = name
|
||||
if err := db.Create(&n).Error; err != nil {
|
||||
if err := h.db.Create(&n).Error; err != nil {
|
||||
log.Printf("Could not create row: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
@@ -46,13 +40,6 @@ func (h *Headscale) CreateNamespace(name string) (*Namespace, error) {
|
||||
// DestroyNamespace destroys a Namespace. Returns error if the Namespace does
|
||||
// not exist or if there are machines associated with it.
|
||||
func (h *Headscale) DestroyNamespace(name string) error {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
n, err := h.GetNamespace(name)
|
||||
if err != nil {
|
||||
return errorNamespaceNotFound
|
||||
@@ -66,8 +53,7 @@ func (h *Headscale) DestroyNamespace(name string) error {
|
||||
return errorNamespaceNotEmpty
|
||||
}
|
||||
|
||||
err = db.Unscoped().Delete(&n).Error
|
||||
if err != nil {
|
||||
if result := h.db.Unscoped().Delete(&n); result.Error != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -76,15 +62,8 @@ func (h *Headscale) DestroyNamespace(name string) error {
|
||||
|
||||
// GetNamespace fetches a namespace by name
|
||||
func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
n := Namespace{}
|
||||
if db.First(&n, "name = ?", name).RecordNotFound() {
|
||||
if result := h.db.First(&n, "name = ?", name); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errorNamespaceNotFound
|
||||
}
|
||||
return &n, nil
|
||||
@@ -92,14 +71,8 @@ func (h *Headscale) GetNamespace(name string) (*Namespace, error) {
|
||||
|
||||
// ListNamespaces gets all the existing namespaces
|
||||
func (h *Headscale) ListNamespaces() (*[]Namespace, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
namespaces := []Namespace{}
|
||||
if err := db.Find(&namespaces).Error; err != nil {
|
||||
if err := h.db.Find(&namespaces).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &namespaces, nil
|
||||
@@ -111,15 +84,9 @@ func (h *Headscale) ListMachinesInNamespace(name string) (*[]Machine, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
machines := []Machine{}
|
||||
if err := db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
|
||||
if err := h.db.Preload("AuthKey").Where(&Machine{NamespaceID: n.ID}).Find(&machines).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &machines, nil
|
||||
@@ -131,14 +98,8 @@ func (h *Headscale) SetMachineNamespace(m *Machine, namespaceName string) error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
m.NamespaceID = n.ID
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -150,7 +111,6 @@ func (n *Namespace) toUser() *tailcfg.User {
|
||||
ProfilePicURL: "",
|
||||
Domain: "",
|
||||
Logins: []tailcfg.LoginID{},
|
||||
Roles: []tailcfg.RoleID{},
|
||||
Created: time.Time{},
|
||||
}
|
||||
return &u
|
||||
|
||||
+1
-6
@@ -30,11 +30,6 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := Machine{
|
||||
ID: 0,
|
||||
MachineKey: "foo",
|
||||
@@ -46,7 +41,7 @@ func (s *Suite) TestDestroyNamespaceErrors(c *check.C) {
|
||||
RegisterMethod: "authKey",
|
||||
AuthKeyID: uint(pak.ID),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
err = h.DestroyNamespace("test")
|
||||
c.Assert(err, check.Equals, errorNamespaceNotEmpty)
|
||||
|
||||
+7
-24
@@ -3,8 +3,10 @@ package headscale
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const errorAuthKeyNotFound = Error("AuthKey not found")
|
||||
@@ -31,13 +33,6 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
|
||||
return nil, err
|
||||
}
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now().UTC()
|
||||
kstr, err := h.generateKey()
|
||||
if err != nil {
|
||||
@@ -53,7 +48,7 @@ func (h *Headscale) CreatePreAuthKey(namespaceName string, reusable bool, epheme
|
||||
CreatedAt: &now,
|
||||
Expiration: expiration,
|
||||
}
|
||||
db.Save(&k)
|
||||
h.db.Save(&k)
|
||||
|
||||
return &k, nil
|
||||
}
|
||||
@@ -64,15 +59,9 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
keys := []PreAuthKey{}
|
||||
if err := db.Preload("Namespace").Where(&PreAuthKey{NamespaceID: n.ID}).Find(&keys).Error; err != nil {
|
||||
if err := h.db.Preload("Namespace").Where(&PreAuthKey{NamespaceID: n.ID}).Find(&keys).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &keys, nil
|
||||
@@ -81,14 +70,8 @@ func (h *Headscale) GetPreAuthKeys(namespaceName string) (*[]PreAuthKey, error)
|
||||
// checkKeyValidity does the heavy lifting for validation of the PreAuthKey coming from a node
|
||||
// If returns no error and a PreAuthKey, it can be used
|
||||
func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
pak := PreAuthKey{}
|
||||
if db.Preload("Namespace").First(&pak, "key = ?", k).RecordNotFound() {
|
||||
if result := h.db.Preload("Namespace").First(&pak, "key = ?", k); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, errorAuthKeyNotFound
|
||||
}
|
||||
|
||||
@@ -101,7 +84,7 @@ func (h *Headscale) checkKeyValidity(k string) (*PreAuthKey, error) {
|
||||
}
|
||||
|
||||
machines := []Machine{}
|
||||
if err := db.Preload("AuthKey").Where(&Machine{AuthKeyID: uint(pak.ID)}).Find(&machines).Error; err != nil {
|
||||
if err := h.db.Preload("AuthKey").Where(&Machine{AuthKeyID: uint(pak.ID)}).Find(&machines).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
+3
-18
@@ -73,11 +73,6 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := Machine{
|
||||
ID: 0,
|
||||
MachineKey: "foo",
|
||||
@@ -89,7 +84,7 @@ func (*Suite) TestAlreadyUsedKey(c *check.C) {
|
||||
RegisterMethod: "authKey",
|
||||
AuthKeyID: uint(pak.ID),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
p, err := h.checkKeyValidity(pak.Key)
|
||||
c.Assert(err, check.Equals, errorAuthKeyNotReusableAlreadyUsed)
|
||||
@@ -103,11 +98,6 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, true, false, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
m := Machine{
|
||||
ID: 1,
|
||||
MachineKey: "foo",
|
||||
@@ -119,7 +109,7 @@ func (*Suite) TestReusableBeingUsedKey(c *check.C) {
|
||||
RegisterMethod: "authKey",
|
||||
AuthKeyID: uint(pak.ID),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
p, err := h.checkKeyValidity(pak.Key)
|
||||
c.Assert(err, check.IsNil)
|
||||
@@ -145,11 +135,6 @@ func (*Suite) TestEphemeralKey(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, false, true, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
m := Machine{
|
||||
ID: 0,
|
||||
@@ -163,7 +148,7 @@ func (*Suite) TestEphemeralKey(c *check.C) {
|
||||
LastSeen: &now,
|
||||
AuthKeyID: uint(pak.ID),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
_, err = h.checkKeyValidity(pak.Key)
|
||||
// Ephemeral keys are by definition reusable
|
||||
|
||||
@@ -3,7 +3,6 @@ package headscale
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
"inet.af/netaddr"
|
||||
@@ -42,16 +41,9 @@ func (h *Headscale) EnableNodeRoute(namespace string, nodeName string, routeStr
|
||||
|
||||
for _, rIP := range hi.RoutableIPs {
|
||||
if rIP == route {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
log.Printf("Cannot open DB: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
routes, _ := json.Marshal([]string{routeStr}) // TODO: only one for the time being, so overwriting the rest
|
||||
m.EnabledRoutes = datatypes.JSON(routes)
|
||||
db.Save(&m)
|
||||
db.Close()
|
||||
h.db.Save(&m)
|
||||
|
||||
// THIS IS COMPLETELY USELESS.
|
||||
// The peers map is stored in memory in the server process.
|
||||
|
||||
+1
-7
@@ -16,12 +16,6 @@ func (s *Suite) TestGetRoutes(c *check.C) {
|
||||
pak, err := h.CreatePreAuthKey(n.Name, false, false, nil)
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
c.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = h.GetMachine("test", "testmachine")
|
||||
c.Assert(err, check.NotNil)
|
||||
|
||||
@@ -46,7 +40,7 @@ func (s *Suite) TestGetRoutes(c *check.C) {
|
||||
AuthKeyID: uint(pak.ID),
|
||||
HostInfo: datatypes.JSON(hostinfo),
|
||||
}
|
||||
db.Save(&m)
|
||||
h.db.Save(&m)
|
||||
|
||||
r, err := h.GetNodeRoutes("test", "testmachine")
|
||||
c.Assert(err, check.IsNil)
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
mathrand "math/rand"
|
||||
|
||||
"golang.org/x/crypto/nacl/box"
|
||||
"tailscale.com/wgengine/wgcfg"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/types/wgkey"
|
||||
)
|
||||
|
||||
// Error is used to compare errors as per https://dave.cheney.net/2016/04/07/constant-errors
|
||||
@@ -26,11 +27,11 @@ type Error string
|
||||
|
||||
func (e Error) Error() string { return string(e) }
|
||||
|
||||
func decode(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) error {
|
||||
func decode(msg []byte, v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) error {
|
||||
return decodeMsg(msg, v, pubKey, privKey)
|
||||
}
|
||||
|
||||
func decodeMsg(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) error {
|
||||
func decodeMsg(msg []byte, v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) error {
|
||||
decrypted, err := decryptMsg(msg, pubKey, privKey)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -42,7 +43,7 @@ func decodeMsg(msg []byte, v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.Priv
|
||||
return nil
|
||||
}
|
||||
|
||||
func decryptMsg(msg []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
|
||||
func decryptMsg(msg []byte, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
|
||||
var nonce [24]byte
|
||||
if len(msg) < len(nonce)+1 {
|
||||
return nil, fmt.Errorf("response missing nonce, len=%d", len(msg))
|
||||
@@ -58,7 +59,7 @@ func decryptMsg(msg []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byt
|
||||
return decrypted, nil
|
||||
}
|
||||
|
||||
func encode(v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
|
||||
func encode(v interface{}, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -66,7 +67,7 @@ func encode(v interface{}, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte
|
||||
return encodeMsg(b, pubKey, privKey)
|
||||
}
|
||||
|
||||
func encodeMsg(b []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte, error) {
|
||||
func encodeMsg(b []byte, pubKey *wgkey.Key, privKey *wgkey.Private) ([]byte, error) {
|
||||
var nonce [24]byte
|
||||
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
|
||||
panic(err)
|
||||
@@ -77,11 +78,6 @@ func encodeMsg(b []byte, pubKey *wgcfg.Key, privKey *wgcfg.PrivateKey) ([]byte,
|
||||
}
|
||||
|
||||
func (h *Headscale) getAvailableIP() (*net.IP, error) {
|
||||
db, err := h.db()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer db.Close()
|
||||
i := 0
|
||||
for {
|
||||
ip, err := getRandomIP()
|
||||
@@ -89,7 +85,7 @@ func (h *Headscale) getAvailableIP() (*net.IP, error) {
|
||||
return nil, err
|
||||
}
|
||||
m := Machine{}
|
||||
if db.First(&m, "ip_address = ?", ip.String()).RecordNotFound() {
|
||||
if result := h.db.First(&m, "ip_address = ?", ip.String()); errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||
return ip, nil
|
||||
}
|
||||
i++
|
||||
|
||||
Reference in New Issue
Block a user