Compare commits

..

1 Commits

Author SHA1 Message Date
braginini
03d0f62ccd Add GroupMinimum to the SetupKey response 2022-09-12 14:30:46 +02:00
15 changed files with 71 additions and 662 deletions

View File

@@ -33,55 +33,3 @@ jobs:
- name: Test
run: GOARCH=${{ matrix.arch }} go test -exec 'sudo --preserve-env=CI' -timeout 5m -p 1 ./...
test_client_on_docker:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@v2
with:
go-version: 1.18.x
- name: Cache Go modules
uses: actions/cache@v2
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Checkout code
uses: actions/checkout@v2
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libappindicator3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev
- name: Install modules
run: go mod tidy
- name: Generate Iface Test bin
run: go test -c -o iface-testing.bin ./iface/...
- name: Generate RouteManager Test bin
run: go test -c -o routemanager-testing.bin ./client/internal/routemanager/...
- name: Generate Engine Test bin
run: go test -c -o engine-testing.bin ./client/internal/*.go
- name: Generate Peer Test bin
run: go test -c -o peer-testing.bin ./client/internal/peer/...
- run: chmod +x *testing.bin
- name: Run Iface tests in docker
run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/iface --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/iface-testing.bin
- name: Run RouteManager tests in docker
run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal/routemanager --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/routemanager-testing.bin
- name: Run Engine tests in docker
run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/engine-testing.bin
- name: Run Peer tests in docker
run: docker run -t --cap-add=NET_ADMIN --privileged --rm -v $PWD:/ci -w /ci/client/internal/peer --entrypoint /busybox/sh gcr.io/distroless/base:debug -c /ci/peer-testing.bin

View File

@@ -107,7 +107,7 @@ func RunClient(ctx context.Context, config *Config, statusRecorder *nbStatus.Sta
localPeerState := nbStatus.LocalPeerState{
IP: loginResp.GetPeerConfig().GetAddress(),
PubKey: myPrivateKey.PublicKey().String(),
KernelInterface: iface.WireguardModuleIsLoaded(),
KernelInterface: iface.WireguardModExists(),
}
statusRecorder.UpdateLocalPeerState(localPeerState)

View File

@@ -84,10 +84,8 @@ func (n *nftablesManager) CleanRoutingRules() {
n.mux.Lock()
defer n.mux.Unlock()
log.Debug("flushing tables")
if n.tableIPv4 != nil && n.tableIPv6 != nil {
n.conn.FlushTable(n.tableIPv6)
n.conn.FlushTable(n.tableIPv4)
}
n.conn.FlushTable(n.tableIPv6)
n.conn.FlushTable(n.tableIPv4)
log.Debugf("flushing tables result in: %v error", n.conn.Flush())
}

View File

@@ -34,7 +34,7 @@ func (w *WGIface) assignAddr() error {
return nil
}
// WireguardModuleIsLoaded check if we can load wireguard mod (linux only)
func WireguardModuleIsLoaded() bool {
// WireguardModExists check if we can load wireguard mod (linux only)
func WireguardModExists() bool {
return false
}

View File

@@ -1,29 +1,48 @@
package iface
import (
"fmt"
"errors"
"math"
"os"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/vishvananda/netlink"
"os"
)
type NativeLink struct {
Link *netlink.Link
}
// WireguardModExists check if we can load wireguard mod (linux only)
func WireguardModExists() bool {
link := newWGLink("mustnotexist")
// We willingly try to create a device with an invalid
// MTU here as the validation of the MTU will be performed after
// the validation of the link kind and hence allows us to check
// for the existance of the wireguard module without actually
// creating a link.
//
// As a side-effect, this will also let the kernel lazy-load
// the wireguard module.
link.attrs.MTU = math.MaxInt
err := netlink.LinkAdd(link)
return errors.Is(err, syscall.EINVAL)
}
// Create creates a new Wireguard interface, sets a given IP and brings it up.
// Will reuse an existing one.
func (w *WGIface) Create() error {
w.mu.Lock()
defer w.mu.Unlock()
if WireguardModuleIsLoaded() {
if WireguardModExists() {
log.Info("using kernel WireGuard")
return w.createWithKernel()
} else {
if !tunModuleIsLoaded() {
return fmt.Errorf("couldn't check or load tun module")
}
log.Info("using userspace WireGuard")
return w.createWithUserspace()
}

View File

@@ -58,7 +58,7 @@ func (w *WGIface) UpdateAddr(newAddr string) error {
return w.assignAddr(luid)
}
// WireguardModuleIsLoaded check if we can load wireguard mod (linux only)
func WireguardModuleIsLoaded() bool {
// WireguardModExists check if we can load wireguard mod (linux only)
func WireguardModExists() bool {
return false
}

View File

@@ -1,349 +0,0 @@
// Package iface provides wireguard network interface creation and management
package iface
import (
"bufio"
"errors"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/vishvananda/netlink"
"golang.org/x/sys/unix"
"io/fs"
"io/ioutil"
"math"
"os"
"path/filepath"
"strings"
"syscall"
)
// Holds logic to check existence of kernel modules used by wireguard interfaces
// Copied from https://github.com/paultag/go-modprobe and
// https://github.com/pmorjan/kmod
type status int
const (
defaultModuleDir = "/lib/modules"
unknown status = iota
unloaded
unloading
loading
live
inuse
)
type module struct {
name string
path string
}
var (
// ErrModuleNotFound is the error resulting if a module can't be found.
ErrModuleNotFound = errors.New("module not found")
moduleLibDir = defaultModuleDir
// get the root directory for the kernel modules. If this line panics,
// it's because getModuleRoot has failed to get the uname of the running
// kernel (likely a non-POSIX system, but maybe a broken kernel?)
moduleRoot = getModuleRoot()
)
// Get the module root (/lib/modules/$(uname -r)/)
func getModuleRoot() string {
uname := unix.Utsname{}
if err := unix.Uname(&uname); err != nil {
panic(err)
}
i := 0
for ; uname.Release[i] != 0; i++ {
}
return filepath.Join(moduleLibDir, string(uname.Release[:i]))
}
// tunModuleIsLoaded check if tun module exist, if is not attempt to load it
func tunModuleIsLoaded() bool {
_, err := os.Stat("/dev/net/tun")
if err == nil {
return true
}
log.Infof("couldn't access device /dev/net/tun, go error %v, "+
"will attempt to load tun module, if running on container add flag --cap-add=NET_ADMIN", err)
tunLoaded, err := tryToLoadModule("tun")
if err != nil {
log.Errorf("unable to find or load tun module, got error: %v", err)
}
return tunLoaded
}
// WireguardModuleIsLoaded check if we can load wireguard mod (linux only)
func WireguardModuleIsLoaded() bool {
if canCreateFakeWireguardInterface() {
return true
}
loaded, err := tryToLoadModule("wireguard")
if err != nil {
log.Info(err)
return false
}
return loaded
}
func canCreateFakeWireguardInterface() bool {
link := newWGLink("mustnotexist")
// We willingly try to create a device with an invalid
// MTU here as the validation of the MTU will be performed after
// the validation of the link kind and hence allows us to check
// for the existance of the wireguard module without actually
// creating a link.
//
// As a side-effect, this will also let the kernel lazy-load
// the wireguard module.
link.attrs.MTU = math.MaxInt
err := netlink.LinkAdd(link)
return errors.Is(err, syscall.EINVAL)
}
func tryToLoadModule(moduleName string) (bool, error) {
if isModuleEnabled(moduleName) {
return true, nil
}
modulePath, err := getModulePath(moduleName)
if err != nil {
return false, fmt.Errorf("couldn't find module path for %s, error: %v", moduleName, err)
}
if modulePath == "" {
return false, nil
}
log.Infof("trying to load %s module", moduleName)
err = loadModuleWithDependencies(moduleName, modulePath)
if err != nil {
return false, fmt.Errorf("couldn't load %s module, error: %v", moduleName, err)
}
return true, nil
}
func isModuleEnabled(name string) bool {
builtin, builtinErr := isBuiltinModule(name)
state, statusErr := moduleStatus(name)
return (builtinErr == nil && builtin) || (statusErr == nil && state >= loading)
}
func getModulePath(name string) (string, error) {
var foundPath string
skipRemainingDirs := false
err := filepath.WalkDir(
moduleRoot,
func(path string, info fs.DirEntry, err error) error {
if skipRemainingDirs {
return fs.SkipDir
}
if err != nil {
// skip broken files
return nil
}
if !info.Type().IsRegular() {
return nil
}
nameFromPath := pathToName(path)
if nameFromPath == name {
foundPath = path
skipRemainingDirs = true
}
return nil
})
if err != nil {
return "", err
}
return foundPath, nil
}
func pathToName(s string) string {
s = filepath.Base(s)
for ext := filepath.Ext(s); ext != ""; ext = filepath.Ext(s) {
s = strings.TrimSuffix(s, ext)
}
return cleanName(s)
}
func cleanName(s string) string {
return strings.ReplaceAll(strings.TrimSpace(s), "-", "_")
}
func isBuiltinModule(name string) (bool, error) {
f, err := os.Open(filepath.Join(moduleRoot, "/modules.builtin"))
if err != nil {
return false, err
}
defer func() {
err := f.Close()
if err != nil {
log.Errorf("failed closing modules.builtin file, %v", err)
}
}()
var found bool
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if pathToName(line) == name {
found = true
break
}
}
if err := scanner.Err(); err != nil {
return false, err
}
return found, nil
}
// /proc/modules
// name | memory size | reference count | references | state: <Live|Loading|Unloading>
// macvlan 28672 1 macvtap, Live 0x0000000000000000
func moduleStatus(name string) (status, error) {
state := unknown
f, err := os.Open("/proc/modules")
if err != nil {
return state, err
}
defer func() {
err := f.Close()
if err != nil {
log.Errorf("failed closing /proc/modules file, %v", err)
}
}()
state = unloaded
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if fields[0] == name {
if fields[2] != "0" {
state = inuse
break
}
switch fields[4] {
case "Live":
state = live
case "Loading":
state = loading
case "Unloading":
state = unloading
}
break
}
}
if err := scanner.Err(); err != nil {
return state, err
}
return state, nil
}
func loadModuleWithDependencies(name, path string) error {
deps, err := getModuleDependencies(name)
if err != nil {
return fmt.Errorf("couldn't load list of module %s dependecies", name)
}
for _, dep := range deps {
err = loadModule(dep.name, dep.path)
if err != nil {
return fmt.Errorf("couldn't load dependecy module %s for %s", dep.name, name)
}
}
return loadModule(name, path)
}
func loadModule(name, path string) error {
state, err := moduleStatus(name)
if err != nil {
return err
}
if state >= loading {
return nil
}
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
err := f.Close()
if err != nil {
log.Errorf("failed closing %s file, %v", path, err)
}
}()
// first try finit_module(2), then init_module(2)
err = unix.FinitModule(int(f.Fd()), "", 0)
if errors.Is(err, unix.ENOSYS) {
buf, err := ioutil.ReadAll(f)
if err != nil {
return err
}
return unix.InitModule(buf, "")
}
return err
}
// getModuleDependencies returns a module dependencies
func getModuleDependencies(name string) ([]module, error) {
f, err := os.Open(filepath.Join(moduleRoot, "/modules.dep"))
if err != nil {
return nil, err
}
defer func() {
err := f.Close()
if err != nil {
log.Errorf("failed closing modules.dep file, %v", err)
}
}()
var deps []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if pathToName(strings.TrimSuffix(fields[0], ":")) == name {
deps = fields
break
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
if len(deps) == 0 {
return nil, ErrModuleNotFound
}
deps[0] = strings.TrimSuffix(deps[0], ":")
var modules []module
for _, v := range deps {
if pathToName(v) != name {
modules = append(modules, module{
name: pathToName(v),
path: filepath.Join(moduleRoot, v),
})
}
}
return modules, nil
}

View File

@@ -1,221 +0,0 @@
package iface
import (
"bufio"
"bytes"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
"io"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
)
func TestGetModuleDependencies(t *testing.T) {
testCases := []struct {
name string
module string
expected []module
}{
{
name: "Get Single Dependency",
module: "bar",
expected: []module{
{name: "foo", path: "kernel/a/foo.ko"},
},
},
{
name: "Get Multiple Dependencies",
module: "baz",
expected: []module{
{name: "foo", path: "kernel/a/foo.ko"},
{name: "bar", path: "kernel/a/bar.ko"},
},
},
{
name: "Get No Dependencies",
module: "foo",
expected: []module{},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
defer resetGlobals()
_, _ = createFiles(t)
modules, err := getModuleDependencies(testCase.module)
require.NoError(t, err)
expected := testCase.expected
for i := range expected {
expected[i].path = moduleRoot + "/" + expected[i].path
}
require.ElementsMatchf(t, modules, expected, "returned modules should match")
})
}
}
func TestIsBuiltinModule(t *testing.T) {
testCases := []struct {
name string
module string
expected bool
}{
{
name: "Built In Should Return True",
module: "foo_bi",
expected: true,
},
{
name: "Not Built In Should Return False",
module: "not_built_in",
expected: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
defer resetGlobals()
_, _ = createFiles(t)
isBuiltIn, err := isBuiltinModule(testCase.module)
require.NoError(t, err)
require.Equal(t, testCase.expected, isBuiltIn)
})
}
}
func TestModuleStatus(t *testing.T) {
random, err := getRandomLoadedModule(t)
if err != nil {
t.Fatal("should be able to get random module")
}
testCases := []struct {
name string
module string
shouldBeLoaded bool
}{
{
name: "Should Return Module Loading Or Greater Status",
module: random,
shouldBeLoaded: true,
},
{
name: "Should Return Module Unloaded Or Lower Status",
module: "not_loaded_module",
shouldBeLoaded: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
defer resetGlobals()
_, _ = createFiles(t)
state, err := moduleStatus(testCase.module)
require.NoError(t, err)
if testCase.shouldBeLoaded {
require.GreaterOrEqual(t, loading, state, "moduleStatus for %s should return state loading", testCase.module)
} else {
require.Less(t, state, loading, "module should return state unloading or lower")
}
})
}
}
func resetGlobals() {
moduleLibDir = defaultModuleDir
moduleRoot = getModuleRoot()
}
func createFiles(t *testing.T) (string, []module) {
writeFile := func(path, text string) {
if err := ioutil.WriteFile(path, []byte(text), 0644); err != nil {
t.Fatal(err)
}
}
var u unix.Utsname
if err := unix.Uname(&u); err != nil {
t.Fatal(err)
}
moduleLibDir = t.TempDir()
moduleRoot = getModuleRoot()
if err := os.Mkdir(moduleRoot, 0755); err != nil {
t.Fatal(err)
}
text := "kernel/a/foo.ko:\n"
text += "kernel/a/bar.ko: kernel/a/foo.ko\n"
text += "kernel/a/baz.ko: kernel/a/bar.ko kernel/a/foo.ko\n"
writeFile(filepath.Join(moduleRoot, "/modules.dep"), text)
text = "kernel/a/foo_bi.ko\n"
text += "kernel/a/bar-bi.ko.gz\n"
writeFile(filepath.Join(moduleRoot, "/modules.builtin"), text)
modules := []module{
{name: "foo", path: "kernel/a/foo.ko"},
{name: "bar", path: "kernel/a/bar.ko"},
{name: "baz", path: "kernel/a/baz.ko"},
}
return moduleLibDir, modules
}
func getRandomLoadedModule(t *testing.T) (string, error) {
f, err := os.Open("/proc/modules")
if err != nil {
return "", err
}
defer func() {
err := f.Close()
if err != nil {
t.Logf("failed closing /proc/modules file, %v", err)
}
}()
lines, err := lineCounter(f)
if err != nil {
return "", err
}
counter := 1
midLine := lines / 2
modName := ""
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if counter == midLine {
if fields[4] == "Unloading" {
continue
}
modName = fields[0]
break
}
counter++
}
if scanner.Err() != nil {
return "", scanner.Err()
}
return modName, nil
}
func lineCounter(r io.Reader) (int, error) {
buf := make([]byte, 32*1024)
count := 0
lineSep := []byte{'\n'}
for {
c, err := r.Read(buf)
count += bytes.Count(buf[:c], lineSep)
switch {
case err == io.EOF:
return count, nil
case err != nil:
return count, err
}
}
}

View File

@@ -138,7 +138,7 @@ components:
description: Setup key groups to auto-assign to peers registered with this key
type: array
items:
type: string
$ref: '#/components/schemas/GroupMinimum'
updated_at:
description: Setup key last update date
type: string

View File

@@ -300,7 +300,7 @@ type RulePatchOperationPath string
// SetupKey defines model for SetupKey.
type SetupKey struct {
// Setup key groups to auto-assign to peers registered with this key
AutoGroups []string `json:"auto_groups"`
AutoGroups []GroupMinimum `json:"auto_groups"`
// Setup Key expiration date
Expires time.Time `json:"expires"`

View File

@@ -344,6 +344,14 @@ func peerIPsToKeys(account *server.Account, peerIPs *[]string) []string {
return mappedPeerKeys
}
func toGroupMinimumResponse(group *server.Group) *api.GroupMinimum {
return &api.GroupMinimum{
Id: group.ID,
Name: group.Name,
PeersCount: len(group.Peers),
}
}
func toGroupResponse(account *server.Account, group *server.Group) *api.Group {
cache := make(map[string]api.PeerMinimum)
gr := api.Group{

View File

@@ -181,9 +181,17 @@ func (h *SetupKeys) GetAllSetupKeysHandler(w http.ResponseWriter, r *http.Reques
http.Redirect(w, r, "/", http.StatusInternalServerError)
return
}
groups, err := h.accountManager.ListGroups(account.Id)
if err != nil {
log.Error(err)
http.Redirect(w, r, "/", http.StatusInternalServerError)
return
}
apiSetupKeys := make([]*api.SetupKey, 0)
for _, key := range setupKeys {
apiSetupKeys = append(apiSetupKeys, toResponseBody(key))
apiSetupKeys = append(apiSetupKeys, toResponseBody(groups, key))
}
writeJSONObject(w, apiSetupKeys)
@@ -192,14 +200,15 @@ func (h *SetupKeys) GetAllSetupKeysHandler(w http.ResponseWriter, r *http.Reques
func writeSuccess(w http.ResponseWriter, key *server.SetupKey) {
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(toResponseBody(key))
err := json.NewEncoder(w).Encode(toResponseBody(nil, key))
if err != nil {
http.Error(w, "failed handling request", http.StatusInternalServerError)
return
}
}
func toResponseBody(key *server.SetupKey) *api.SetupKey {
// toResponseBody takes a list of all groups, a key, finds groups that belong to the key and add them to the response
func toResponseBody(groups []*server.Group, key *server.SetupKey) *api.SetupKey {
var state string
if key.IsExpired() {
state = "expired"
@@ -211,6 +220,17 @@ func toResponseBody(key *server.SetupKey) *api.SetupKey {
state = "valid"
}
// should be instantiated like that to ensure if empty, we return an empty array to the client
autoGroups := []api.GroupMinimum{}
for _, group := range groups {
for _, keyGroup := range key.AutoGroups {
if group.ID == keyGroup {
autoGroups = append(autoGroups, *toGroupMinimumResponse(group))
break
}
}
}
return &api.SetupKey{
Id: key.Id,
Key: key.Key,
@@ -222,7 +242,7 @@ func toResponseBody(key *server.SetupKey) *api.SetupKey {
UsedTimes: key.UsedTimes,
LastUsed: key.LastUsed,
State: state,
AutoGroups: key.AutoGroups,
AutoGroups: autoGroups,
UpdatedAt: key.UpdatedAt,
}
}

View File

@@ -110,7 +110,7 @@ func TestSetupKeysHandlers(t *testing.T) {
requestPath: "/api/setup-keys",
expectedStatus: http.StatusOK,
expectedBody: true,
expectedSetupKeys: []*api.SetupKey{toResponseBody(defaultSetupKey)},
expectedSetupKeys: []*api.SetupKey{toResponseBody(nil, defaultSetupKey)},
},
{
name: "Get Existing Setup Key",
@@ -118,7 +118,7 @@ func TestSetupKeysHandlers(t *testing.T) {
requestPath: "/api/setup-keys/" + existingSetupKeyID,
expectedStatus: http.StatusOK,
expectedBody: true,
expectedSetupKey: toResponseBody(defaultSetupKey),
expectedSetupKey: toResponseBody(nil, defaultSetupKey),
},
{
name: "Get Not Existing Setup Key",
@@ -135,7 +135,7 @@ func TestSetupKeysHandlers(t *testing.T) {
[]byte(fmt.Sprintf("{\"name\":\"%s\",\"type\":\"%s\"}", newSetupKey.Name, newSetupKey.Type))),
expectedStatus: http.StatusOK,
expectedBody: true,
expectedSetupKey: toResponseBody(newSetupKey),
expectedSetupKey: toResponseBody(nil, newSetupKey),
},
{
name: "Update Setup Key",
@@ -149,7 +149,7 @@ func TestSetupKeysHandlers(t *testing.T) {
))),
expectedStatus: http.StatusOK,
expectedBody: true,
expectedSetupKey: toResponseBody(updatedDefaultSetupKey),
expectedSetupKey: toResponseBody(nil, updatedDefaultSetupKey),
},
}

View File

@@ -294,8 +294,6 @@ func (am *DefaultAccountManager) AddPeer(
var account *Account
var err error
var sk *SetupKey
// auto-assign groups that are coming with a SetupKey or a User
var groupsToAdd []string
if len(upperKey) != 0 {
account, err = am.Store.GetAccountBySetupKey(upperKey)
if err != nil {
@@ -323,8 +321,6 @@ func (am *DefaultAccountManager) AddPeer(
)
}
groupsToAdd = sk.AutoGroups
} else if len(userID) != 0 {
account, err = am.Store.GetUserAccount(userID)
if err != nil {
@@ -365,14 +361,6 @@ func (am *DefaultAccountManager) AddPeer(
}
group.Peers = append(group.Peers, newPeer.Key)
if len(groupsToAdd) > 0 {
for _, s := range groupsToAdd {
if g, ok := account.Groups[s]; ok && g.Name != "All" {
g.Peers = append(g.Peers, newPeer.Key)
}
}
}
account.Peers[newPeer.Key] = newPeer
if len(upperKey) != 0 {
account.SetupKeys[sk.Key] = sk.IncrementUsage()

View File

@@ -80,8 +80,6 @@ type SetupKey struct {
// Copy copies SetupKey to a new object
func (key *SetupKey) Copy() *SetupKey {
autoGroups := make([]string, 0)
autoGroups = append(autoGroups, key.AutoGroups...)
if key.UpdatedAt.IsZero() {
key.UpdatedAt = key.CreatedAt
}
@@ -96,7 +94,7 @@ func (key *SetupKey) Copy() *SetupKey {
Revoked: key.Revoked,
UsedTimes: key.UsedTimes,
LastUsed: key.LastUsed,
AutoGroups: autoGroups,
AutoGroups: key.AutoGroups,
}
}