mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-10 01:31:31 +02:00
Compare commits
4 Commits
hardening/
...
ssh-settin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e403567b88 | ||
|
|
1ea9405557 | ||
|
|
c4d01f0c8e | ||
|
|
6495ad8687 |
@@ -92,6 +92,11 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# Names the polkit action for the elevation prompt the app raises when an
|
||||
# unprivileged user changes a privileged setting; without it the dialog
|
||||
# shows a raw command line.
|
||||
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-4-1 (>= 4.14)
|
||||
@@ -115,6 +120,11 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# Names the polkit action for the elevation prompt the app raises when an
|
||||
# unprivileged user changes a privileged setting; without it the dialog
|
||||
# shows a raw command line.
|
||||
- src: client/ui/build/linux/polkit/io.netbird.settings.policy
|
||||
dst: /usr/share/polkit-1/actions/io.netbird.settings.policy
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk4 >= 4.14 or libgtk-4-1 >= 4.14)
|
||||
|
||||
17
client/internal/daemonaddr/identity.go
Normal file
17
client/internal/daemonaddr/identity.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package daemonaddr
|
||||
|
||||
import "strings"
|
||||
|
||||
// CarriesIdentity reports whether the control channel at addr conveys the
|
||||
// connecting process's identity to the daemon. A Unix socket carries peer
|
||||
// credentials and a named pipe carries the client's token. Nothing else does, TCP
|
||||
// included, and there the daemon can authorize a privileged operation for nobody
|
||||
// at all: see ResolveDaemonAddr, which says as much to anyone still reaching the
|
||||
// Windows daemon on the address it served before it had a pipe.
|
||||
//
|
||||
// A client uses this to tell whether becoming privileged would get it anywhere.
|
||||
// It answers from the scheme and nothing else, so an address it does not
|
||||
// recognise counts as carrying no identity.
|
||||
func CarriesIdentity(addr string) bool {
|
||||
return strings.HasPrefix(addr, "unix://") || strings.HasPrefix(addr, pipeScheme)
|
||||
}
|
||||
29
client/internal/daemonaddr/identity_test.go
Normal file
29
client/internal/daemonaddr/identity_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package daemonaddr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCarriesIdentity(t *testing.T) {
|
||||
tests := []struct {
|
||||
addr string
|
||||
want bool
|
||||
}{
|
||||
{"unix:///var/run/netbird.sock", true},
|
||||
{"unix:///var/run/netbird/default.sock", true},
|
||||
{"npipe://netbird", true},
|
||||
{`npipe://\\.\pipe\ProtectedPrefix\Administrators\netbird`, true},
|
||||
{"tcp://127.0.0.1:41731", false},
|
||||
{"tcp://localhost:41731", false},
|
||||
{"", false},
|
||||
{"/var/run/netbird.sock", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.addr, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, CarriesIdentity(tt.addr), "address %q", tt.addr)
|
||||
})
|
||||
}
|
||||
}
|
||||
74
client/internal/elevate/elevate.go
Normal file
74
client/internal/elevate/elevate.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// Package elevate re-runs this very executable under the operating system's own
|
||||
// privilege-elevation mechanism and waits for it to finish.
|
||||
//
|
||||
// It exists so that a change the daemon restricts to root/administrator can be
|
||||
// authorized from the GUI, by the user, at the moment they ask for it: Windows
|
||||
// shows the UAC consent dialog, macOS the system authentication dialog, and
|
||||
// Linux/FreeBSD the session's polkit agent. The credentials, where any are
|
||||
// asked for, are collected by the operating system and never pass through
|
||||
// NetBird.
|
||||
//
|
||||
// What the elevated process then does is the caller's business: it is the same
|
||||
// binary, in a one-shot mode, and it is authorized by the daemon exactly like
|
||||
// any other privileged caller, from the identity the kernel reports on the
|
||||
// control channel. Nothing here grants privilege, and the daemon gains no new
|
||||
// way to be talked into something: elevation only changes who is calling it.
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// AppliedMarker is what the elevated process prints on standard output once it has
|
||||
// done what it was run for.
|
||||
//
|
||||
// macOS's AuthorizationExecuteWithPrivileges reports no exit status and does not
|
||||
// say which process it started, so there this line is the only evidence that the
|
||||
// change was applied. The other platforms have an exit code and ignore it.
|
||||
const AppliedMarker = "netbird-elevated: applied"
|
||||
|
||||
var (
|
||||
// ErrDeclined reports that the user dismissed the prompt or did not
|
||||
// authenticate. Nothing happened and nothing is wrong: a caller undoes its
|
||||
// optimistic update and stays quiet.
|
||||
ErrDeclined = errors.New("authorization declined")
|
||||
|
||||
// ErrUnavailable reports that this host has no elevation mechanism we can
|
||||
// drive: no polkit on a Unix desktop, or an executable we decline to run as
|
||||
// root. A caller falls back to telling the user which command to run.
|
||||
ErrUnavailable = errors.New("no privilege elevation mechanism available")
|
||||
)
|
||||
|
||||
// Run runs this executable with args under the platform's elevation mechanism
|
||||
// and waits for it to exit. A non-zero exit is returned as an error, so the
|
||||
// caller can treat a completed Run as the operation having succeeded.
|
||||
//
|
||||
// The args are the caller's own command line, so they cross no privilege
|
||||
// boundary: only a user who has just authenticated as an administrator can get
|
||||
// them run at all.
|
||||
func Run(ctx context.Context, args ...string) error {
|
||||
self, err := trustedSelf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run(ctx, self, args)
|
||||
}
|
||||
|
||||
// Available reports whether Run has a mechanism to use on this host, so a caller
|
||||
// can offer the prompt only when there is one and otherwise fall back to
|
||||
// guidance the user can act on. It answers from what is installed, not from what
|
||||
// the user is allowed to do: an administrator's password may still be required
|
||||
// and may still not be given, which is ErrDeclined from Run.
|
||||
func Available() bool {
|
||||
if _, err := trustedSelf(); err != nil {
|
||||
// Worth a line: this is also what a build run from a group-writable
|
||||
// directory hits, and there is nothing in the UI to say why the offer is
|
||||
// missing.
|
||||
log.Debugf("not offering privilege elevation: %v", err)
|
||||
return false
|
||||
}
|
||||
return mechanismAvailable()
|
||||
}
|
||||
18
client/internal/elevate/output.go
Normal file
18
client/internal/elevate/output.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package elevate
|
||||
|
||||
import "strings"
|
||||
|
||||
// noOutput stands in for a process that said nothing, so that a report of what it
|
||||
// said still reads as a sentence.
|
||||
const noOutput = "no output"
|
||||
|
||||
func firstLine(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return noOutput
|
||||
}
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
21
client/internal/elevate/output_test.go
Normal file
21
client/internal/elevate/output_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFirstLine(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{in: "", want: noOutput},
|
||||
{in: " \n ", want: noOutput},
|
||||
{in: "one line", want: "one line"},
|
||||
{in: "first\nsecond", want: "first"},
|
||||
{in: "\nsecond\n", want: "second"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, firstLine(tt.in), "input %q", tt.in)
|
||||
}
|
||||
}
|
||||
359
client/internal/elevate/run_darwin.go
Normal file
359
client/internal/elevate/run_darwin.go
Normal file
@@ -0,0 +1,359 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Authorization Services, reached through purego rather than cgo so the released
|
||||
// binaries keep building with CGO_ENABLED=0.
|
||||
//
|
||||
// The prompt belongs to this process, which is what makes it carry the
|
||||
// application's name and our own explanation. Going through osascript instead puts
|
||||
// the very same trampoline behind a dialog attributed to osascript, and means
|
||||
// handing a shell a command line to re-parse.
|
||||
//
|
||||
// # On AuthorizationExecuteWithPrivileges
|
||||
//
|
||||
// It is deprecated, and Apple's guidance (Quinn, "BSD Privilege Escalation on
|
||||
// macOS", developer.apple.com/forums/thread/708765) is "while it still works, it's
|
||||
// been deprecated for many years. Do not use it in a widely distributed product."
|
||||
// It is used here anyway, knowingly, because the alternatives Apple offers are for
|
||||
// *obtaining* ongoing privileges — an installer package, SMAppService, SMJobBless —
|
||||
// and NetBird already has what they would install: a launchd daemon running as
|
||||
// root. What is missing is only a way for an unprivileged client to ask it to act.
|
||||
//
|
||||
// The way to that without a deprecated call is to authorize the client instead of
|
||||
// elevating one: the app takes the right with AuthorizationCreate, passes the
|
||||
// AuthorizationExternalForm to the daemon, and the daemon checks it with
|
||||
// AuthorizationCopyRights before acting — none of which is deprecated. It is the
|
||||
// better design and it is where this should end up. It also means the daemon
|
||||
// accepting an authorization over its control socket, which is a new way to be
|
||||
// asked for privileged work and wants reviewing as such, so it is deliberately not
|
||||
// bundled in with the rest of this.
|
||||
//
|
||||
// Until then, three things keep the deprecation from being a trap. Every symbol is
|
||||
// resolved with an error rather than a panic, so a macOS that has dropped this
|
||||
// function leaves the app offering the user a command instead of crashing on the
|
||||
// way to a prompt. A failure to run the tool is reported as ErrUnavailable, so the
|
||||
// fallback is the same one an agent-less Linux session gets. And the whole path
|
||||
// runs under guard, which turns a panic out of the FFI layer into that same
|
||||
// fallback.
|
||||
//
|
||||
// The trampoline passes on the environment it was given, so what it starts as root
|
||||
// must be an executable this user's peers cannot influence: that is what
|
||||
// trustedSelf refuses, and what signing the binary settles for the loader.
|
||||
|
||||
const (
|
||||
securityFramework = "/System/Library/Frameworks/Security.framework/Security"
|
||||
libSystem = "/usr/lib/libSystem.B.dylib"
|
||||
|
||||
// trampoline is what the framework hands the tool to. Present on every macOS,
|
||||
// and worth confirming before offering a prompt rather than mid-prompt.
|
||||
trampoline = "/usr/libexec/security_authtrampoline"
|
||||
)
|
||||
|
||||
// rightExecute is the right an administrator holds, and what
|
||||
// AuthorizationExecuteWithPrivileges requires of us.
|
||||
const rightExecute = "system.privilege.admin"
|
||||
|
||||
// promptKey is kAuthorizationEnvironmentPrompt, which puts a sentence of ours above
|
||||
// the system's in the dialog. It is about the change rather than the mechanism.
|
||||
const (
|
||||
promptKey = "prompt"
|
||||
promptText = "NetBird needs to change a setting that grants SSH access to this computer."
|
||||
)
|
||||
|
||||
// OSStatus values from SecBase.h that mean something to us; anything else is
|
||||
// reported as it comes.
|
||||
const (
|
||||
errAuthorizationSuccess = 0
|
||||
errAuthorizationDenied = -60005
|
||||
errAuthorizationCanceled = -60006
|
||||
errAuthorizationInteractionNotAllowed = -60007
|
||||
errAuthorizationToolExecuteFailure = -60031
|
||||
errAuthorizationToolEnvironmentError = -60032
|
||||
)
|
||||
|
||||
// AuthorizationFlags from Authorization.h.
|
||||
const (
|
||||
flagDefaults = 0
|
||||
flagInteractionAllowed = 1 << 0
|
||||
flagExtendRights = 1 << 1
|
||||
flagDestroyRights = 1 << 3
|
||||
flagPreAuthorize = 1 << 4
|
||||
)
|
||||
|
||||
// authorizationItem mirrors AuthorizationItem: a name, and a value the name gives
|
||||
// meaning to. 32 bytes on both amd64 and arm64.
|
||||
type authorizationItem struct {
|
||||
name *byte
|
||||
valueLength uintptr
|
||||
value unsafe.Pointer
|
||||
// flags is reserved by the API and always zero. Declared because the layout
|
||||
// is the contract: without it the struct is 24 bytes where C reads 32.
|
||||
flags uint32 //nolint:unused // part of the C layout
|
||||
}
|
||||
|
||||
// authorizationItemSet mirrors AuthorizationItemSet, which serves as both an
|
||||
// AuthorizationRights and an AuthorizationEnvironment.
|
||||
type authorizationItemSet struct {
|
||||
count uint32
|
||||
items *authorizationItem
|
||||
}
|
||||
|
||||
var (
|
||||
authorizationCreate func(rights, environment *authorizationItemSet, flags uint32, authorization *uintptr) int32
|
||||
authorizationExecuteWithPrivileges func(authorization uintptr, pathToTool string, options uint32, arguments *uintptr, communicationsPipe *uintptr) int32
|
||||
authorizationFree func(authorization uintptr, flags uint32) int32
|
||||
fileno func(stream uintptr) int32
|
||||
fclose func(stream uintptr) int32
|
||||
|
||||
loadOnce sync.Once
|
||||
loadErr error
|
||||
)
|
||||
|
||||
// load resolves the functions once. A framework that cannot be opened, or a symbol
|
||||
// that is no longer there, leaves the host without a mechanism rather than taking
|
||||
// the process down with it: see the note on deprecation above.
|
||||
func load() error {
|
||||
loadOnce.Do(func() { loadErr = guard("loading Security.framework", resolve) })
|
||||
return loadErr
|
||||
}
|
||||
|
||||
// guard turns a panic out of the FFI layer into an error, so an API that has
|
||||
// changed under us costs the user a prompt rather than the window they were
|
||||
// clicking in. purego panics on a signature it cannot map, and this is the one
|
||||
// place in the client that calls a deprecated system function.
|
||||
//
|
||||
// It catches Go panics, which is what purego raises. A fault inside the framework
|
||||
// itself is not a panic and not recoverable; the layout the tests pin down is what
|
||||
// stands between us and that.
|
||||
func guard(what string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
log.Errorf("%s panicked: %v", what, r)
|
||||
err = fmt.Errorf("%w: %s: %v", ErrUnavailable, what, r)
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
func resolve() error {
|
||||
security, err := purego.Dlopen(securityFramework, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", securityFramework, err)
|
||||
}
|
||||
system, err := purego.Dlopen(libSystem, purego.RTLD_LAZY|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", libSystem, err)
|
||||
}
|
||||
|
||||
// purego.RegisterLibFunc panics on a symbol it cannot find, which is not how a
|
||||
// deprecated function's disappearance should reach the user.
|
||||
for _, fn := range []struct {
|
||||
ptr any
|
||||
handle uintptr
|
||||
name string
|
||||
}{
|
||||
{&authorizationCreate, security, "AuthorizationCreate"},
|
||||
{&authorizationExecuteWithPrivileges, security, "AuthorizationExecuteWithPrivileges"},
|
||||
{&authorizationFree, security, "AuthorizationFree"},
|
||||
{&fileno, system, "fileno"},
|
||||
{&fclose, system, "fclose"},
|
||||
} {
|
||||
symbol, err := purego.Dlsym(fn.handle, fn.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve %s: %w", fn.name, err)
|
||||
}
|
||||
if symbol == 0 {
|
||||
return fmt.Errorf("resolve %s: not present on this system", fn.name)
|
||||
}
|
||||
purego.RegisterFunc(fn.ptr, symbol)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// run asks the system to run self as root: first for the right, which is what puts
|
||||
// up the authentication dialog and collects the password or takes the Touch ID,
|
||||
// then for the tool. The credentials go to the system's authorization trampoline
|
||||
// and never to us.
|
||||
//
|
||||
// The context bounds only our own waiting; the dialog belongs to the system and
|
||||
// closes when the user answers it.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
if err := load(); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrUnavailable, err)
|
||||
}
|
||||
|
||||
return guard("asking for privileges", func() error {
|
||||
authorization, err := authorize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer authorizationFree(authorization, flagDestroyRights)
|
||||
|
||||
return execute(ctx, authorization, self, args)
|
||||
})
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
if err := load(); err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(trampoline)
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// authorize obtains the right, prompting for it. A dismissed dialog comes back as
|
||||
// errAuthorizationCanceled and a password given up on as errAuthorizationDenied;
|
||||
// both are the user's answer rather than a failure.
|
||||
func authorize() (uintptr, error) {
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||
environment := itemSet(&pinner, promptItem(&pinner))
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, environment,
|
||||
flagDefaults|flagInteractionAllowed|flagPreAuthorize|flagExtendRights, &authorization)
|
||||
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
return authorization, nil
|
||||
case errAuthorizationCanceled, errAuthorizationDenied:
|
||||
return 0, ErrDeclined
|
||||
case errAuthorizationInteractionNotAllowed:
|
||||
// Nowhere to put a dialog, so there is nobody to ask: a launch daemon, or
|
||||
// a session with no window server.
|
||||
return 0, fmt.Errorf("%w: this session cannot show an authorization prompt", ErrUnavailable)
|
||||
default:
|
||||
return 0, fmt.Errorf("request %s: OSStatus %d", rightExecute, status)
|
||||
}
|
||||
}
|
||||
|
||||
// execute runs the tool with the right in hand and waits for it by reading the pipe
|
||||
// it is given until the tool closes it.
|
||||
//
|
||||
// AuthorizationExecuteWithPrivileges reports no exit status and does not say what
|
||||
// process it started, which is why the one-shot says so itself: what it prints is
|
||||
// the only evidence that the change was applied.
|
||||
func execute(ctx context.Context, authorization uintptr, self string, args []string) error {
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
argv := make([]uintptr, 0, len(args)+1)
|
||||
for _, arg := range args {
|
||||
argv = append(argv, uintptr(unsafe.Pointer(cString(&pinner, arg))))
|
||||
}
|
||||
argv = append(argv, 0)
|
||||
pinner.Pin(&argv[0])
|
||||
|
||||
var pipe uintptr
|
||||
status := authorizationExecuteWithPrivileges(authorization, self, flagDefaults, &argv[0], &pipe)
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
case errAuthorizationCanceled:
|
||||
return ErrDeclined
|
||||
case errAuthorizationToolExecuteFailure, errAuthorizationToolEnvironmentError:
|
||||
// The right was granted and the tool still did not start. Nothing the user
|
||||
// can do about it from here, so point them at the command instead.
|
||||
return fmt.Errorf("%w: the system would not run %s elevated (OSStatus %d)", ErrUnavailable, self, status)
|
||||
default:
|
||||
return fmt.Errorf("run %s elevated: OSStatus %d", self, status)
|
||||
}
|
||||
|
||||
out, err := readPipe(ctx, pipe)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkApplied(out)
|
||||
}
|
||||
|
||||
// checkApplied reads the one-shot's report, which stands in for the exit status
|
||||
// there is no way to ask for here. A run that said nothing did not apply the
|
||||
// change, whatever else went on.
|
||||
func checkApplied(out string) error {
|
||||
if !strings.Contains(out, AppliedMarker) {
|
||||
return fmt.Errorf("elevated netbird did not report the change as applied: %s", firstLine(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// readPipe drains the tool's output, which ends when the tool exits and is
|
||||
// therefore also how we wait for it.
|
||||
func readPipe(ctx context.Context, pipe uintptr) (string, error) {
|
||||
if pipe == 0 {
|
||||
return "", nil
|
||||
}
|
||||
defer fclose(pipe)
|
||||
|
||||
fd := int(fileno(pipe))
|
||||
if fd < 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return out.String(), err
|
||||
}
|
||||
n, err := syscall.Read(fd, buf)
|
||||
if n > 0 {
|
||||
out.Write(buf[:n])
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, syscall.EINTR):
|
||||
// A signal landed mid-read, which says nothing about the tool.
|
||||
continue
|
||||
case err != nil:
|
||||
log.Debugf("read the elevated process's output: %v", err)
|
||||
return out.String(), nil
|
||||
case n <= 0:
|
||||
// End of file: the tool closed the pipe, which is how it exiting
|
||||
// reaches us.
|
||||
return out.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// itemSet builds an AuthorizationItemSet over items, pinned for the call.
|
||||
func itemSet(pinner *runtime.Pinner, items ...authorizationItem) *authorizationItemSet {
|
||||
pinner.Pin(&items[0])
|
||||
set := &authorizationItemSet{count: uint32(len(items)), items: &items[0]}
|
||||
pinner.Pin(set)
|
||||
return set
|
||||
}
|
||||
|
||||
// promptItem is the environment entry carrying our sentence for the dialog.
|
||||
func promptItem(pinner *runtime.Pinner) authorizationItem {
|
||||
value := []byte(promptText)
|
||||
pinner.Pin(&value[0])
|
||||
return authorizationItem{
|
||||
name: cString(pinner, promptKey),
|
||||
valueLength: uintptr(len(value)),
|
||||
value: unsafe.Pointer(&value[0]),
|
||||
}
|
||||
}
|
||||
|
||||
// cString returns a NUL-terminated copy of s, pinned so the C side may hold it for
|
||||
// the duration of the call.
|
||||
func cString(pinner *runtime.Pinner, s string) *byte {
|
||||
b := append([]byte(s), 0)
|
||||
pinner.Pin(&b[0])
|
||||
return &b[0]
|
||||
}
|
||||
111
client/internal/elevate/run_darwin_test.go
Normal file
111
client/internal/elevate/run_darwin_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The framework has to load and the symbols have to resolve, or nothing else here
|
||||
// means anything.
|
||||
func TestSecurityFrameworkLoads(t *testing.T) {
|
||||
require.NoError(t, load(), "Security.framework must open")
|
||||
|
||||
for name, fn := range map[string]any{
|
||||
"AuthorizationCreate": authorizationCreate,
|
||||
"AuthorizationExecuteWithPrivileges": authorizationExecuteWithPrivileges,
|
||||
"AuthorizationFree": authorizationFree,
|
||||
"fileno": fileno,
|
||||
"fclose": fclose,
|
||||
} {
|
||||
assert.NotNil(t, fn, "%s must resolve", name)
|
||||
}
|
||||
}
|
||||
|
||||
// A request with no interaction allowed exercises the whole call — the rights and
|
||||
// environment structs, and the OSStatus that comes back — without a dialog anybody
|
||||
// has to answer. What the system decides is its business; that it decides at all is
|
||||
// what this asserts.
|
||||
func TestAuthorizationCreateWithoutInteraction(t *testing.T) {
|
||||
if err := load(); err != nil {
|
||||
t.Skipf("Security.framework did not open: %v", err)
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, rightExecute)})
|
||||
environment := itemSet(&pinner, promptItem(&pinner))
|
||||
require.EqualValues(t, 1, rights.count, "the rights struct layout must match the C one")
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, environment, flagDefaults|flagExtendRights, &authorization)
|
||||
|
||||
switch status {
|
||||
case errAuthorizationSuccess:
|
||||
// Credentials were already cached for this session.
|
||||
authorizationFree(authorization, flagDestroyRights)
|
||||
case errAuthorizationDenied, errAuthorizationInteractionNotAllowed:
|
||||
// The expected answers when nobody may be asked.
|
||||
default:
|
||||
require.Failf(t, "unknown OSStatus", "AuthorizationCreate returned %d, want a status we recognise", status)
|
||||
}
|
||||
}
|
||||
|
||||
// Asking with a right nobody has must not be mistaken for a declined prompt: the
|
||||
// caller would report nothing at all.
|
||||
func TestAuthorizeUnknownRightIsNotDeclined(t *testing.T) {
|
||||
if err := load(); err != nil {
|
||||
t.Skipf("Security.framework did not open: %v", err)
|
||||
}
|
||||
|
||||
var pinner runtime.Pinner
|
||||
defer pinner.Unpin()
|
||||
|
||||
rights := itemSet(&pinner, authorizationItem{name: cString(&pinner, "io.netbird.right.that.does.not.exist")})
|
||||
|
||||
var authorization uintptr
|
||||
status := authorizationCreate(rights, nil, flagDefaults|flagExtendRights, &authorization)
|
||||
if status == errAuthorizationSuccess {
|
||||
authorizationFree(authorization, flagDestroyRights)
|
||||
}
|
||||
assert.NotEqual(t, int32(errAuthorizationSuccess), status, "a right that does not exist must not be granted")
|
||||
}
|
||||
|
||||
func TestMechanismAvailable(t *testing.T) {
|
||||
assert.True(t, mechanismAvailable(), "the trampoline exists on every macOS")
|
||||
}
|
||||
|
||||
// The one-shot's report is what stands in for an exit status here, so a run that
|
||||
// says nothing must not read as success.
|
||||
func TestCheckApplied(t *testing.T) {
|
||||
require.NoError(t, checkApplied(AppliedMarker+"\n"), "the report the one-shot prints")
|
||||
require.NoError(t, checkApplied("some warning\n"+AppliedMarker+"\n"), "the report after other output")
|
||||
|
||||
assert.Error(t, checkApplied(""), "a run that printed nothing did not apply the change")
|
||||
assert.Error(t, checkApplied("dyld: library not loaded\n"), "output that is not the report")
|
||||
}
|
||||
|
||||
// A panic out of the FFI layer has to reach the caller as "no mechanism", which is
|
||||
// the outcome that offers the user the command instead of taking the window down.
|
||||
func TestGuardTurnsAPanicIntoUnavailable(t *testing.T) {
|
||||
err := guard("pretending to call something", func() error {
|
||||
panic("purego: signature it cannot map")
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, ErrUnavailable, "a panic must read as a missing mechanism")
|
||||
assert.Contains(t, err.Error(), "pretending to call something", "what panicked")
|
||||
}
|
||||
|
||||
// guard wraps every darwin path, so what a caller switches on has to survive it.
|
||||
func TestGuardPassesErrorsThrough(t *testing.T) {
|
||||
sentinel := errors.New("the call itself failed")
|
||||
assert.ErrorIs(t, guard("calling", func() error { return sentinel }), sentinel,
|
||||
"the error it was given")
|
||||
assert.ErrorIs(t, guard("calling", func() error { return ErrDeclined }), ErrDeclined,
|
||||
"a declined prompt stays declined")
|
||||
assert.NoError(t, guard("calling", func() error { return nil }), "a call that worked")
|
||||
}
|
||||
117
client/internal/elevate/run_unix.go
Normal file
117
client/internal/elevate/run_unix.go
Normal file
@@ -0,0 +1,117 @@
|
||||
//go:build linux
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// pkexec exit codes that are about the authorization rather than about the program
|
||||
// we asked it to run. The manual page reserves both.
|
||||
const (
|
||||
// exitDismissed is returned when the user dismissed the authentication
|
||||
// dialog.
|
||||
exitDismissed = 126
|
||||
// exitNotAuthorized is returned when the authorization was not obtained. That
|
||||
// covers the user saying no as well as pkexec having had nobody to ask: see
|
||||
// noAgentMarkers.
|
||||
exitNotAuthorized = 127
|
||||
)
|
||||
|
||||
// exitNotAuthorized covers three different endings that only pkexec's own words
|
||||
// tell apart, so they are matched here. Read with LC_ALL=C so the words are the
|
||||
// ones written below.
|
||||
//
|
||||
// refusedMarker is a refusal: the user said no, gave up on the password, or holds
|
||||
// an account that may not elevate at all.
|
||||
const refusedMarker = "Not authorized"
|
||||
|
||||
// noAgentMarkers say pkexec had no way to ask: no agent registered for the
|
||||
// session, and no controlling terminal for the textual agent it falls back to.
|
||||
var noAgentMarkers = []string{"authentication agent", "controlling terminal"}
|
||||
|
||||
// run asks polkit to run self as root. pkexec hands the request to the session's
|
||||
// polkit agent, which is what prompts and what collects any password; we see only
|
||||
// its verdict.
|
||||
//
|
||||
// The environment is otherwise deliberately not passed through: pkexec clears it
|
||||
// bar a small allowlist, and the one-shot needs nothing from it.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
pkexec, err := exec.LookPath("pkexec")
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: pkexec is not installed", ErrUnavailable)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, pkexec, append([]string{self}, args...)...)
|
||||
// C locale so pkexec's own diagnostics are the ones noAgentMarkers knows.
|
||||
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
// The one-shot reports itself on stdout for macOS's sake, where there is no
|
||||
// exit status to read. Here there is one, so that line is noise.
|
||||
cmd.Stdout = io.Discard
|
||||
|
||||
err = cmd.Run()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
return fmt.Errorf("run pkexec: %w", err)
|
||||
}
|
||||
|
||||
// Matched against everything pkexec said, reported as one line: a complaint
|
||||
// that is not the first thing printed still has to be recognised, and reading
|
||||
// it as a refusal would swallow it.
|
||||
full := stderr.String()
|
||||
out := firstLine(full)
|
||||
|
||||
switch exitErr.ExitCode() {
|
||||
case exitDismissed:
|
||||
return ErrDeclined
|
||||
case exitNotAuthorized:
|
||||
return notAuthorized(full, out)
|
||||
default:
|
||||
return fmt.Errorf("elevated netbird exited with %d: %s", exitErr.ExitCode(), out)
|
||||
}
|
||||
}
|
||||
|
||||
// notAuthorized sorts out the three endings pkexec reports as exitNotAuthorized.
|
||||
//
|
||||
// It also returns that code when the authorization succeeded and it then could
|
||||
// not run the program, so a refusal has to be recognised rather than assumed:
|
||||
// reading every one of these as "the user said no" would revert the control in
|
||||
// silence on a host where elevation is broken.
|
||||
func notAuthorized(full, out string) error {
|
||||
switch {
|
||||
case hasAny(full, noAgentMarkers):
|
||||
return fmt.Errorf("%w: polkit had no way to ask: %s", ErrUnavailable, out)
|
||||
case out == noOutput, strings.Contains(full, refusedMarker):
|
||||
// The user said no, which needs no message; that an account barred from
|
||||
// elevating altogether lands here too is why the reason is kept.
|
||||
return fmt.Errorf("%w: %s", ErrDeclined, out)
|
||||
default:
|
||||
return fmt.Errorf("pkexec could not run elevated netbird: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func hasAny(s string, markers []string) bool {
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(s, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
_, err := exec.LookPath("pkexec")
|
||||
return err == nil
|
||||
}
|
||||
110
client/internal/elevate/run_unix_test.go
Normal file
110
client/internal/elevate/run_unix_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
//go:build linux
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakePkexec puts a pkexec on PATH that exits with the given code, so the
|
||||
// mapping from polkit's exit codes onto our errors can be exercised without a
|
||||
// polkit agent.
|
||||
func fakePkexec(t *testing.T, exitCode int, stderr string) {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
script := fmt.Sprintf("#!/bin/sh\necho %s >&2\nexit %d\n", shellQuote(stderr), exitCode)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "pkexec"), []byte(script), 0o700), "write the fake pkexec")
|
||||
t.Setenv("PATH", dir)
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
func TestRunMapsPkexecExitCodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exitCode int
|
||||
stderr string
|
||||
wantErr error
|
||||
}{
|
||||
{name: "applied", exitCode: 0},
|
||||
{
|
||||
name: "dialog dismissed",
|
||||
exitCode: exitDismissed,
|
||||
stderr: "Error executing command as another user: Request dismissed",
|
||||
wantErr: ErrDeclined,
|
||||
},
|
||||
{
|
||||
// What a graphical agent reports for a cancelled prompt. Not a
|
||||
// failure: the user was asked and answered.
|
||||
name: "prompt cancelled",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error executing command as another user: Not authorized",
|
||||
wantErr: ErrDeclined,
|
||||
},
|
||||
{
|
||||
// The same status, but pkexec never got to ask anybody.
|
||||
name: "no agent and no terminal to fall back on",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error creating textual authentication agent: Error opening current controlling terminal for the process (`/dev/tty'): No such device or address",
|
||||
wantErr: ErrUnavailable,
|
||||
},
|
||||
{
|
||||
// And the same status again once the authorization succeeded and
|
||||
// pkexec could not run what it had been authorized to run. Reading
|
||||
// that as a refusal would revert the control in silence on a host
|
||||
// where elevation is broken.
|
||||
name: "authorized but not runnable",
|
||||
exitCode: exitNotAuthorized,
|
||||
stderr: "Error executing command as another user: No such file or directory",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fakePkexec(t, tt.exitCode, tt.stderr)
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", []string{"--flag"})
|
||||
switch {
|
||||
case tt.wantErr != nil:
|
||||
require.ErrorIs(t, err, tt.wantErr, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||
case tt.exitCode == 0:
|
||||
require.NoError(t, err, "a pkexec that exited cleanly applied the change")
|
||||
default:
|
||||
require.Error(t, err, "exit %d said %q", tt.exitCode, tt.stderr)
|
||||
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An exit code that is not polkit's is the one-shot's own failure, and has to
|
||||
// stay distinguishable from a declined prompt: the caller reports it.
|
||||
func TestRunReportsOneShotFailure(t *testing.T) {
|
||||
fakePkexec(t, 3, "the one-shot said no")
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||
|
||||
require.Error(t, err, "a one-shot that failed is not a prompt that was answered")
|
||||
assert.NotErrorIs(t, err, ErrDeclined, "not the user's answer")
|
||||
assert.NotErrorIs(t, err, ErrUnavailable, "not a missing mechanism")
|
||||
}
|
||||
|
||||
func TestRunWithoutPkexecIsUnavailable(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
err := run(context.Background(), "/nonexistent/netbird-ui", nil)
|
||||
require.ErrorIs(t, err, ErrUnavailable, "no pkexec means no mechanism")
|
||||
assert.False(t, mechanismAvailable(), "mechanismAvailable without pkexec on PATH")
|
||||
}
|
||||
19
client/internal/elevate/run_unsupported.go
Normal file
19
client/internal/elevate/run_unsupported.go
Normal file
@@ -0,0 +1,19 @@
|
||||
//go:build !windows && !darwin && !linux
|
||||
|
||||
package elevate
|
||||
|
||||
import "context"
|
||||
|
||||
// run reports that this platform has no elevation prompt to drive.
|
||||
//
|
||||
// The desktop app is the only caller and is not built for any of these: mobile
|
||||
// and WASM have no local user to ask, and the FreeBSD client ships without a UI.
|
||||
// pkexec would be the mechanism there, and run_unix.go is what to widen if that
|
||||
// changes.
|
||||
func run(context.Context, string, []string) error {
|
||||
return ErrUnavailable
|
||||
}
|
||||
|
||||
func mechanismAvailable() bool {
|
||||
return false
|
||||
}
|
||||
193
client/internal/elevate/run_windows.go
Normal file
193
client/internal/elevate/run_windows.go
Normal file
@@ -0,0 +1,193 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// seeMaskNoCloseProcess keeps the started process's handle open in
|
||||
// hProcess so we can wait for it.
|
||||
seeMaskNoCloseProcess = 0x00000040
|
||||
// seeMaskNoAsync makes ShellExecuteExW finish its work before returning,
|
||||
// which it must when the calling thread does not pump messages.
|
||||
seeMaskNoAsync = 0x00000100
|
||||
// seeMaskFlagNoUI suppresses the shell's own error dialogs; the UAC consent
|
||||
// dialog is not one of them and still appears.
|
||||
seeMaskFlagNoUI = 0x00000400
|
||||
|
||||
// swHide: the one-shot has no window to show.
|
||||
swHide = 0
|
||||
|
||||
// sFalse (S_FALSE) answers CoInitializeEx when COM is already up on this
|
||||
// thread in the mode we asked for; rpcChangedMode (RPC_E_CHANGED_MODE) when
|
||||
// it is up in the other one.
|
||||
sFalse = 1
|
||||
rpcChangedMode = 0x80010106
|
||||
)
|
||||
|
||||
// shellExecuteInfoW mirrors SHELLEXECUTEINFOW. The field order and Go's own
|
||||
// padding match the C layout on both 386 and amd64.
|
||||
type shellExecuteInfoW struct {
|
||||
cbSize uint32
|
||||
fMask uint32
|
||||
hwnd windows.HWND
|
||||
lpVerb *uint16
|
||||
lpFile *uint16
|
||||
lpParameters *uint16
|
||||
lpDirectory *uint16
|
||||
nShow int32
|
||||
hInstApp windows.Handle
|
||||
lpIDList uintptr
|
||||
lpClass *uint16
|
||||
hkeyClass windows.Handle
|
||||
dwHotKey uint32
|
||||
hIconOrMonitor windows.Handle
|
||||
hProcess windows.Handle
|
||||
}
|
||||
|
||||
var (
|
||||
shell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
procShellExecuteEx = shell32.NewProc("ShellExecuteExW")
|
||||
)
|
||||
|
||||
// run starts self elevated with the "runas" verb, which is what raises the UAC
|
||||
// consent dialog, and waits for it to finish. Windows decides whether consent is
|
||||
// enough or an administrator's credentials are needed, and collects them itself.
|
||||
func run(ctx context.Context, self string, args []string) error {
|
||||
verb, err := windows.UTF16PtrFromString("runas")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode verb: %w", err)
|
||||
}
|
||||
file, err := windows.UTF16PtrFromString(self)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode %s: %w", self, err)
|
||||
}
|
||||
params, err := windows.UTF16PtrFromString(windows.ComposeCommandLine(args))
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode arguments: %w", err)
|
||||
}
|
||||
|
||||
info := shellExecuteInfoW{
|
||||
fMask: seeMaskNoCloseProcess | seeMaskNoAsync | seeMaskFlagNoUI,
|
||||
hwnd: ownerWindow(),
|
||||
lpVerb: verb,
|
||||
lpFile: file,
|
||||
lpParameters: params,
|
||||
nShow: swHide,
|
||||
}
|
||||
info.cbSize = uint32(unsafe.Sizeof(info))
|
||||
|
||||
process, err := shellExecute(&info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(process); err != nil {
|
||||
log.Debugf("close elevated process handle: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return waitForProcess(ctx, process)
|
||||
}
|
||||
|
||||
// shellExecute performs the call itself. ShellExecuteExW wants COM initialised on
|
||||
// the calling thread, so the goroutine is pinned to one for the duration and COM
|
||||
// is set up on it; an "already initialised, different mode" answer is fine,
|
||||
// because then somebody else has done it for us.
|
||||
func shellExecute(info *shellExecuteInfoW) (windows.Handle, error) {
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
switch err := windows.CoInitializeEx(0, windows.COINIT_APARTMENTTHREADED); {
|
||||
case err == nil, isHResult(err, sFalse):
|
||||
// Ours, or already initialised in the same mode: either way this call
|
||||
// counts and has to be balanced.
|
||||
defer windows.CoUninitialize()
|
||||
case isHResult(err, rpcChangedMode):
|
||||
// The thread is already in the other apartment model. ShellExecuteExW
|
||||
// works there too, and there is nothing of ours to balance.
|
||||
default:
|
||||
return 0, fmt.Errorf("initialise COM: %w", err)
|
||||
}
|
||||
|
||||
ret, _, lastErr := procShellExecuteEx.Call(uintptr(unsafe.Pointer(info)))
|
||||
if ret != 0 {
|
||||
return info.hProcess, nil
|
||||
}
|
||||
|
||||
if errors.Is(lastErr, windows.ERROR_CANCELLED) {
|
||||
return 0, ErrDeclined
|
||||
}
|
||||
return 0, fmt.Errorf("run elevated: %w", lastErr)
|
||||
}
|
||||
|
||||
// ownerWindow returns this process's foreground window, and 0 when the window in
|
||||
// front belongs to somebody else or cannot be attributed. ShellExecuteExW takes it
|
||||
// as the parent for the UI it raises, which is what keeps the consent dialog in
|
||||
// front of the window the user was just clicking in instead of behind it. It is
|
||||
// also what a remote-desktop session needs to place the dialog at all when the
|
||||
// secure desktop is switched off.
|
||||
func ownerWindow() windows.HWND {
|
||||
hwnd := windows.GetForegroundWindow()
|
||||
if hwnd == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var pid uint32
|
||||
if _, err := windows.GetWindowThreadProcessId(hwnd, &pid); err != nil {
|
||||
log.Debugf("cannot attribute the foreground window, raising the prompt without an owner: %v", err)
|
||||
return 0
|
||||
}
|
||||
if pid != windows.GetCurrentProcessId() {
|
||||
return 0
|
||||
}
|
||||
return hwnd
|
||||
}
|
||||
|
||||
// isHResult reports whether err carries the given HRESULT. CoInitializeEx
|
||||
// returns its HRESULT as an Errno, so the comparison is on the raw value.
|
||||
func isHResult(err error, hresult uintptr) bool {
|
||||
var errno windows.Errno
|
||||
return errors.As(err, &errno) && uintptr(errno) == hresult
|
||||
}
|
||||
|
||||
func waitForProcess(ctx context.Context, process windows.Handle) error {
|
||||
// The wait is interruptible so a cancelled context stops us waiting on a
|
||||
// consent dialog nobody is going to answer. The elevated process is not
|
||||
// ours to kill, and it either applies the change or does not.
|
||||
for {
|
||||
event, err := windows.WaitForSingleObject(process, 250)
|
||||
if err != nil {
|
||||
return fmt.Errorf("wait for the elevated process: %w", err)
|
||||
}
|
||||
if event == uint32(windows.WAIT_OBJECT_0) {
|
||||
break
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(process, &code); err != nil {
|
||||
return fmt.Errorf("read the elevated process's exit code: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
return fmt.Errorf("elevated netbird exited with %d", code)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// mechanismAvailable is true on Windows: UAC prompts for consent when the user
|
||||
// is an administrator and for an administrator's credentials when they are not,
|
||||
// so there is always something to ask.
|
||||
func mechanismAvailable() bool {
|
||||
return true
|
||||
}
|
||||
40
client/internal/elevate/trusted.go
Normal file
40
client/internal/elevate/trusted.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// trustedSelf returns the path of this executable, provided it is one we are
|
||||
// willing to have run as root.
|
||||
//
|
||||
// The check is what keeps elevation from becoming a way to launder someone
|
||||
// else's code into a root process: the user consents to NetBird being elevated,
|
||||
// having been shown NetBird's name, so what runs must be the file NetBird was
|
||||
// installed as and not something a third party could have swapped for it. An
|
||||
// executable only its owner can write is that; anything wider is refused, and
|
||||
// the caller falls back to showing the command instead.
|
||||
//
|
||||
// The owner writing to their own executable is not part of that threat: code
|
||||
// running as the user can already prompt them for anything, and could just as
|
||||
// well ask them to run the command by hand. What matters is that no *other*
|
||||
// unprivileged account can reach it.
|
||||
func trustedSelf() (string, error) {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("locate this executable: %w", err)
|
||||
}
|
||||
|
||||
// Resolve symlinks so the checks below apply to the file that would actually
|
||||
// be executed, not to a link somebody else may control.
|
||||
resolved, err := filepath.EvalSymlinks(exe)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve %s: %w", exe, err)
|
||||
}
|
||||
|
||||
if err := checkOnlyOwnerWritable(resolved); err != nil {
|
||||
return "", fmt.Errorf("%w: %s cannot be trusted to run as root: %w", ErrUnavailable, resolved, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
10
client/internal/elevate/trusted_group_darwin.go
Normal file
10
client/internal/elevate/trusted_group_darwin.go
Normal file
@@ -0,0 +1,10 @@
|
||||
package elevate
|
||||
|
||||
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||
// widen who could authorize elevating it.
|
||||
//
|
||||
// macOS installs applications as root:admin, mode 0775, /Applications included,
|
||||
// so requiring owner-only write would reject every normal install. Group admin
|
||||
// (gid 80) is exactly the set of accounts that can answer the authentication
|
||||
// dialog, so its write access grants nothing the prompt would not.
|
||||
var adminWriteGIDs = []uint32{0, 80}
|
||||
9
client/internal/elevate/trusted_group_unix.go
Normal file
9
client/internal/elevate/trusted_group_unix.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows && !darwin
|
||||
|
||||
package elevate
|
||||
|
||||
// adminWriteGIDs are the groups whose write access to an executable does not
|
||||
// widen who could authorize elevating it. Only root's own group qualifies here:
|
||||
// a distribution installs into root-owned directories, and there is no
|
||||
// system-wide administrators group that both writes them and answers polkit.
|
||||
var adminWriteGIDs = []uint32{0}
|
||||
146
client/internal/elevate/trusted_unix.go
Normal file
146
client/internal/elevate/trusted_unix.go
Normal file
@@ -0,0 +1,146 @@
|
||||
//go:build !windows
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// groupFile lists which accounts are in which group, for the membership a user
|
||||
// private group's name does not state: see groupHasOtherMembers.
|
||||
const groupFile = "/etc/group"
|
||||
|
||||
// checkOnlyOwnerWritable reports an error unless path, and every directory leading
|
||||
// to it, is owned by either root or this user and writable by nobody who could not
|
||||
// already act as its owner. A writable directory is as good as a writable file,
|
||||
// since anything in it can be replaced, so the whole chain is checked.
|
||||
func checkOnlyOwnerWritable(path string) error {
|
||||
self := uint32(os.Getuid())
|
||||
|
||||
for dir := path; ; dir = filepath.Dir(dir) {
|
||||
info, err := os.Lstat(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat %s: %w", dir, err)
|
||||
}
|
||||
|
||||
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return errors.New("file ownership is unavailable on this platform")
|
||||
}
|
||||
if stat.Uid != 0 && stat.Uid != self {
|
||||
return fmt.Errorf("%s is owned by uid %d, neither root nor this user", dir, stat.Uid)
|
||||
}
|
||||
|
||||
if err := checkWriteBits(dir, info, stat.Uid, stat.Gid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if parent := filepath.Dir(dir); parent == dir {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func checkWriteBits(path string, info os.FileInfo, uid, gid uint32) error {
|
||||
// On a directory the sticky bit stands in for the write bits: whoever may
|
||||
// write there still cannot replace an entry they do not own, which is the
|
||||
// only thing that would matter to us. /tmp is the usual example.
|
||||
sticky := info.IsDir() && info.Mode()&os.ModeSticky != 0
|
||||
|
||||
return writeBitsAllow(path, info.Mode().Perm(), sticky, groupWriteAllowed(uid, gid))
|
||||
}
|
||||
|
||||
// writeBitsAllow decides on the permission bits alone, given whether the group's
|
||||
// write access has been vouched for.
|
||||
func writeBitsAllow(path string, perm os.FileMode, sticky, groupAllowed bool) error {
|
||||
if sticky {
|
||||
return nil
|
||||
}
|
||||
if perm&0o020 != 0 && !groupAllowed {
|
||||
return fmt.Errorf("%s is writable by a group with members other than its owner (%v)", path, perm)
|
||||
}
|
||||
if perm&0o002 != 0 {
|
||||
return fmt.Errorf("%s is world-writable (%v)", path, perm)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// groupWriteAllowed reports whether a group's write access to a file owned by uid
|
||||
// puts it in reach of anyone who could not already act as that owner.
|
||||
//
|
||||
// Two ways it does not. A group in adminWriteGIDs holds the accounts that can
|
||||
// answer the elevation prompt anyway. And a user private group is how Debian,
|
||||
// Ubuntu and Fedora ship: their umask of 002 makes a home directory and
|
||||
// everything built in it group-writable, so refusing that would refuse every
|
||||
// build not installed from a package.
|
||||
func groupWriteAllowed(uid, gid uint32) bool {
|
||||
if slices.Contains(adminWriteGIDs, gid) {
|
||||
return true
|
||||
}
|
||||
|
||||
group, err := user.LookupGroupId(strconv.FormatUint(uint64(gid), 10))
|
||||
if err != nil {
|
||||
log.Debugf("cannot look up group %d, treating it as shared: %v", gid, err)
|
||||
return false
|
||||
}
|
||||
owner, err := user.LookupId(strconv.FormatUint(uint64(uid), 10))
|
||||
if err != nil {
|
||||
log.Debugf("cannot look up uid %d, treating its group as shared: %v", uid, err)
|
||||
return false
|
||||
}
|
||||
|
||||
if group.Name != owner.Username {
|
||||
return false
|
||||
}
|
||||
return !groupHasOtherMembers(groupFile, group.Name, owner.Username)
|
||||
}
|
||||
|
||||
// groupHasOtherMembers reports whether the group lists a member besides owner.
|
||||
//
|
||||
// Sharing the owner's name is what a user private group is recognised by, and it
|
||||
// says nothing about who is in it: a group that has since gained a member is
|
||||
// still named that way, and that member can write whatever the group can. So the
|
||||
// membership is read rather than assumed. A group this file does not describe,
|
||||
// because it comes from LDAP or another NSS source, cannot be answered here and
|
||||
// leaves the name as the only thing to go on.
|
||||
func groupHasOtherMembers(path, name, owner string) bool {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("cannot read %s for the members of group %q: %v", path, name, err)
|
||||
return false
|
||||
}
|
||||
defer func() {
|
||||
if err := file.Close(); err != nil {
|
||||
log.Debugf("close %s: %v", path, err)
|
||||
}
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
// name:password:gid:member,member
|
||||
fields := strings.Split(scanner.Text(), ":")
|
||||
if len(fields) < 4 || fields[0] != name {
|
||||
continue
|
||||
}
|
||||
for member := range strings.SplitSeq(fields[3], ",") {
|
||||
if member != "" && member != owner {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Debugf("read %s: %v", path, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
177
client/internal/elevate/trusted_unix_test.go
Normal file
177
client/internal/elevate/trusted_unix_test.go
Normal file
@@ -0,0 +1,177 @@
|
||||
//go:build !windows
|
||||
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ownerOnlyDir is t.TempDir() with the write bits tightened. testing creates its
|
||||
// numbered directory with 0777 minus the umask, so under the common 002 umask it
|
||||
// is group-writable and would fail the check under test on its own.
|
||||
func ownerOnlyDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.Chmod(dir, 0o755), "tighten the temporary directory")
|
||||
return dir
|
||||
}
|
||||
|
||||
// writeExecutable creates a plain executable file, the shape trustedSelf checks.
|
||||
func writeExecutable(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "netbird-ui")
|
||||
require.NoError(t, os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755), "write the executable")
|
||||
require.NoError(t, os.Chmod(path, 0o755), "set the executable's mode")
|
||||
return path
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnerOnly(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(writeExecutable(t, ownerOnlyDir(t)))
|
||||
assert.NoError(t, err, "an owner-only writable executable is trustworthy")
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableRejectsWorldWritableFile(t *testing.T) {
|
||||
path := writeExecutable(t, ownerOnlyDir(t))
|
||||
require.NoError(t, os.Chmod(path, 0o777), "make the executable world-writable")
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path), "a world-writable executable must be refused")
|
||||
}
|
||||
|
||||
// The permission policy on its own, without a filesystem to arrange: whether the
|
||||
// group has been vouched for is the only thing that makes group write acceptable.
|
||||
func TestWriteBitsAllow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
perm os.FileMode
|
||||
sticky bool
|
||||
groupAllowed bool
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "owner only", perm: 0o755},
|
||||
{name: "group write in a private group", perm: 0o775, groupAllowed: true},
|
||||
{name: "group write in a shared group", perm: 0o775, wantErr: true},
|
||||
{name: "world write", perm: 0o777, groupAllowed: true, wantErr: true},
|
||||
{name: "world write on a sticky directory", perm: 0o777, sticky: true},
|
||||
{name: "group write on a sticky directory", perm: 0o775, sticky: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := writeBitsAllow("/path", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err, "perm %v, sticky %v, group allowed %v", tt.perm, tt.sticky, tt.groupAllowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A build under a home directory on a distribution with a 002 umask, which is what
|
||||
// a locally built or tarball-installed binary looks like. Its group has no members
|
||||
// but its owner, so it is as good as owner-only.
|
||||
//
|
||||
// Whether this host is such a distribution is read from the environment rather than
|
||||
// from groupWriteAllowed: asking the function under test whether to run would let
|
||||
// it skip its own coverage away if it regressed to refusing everything.
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnPrivateGroup(t *testing.T) {
|
||||
requirePrivatePrimaryGroup(t)
|
||||
|
||||
dir := ownerOnlyDir(t)
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o775), "make the directory group-writable")
|
||||
require.NoError(t, os.Chmod(path, 0o775), "make the executable group-writable")
|
||||
|
||||
err := checkOnlyOwnerWritable(path)
|
||||
assert.NoError(t, err, "group write in the owner's own private group reaches nobody else")
|
||||
}
|
||||
|
||||
// A group that shares its owner's name but has gained another member is no longer
|
||||
// private, and its write access reaches an account that could not elevate.
|
||||
func TestGroupHasOtherMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
entry string
|
||||
want bool
|
||||
}{
|
||||
{name: "no members", entry: "vma:x:1000:"},
|
||||
{name: "only the owner", entry: "vma:x:1000:vma"},
|
||||
{name: "another member", entry: "vma:x:1000:bob", want: true},
|
||||
{name: "the owner and another", entry: "vma:x:1000:vma,bob", want: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
body := "root:x:0:\n" + tt.entry + "\nsudo:x:27:vma\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0o644), "write the group file")
|
||||
|
||||
assert.Equal(t, tt.want, groupHasOtherMembers(path, "vma", "vma"), "entry %q", tt.entry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A group file that says nothing about the group leaves the name as the only thing
|
||||
// to go on, so the private-group allowance stands rather than collapsing on every
|
||||
// host whose groups come from LDAP.
|
||||
func TestGroupHasOtherMembersTolerantOfAnUnknownGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "group")
|
||||
require.NoError(t, os.WriteFile(path, []byte("root:x:0:\n"), 0o644), "write the group file")
|
||||
|
||||
assert.False(t, groupHasOtherMembers(path, "vma", "vma"), "a group the file does not describe")
|
||||
assert.False(t, groupHasOtherMembers(filepath.Join(t.TempDir(), "absent"), "vma", "vma"),
|
||||
"no group file at all")
|
||||
}
|
||||
|
||||
// A writable directory is as good as a writable file: whoever can write the
|
||||
// directory can put a different binary at the same path.
|
||||
func TestCheckOnlyOwnerWritableRejectsWritableDirectory(t *testing.T) {
|
||||
dir := filepath.Join(ownerOnlyDir(t), "bin")
|
||||
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777), "make the directory world-writable")
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path), "an executable in a world-writable directory must be refused")
|
||||
}
|
||||
|
||||
// A sticky world-writable directory is exempt: the sticky bit is what stops one
|
||||
// user replacing another's entries. /tmp is why this matters.
|
||||
func TestCheckOnlyOwnerWritableAcceptsStickyDirectory(t *testing.T) {
|
||||
dir := filepath.Join(ownerOnlyDir(t), "sticky")
|
||||
require.NoError(t, os.Mkdir(dir, 0o755), "create the directory")
|
||||
path := writeExecutable(t, dir)
|
||||
require.NoError(t, os.Chmod(dir, 0o777|os.ModeSticky), "make the directory sticky and world-writable")
|
||||
|
||||
err := checkOnlyOwnerWritable(path)
|
||||
assert.NoError(t, err, "the sticky bit stops another user replacing the executable")
|
||||
}
|
||||
|
||||
func TestCheckOnlyOwnerWritableRejectsMissingFile(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(filepath.Join(ownerOnlyDir(t), "absent"))
|
||||
assert.Error(t, err, "an executable that is not there must be refused")
|
||||
}
|
||||
|
||||
// requirePrivatePrimaryGroup skips unless this user's primary group is their own,
|
||||
// which is what the user-private-group allowance is about.
|
||||
func requirePrivatePrimaryGroup(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
self, err := user.Current()
|
||||
require.NoError(t, err, "look up the test user")
|
||||
group, err := user.LookupGroupId(strconv.Itoa(os.Getgid()))
|
||||
require.NoError(t, err, "look up the test user's primary group")
|
||||
|
||||
if group.Name != self.Username {
|
||||
t.Skipf("the test user's primary group is %q, not their own, so there is nothing to assert here", group.Name)
|
||||
}
|
||||
if groupHasOtherMembers(groupFile, group.Name, self.Username) {
|
||||
t.Skipf("group %q has other members, so it is not a private group", group.Name)
|
||||
}
|
||||
}
|
||||
215
client/internal/elevate/trusted_windows.go
Normal file
215
client/internal/elevate/trusted_windows.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
// fileDeleteChild is FILE_DELETE_CHILD, which x/sys does not define: the
|
||||
// right to delete an entry of a directory without holding DELETE on it.
|
||||
fileDeleteChild = 0x00000040
|
||||
|
||||
// accessAllowedCallbackACEType is an allow ACE with a condition appended to
|
||||
// the ACCESS_ALLOWED_ACE layout, so its trustee is still at SidStart.
|
||||
accessAllowedCallbackACEType = 0x9
|
||||
|
||||
// The allow ACE types that carry object GUIDs ahead of the trustee, so the
|
||||
// SID is not at SidStart. They occur on directory-service objects rather
|
||||
// than files, and are refused rather than skipped: see aceTrustee.
|
||||
accessAllowedObjectACEType = 0x5
|
||||
accessAllowedCallbackObjectACEType = 0xB
|
||||
)
|
||||
|
||||
// fileWriteAccess are the rights that let a trustee rewrite or replace a file,
|
||||
// or take it over and then do so.
|
||||
const fileWriteAccess = windows.FILE_WRITE_DATA | windows.FILE_APPEND_DATA |
|
||||
windows.DELETE | windows.WRITE_DAC | windows.WRITE_OWNER |
|
||||
windows.GENERIC_WRITE | windows.GENERIC_ALL
|
||||
|
||||
// dirWriteAccess are the rights over a directory that let a trustee replace an
|
||||
// entry somebody else owns. Creating a new entry is not one of them, which is
|
||||
// what the Unix sticky bit says in one bit: the root of every volume grants
|
||||
// BUILTIN\Users the right to add directories under it, and that reaches nothing
|
||||
// already there.
|
||||
const dirWriteAccess = fileDeleteChild | windows.DELETE |
|
||||
windows.WRITE_DAC | windows.WRITE_OWNER | windows.GENERIC_ALL
|
||||
|
||||
// trustedInstallerSID owns much of what Windows itself installs. x/sys has no
|
||||
// well-known constant for it.
|
||||
const trustedInstallerSID = "S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464"
|
||||
|
||||
// checkOnlyOwnerWritable reports an error unless path, and every directory
|
||||
// leading to it, is owned by an account that can elevate (or by this user) and
|
||||
// grants write access to nobody else. A writable directory is as good as a
|
||||
// writable file, since an entry in it can be replaced, so the whole chain is
|
||||
// checked.
|
||||
func checkOnlyOwnerWritable(path string) error {
|
||||
owners, err := trustedOwners()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writers, err := trustedWriters(owners)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
writeAccess := windows.ACCESS_MASK(fileWriteAccess)
|
||||
for target := path; ; target = filepath.Dir(target) {
|
||||
if err := checkSecurity(target, writeAccess, owners, writers); err != nil {
|
||||
return err
|
||||
}
|
||||
if parent := filepath.Dir(target); parent == target {
|
||||
return nil
|
||||
}
|
||||
writeAccess = dirWriteAccess
|
||||
}
|
||||
}
|
||||
|
||||
// trustedOwners are the accounts we accept as the owner of the executable and of
|
||||
// the directories above it: the ones that can already answer the UAC prompt,
|
||||
// plus this user, whose own executable is theirs to write. Code running as the
|
||||
// user could prompt them for anything anyway; what matters is that no *other*
|
||||
// unprivileged account can reach it.
|
||||
func trustedOwners() ([]*windows.SID, error) {
|
||||
self, err := currentUserSID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
owners := []*windows.SID{self}
|
||||
for _, wellKnown := range []windows.WELL_KNOWN_SID_TYPE{
|
||||
windows.WinLocalSystemSid,
|
||||
windows.WinBuiltinAdministratorsSid,
|
||||
} {
|
||||
sid, err := windows.CreateWellKnownSid(wellKnown)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build well-known SID %d: %w", wellKnown, err)
|
||||
}
|
||||
owners = append(owners, sid)
|
||||
}
|
||||
|
||||
installer, err := windows.StringToSid(trustedInstallerSID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse TrustedInstaller SID: %w", err)
|
||||
}
|
||||
return append(owners, installer), nil
|
||||
}
|
||||
|
||||
// trustedWriters are the trustees whose write access does not widen who could
|
||||
// decide what runs behind the prompt. The owners, and CREATOR OWNER, which
|
||||
// resolves to the object's owner and is therefore already vetted.
|
||||
func trustedWriters(owners []*windows.SID) ([]*windows.SID, error) {
|
||||
creatorOwner, err := windows.CreateWellKnownSid(windows.WinCreatorOwnerSid)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build the CREATOR OWNER SID: %w", err)
|
||||
}
|
||||
return append(slices.Clone(owners), creatorOwner), nil
|
||||
}
|
||||
|
||||
func checkSecurity(path string, writeAccess windows.ACCESS_MASK, owners, writers []*windows.SID) error {
|
||||
sd, err := windows.GetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.OWNER_SECURITY_INFORMATION|windows.DACL_SECURITY_INFORMATION)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read security descriptor of %s: %w", path, err)
|
||||
}
|
||||
|
||||
owner, _, err := sd.Owner()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read owner of %s: %w", path, err)
|
||||
}
|
||||
if !containsSID(owners, owner) {
|
||||
return fmt.Errorf("%s is owned by %s, which is neither this user nor an account that can elevate", path, owner)
|
||||
}
|
||||
|
||||
dacl, _, err := sd.DACL()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read DACL of %s: %w", path, err)
|
||||
}
|
||||
// A NULL DACL grants everyone everything; only an absent security
|
||||
// descriptor would have got us here without one, and neither is trustworthy.
|
||||
if dacl == nil {
|
||||
return fmt.Errorf("%s has no DACL, so it grants write access to everyone", path)
|
||||
}
|
||||
|
||||
return checkDACL(path, dacl, writeAccess, writers)
|
||||
}
|
||||
|
||||
// checkDACL refuses an ACL that grants write access to a trustee outside
|
||||
// writers.
|
||||
//
|
||||
// An allowlist, because the trustees that must not have it cannot be listed: an
|
||||
// ACE naming an ordinary user account hands that account the same power as one
|
||||
// naming Everyone, and only the accounts that may hold it are knowable.
|
||||
func checkDACL(path string, dacl *windows.ACL, writeAccess windows.ACCESS_MASK, writers []*windows.SID) error {
|
||||
for i := uint32(0); i < uint32(dacl.AceCount); i++ {
|
||||
var ace *windows.ACCESS_ALLOWED_ACE
|
||||
if err := windows.GetAce(dacl, i, &ace); err != nil {
|
||||
return fmt.Errorf("read ACE %d of %s: %w", i, path, err)
|
||||
}
|
||||
// An inherit-only ACE says what children of this object get, not what
|
||||
// this object grants.
|
||||
if ace.Header.AceFlags&windows.INHERIT_ONLY_ACE != 0 {
|
||||
continue
|
||||
}
|
||||
if ace.Mask&writeAccess == 0 {
|
||||
continue
|
||||
}
|
||||
// Only an allow ACE grants anything; a deny ACE narrows what one gave.
|
||||
if !isAllowACE(ace.Header.AceType) {
|
||||
continue
|
||||
}
|
||||
|
||||
trustee, err := aceTrustee(ace)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read the trustee of ACE %d of %s: %w", i, path, err)
|
||||
}
|
||||
if !containsSID(writers, trustee) {
|
||||
return fmt.Errorf("%s grants write access to %s", path, trustee)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isAllowACE reports whether an ACE type grants rights, rather than denying,
|
||||
// auditing or labelling them.
|
||||
func isAllowACE(aceType uint8) bool {
|
||||
switch aceType {
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType,
|
||||
accessAllowedObjectACEType, accessAllowedCallbackObjectACEType:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// aceTrustee returns who an allow ACE grants its rights to. An ACE whose trustee
|
||||
// cannot be located is an error rather than something to skip past: being unable
|
||||
// to read who is being given write access is a refusal.
|
||||
func aceTrustee(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) {
|
||||
switch ace.Header.AceType {
|
||||
case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackACEType:
|
||||
//nolint:gosec // SidStart is the first uint32 of the variable-length SID that follows the ACE header.
|
||||
return (*windows.SID)(unsafe.Pointer(&ace.SidStart)), nil
|
||||
default:
|
||||
return nil, errors.New("an object-type allow ACE does not carry its trustee where we can read it")
|
||||
}
|
||||
}
|
||||
|
||||
func containsSID(sids []*windows.SID, sid *windows.SID) bool {
|
||||
return slices.ContainsFunc(sids, sid.Equals)
|
||||
}
|
||||
|
||||
func currentUserSID() (*windows.SID, error) {
|
||||
token := windows.GetCurrentProcessToken()
|
||||
user, err := token.GetTokenUser()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read this process's user: %w", err)
|
||||
}
|
||||
return user.User.Sid, nil
|
||||
}
|
||||
126
client/internal/elevate/trusted_windows_test.go
Normal file
126
client/internal/elevate/trusted_windows_test.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package elevate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// A file the test user created under their own profile, which is what a per-user
|
||||
// install looks like. The whole chain up to the volume root is walked, so this is
|
||||
// also what says the walk does not refuse an ordinary Windows installation: the
|
||||
// root of every volume grants BUILTIN\Users rights that are not ours to worry
|
||||
// about.
|
||||
func TestCheckOnlyOwnerWritableAcceptsOwnFile(t *testing.T) {
|
||||
err := checkOnlyOwnerWritable(writeExecutable(t))
|
||||
assert.NoError(t, err, "a file the test user owns, under directories only administrators can write")
|
||||
}
|
||||
|
||||
// Write access held by an account that cannot answer the UAC prompt means that
|
||||
// account decides what runs behind it, whoever the ACE names. The trustees that
|
||||
// must not have it cannot be listed, so the check names the ones that may.
|
||||
func TestCheckOnlyOwnerWritableRejectsUntrustedWriters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wellKnown windows.WELL_KNOWN_SID_TYPE
|
||||
}{
|
||||
{name: "everyone", wellKnown: windows.WinWorldSid},
|
||||
{name: "authenticated users", wellKnown: windows.WinAuthenticatedUserSid},
|
||||
{name: "builtin users", wellKnown: windows.WinBuiltinUsersSid},
|
||||
// A service account, which no denylist of the obvious groups would name
|
||||
// and which cannot elevate any more than Everyone can.
|
||||
{name: "local service", wellKnown: windows.WinLocalServiceSid},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
path := writeExecutable(t)
|
||||
grantWrite(t, path, tt.wellKnown)
|
||||
|
||||
assert.Error(t, checkOnlyOwnerWritable(path),
|
||||
"write access for %s must be refused", tt.name)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The masks are the policy: on a file any write reaches its contents, while on a
|
||||
// directory only deleting or taking over an entry reaches something already
|
||||
// there. Adding an entry does not, which is why the walk survives a volume root.
|
||||
func TestWriteAccessMasks(t *testing.T) {
|
||||
assert.NotZero(t, fileWriteAccess&windows.FILE_WRITE_DATA, "writing a file's data reaches its contents")
|
||||
assert.NotZero(t, fileWriteAccess&windows.FILE_APPEND_DATA, "appending to a file reaches its contents")
|
||||
|
||||
assert.Zero(t, dirWriteAccess&windows.FILE_WRITE_DATA, "adding a file to a directory replaces nothing")
|
||||
assert.Zero(t, dirWriteAccess&windows.FILE_APPEND_DATA, "adding a subdirectory replaces nothing")
|
||||
assert.NotZero(t, dirWriteAccess&fileDeleteChild, "deleting an entry replaces it")
|
||||
assert.NotZero(t, dirWriteAccess&windows.DELETE, "deleting the directory takes its entries with it")
|
||||
}
|
||||
|
||||
func TestIsAllowACE(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
aceType uint8
|
||||
want bool
|
||||
}{
|
||||
{name: "allowed", aceType: windows.ACCESS_ALLOWED_ACE_TYPE, want: true},
|
||||
{name: "allowed callback", aceType: accessAllowedCallbackACEType, want: true},
|
||||
{name: "allowed object", aceType: accessAllowedObjectACEType, want: true},
|
||||
{name: "allowed callback object", aceType: accessAllowedCallbackObjectACEType, want: true},
|
||||
{name: "denied", aceType: windows.ACCESS_DENIED_ACE_TYPE},
|
||||
// SYSTEM_AUDIT_ACE_TYPE, which x/sys does not define: an ACE that records
|
||||
// access rather than granting it.
|
||||
{name: "audit", aceType: 0x2},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, isAllowACE(tt.aceType), "ACE type %#x", tt.aceType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// writeExecutable creates a plain file under the test's own directory, the shape
|
||||
// trustedSelf checks.
|
||||
func writeExecutable(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "netbird-ui.exe")
|
||||
require.NoError(t, os.WriteFile(path, []byte("MZ"), 0o755), "write the executable")
|
||||
return path
|
||||
}
|
||||
|
||||
// grantWrite replaces the file's DACL with one that grants a well-known trustee
|
||||
// everything, keeping the test user's own access so the file stays deletable.
|
||||
func grantWrite(t *testing.T, path string, wellKnown windows.WELL_KNOWN_SID_TYPE) {
|
||||
t.Helper()
|
||||
|
||||
trustee, err := windows.CreateWellKnownSid(wellKnown)
|
||||
require.NoError(t, err, "build the trustee SID")
|
||||
self, err := currentUserSID()
|
||||
require.NoError(t, err, "read the test user's SID")
|
||||
|
||||
acl, err := windows.ACLFromEntries([]windows.EXPLICIT_ACCESS{
|
||||
fullControl(self, windows.TRUSTEE_IS_USER),
|
||||
fullControl(trustee, windows.TRUSTEE_IS_WELL_KNOWN_GROUP),
|
||||
}, nil)
|
||||
require.NoError(t, err, "build the ACL")
|
||||
|
||||
require.NoError(t, windows.SetNamedSecurityInfo(path, windows.SE_FILE_OBJECT,
|
||||
windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION,
|
||||
nil, nil, acl, nil), "set the DACL")
|
||||
}
|
||||
|
||||
func fullControl(sid *windows.SID, trusteeType uint32) windows.EXPLICIT_ACCESS {
|
||||
return windows.EXPLICIT_ACCESS{
|
||||
AccessPermissions: windows.GENERIC_ALL,
|
||||
AccessMode: windows.GRANT_ACCESS,
|
||||
Trustee: windows.TRUSTEE{
|
||||
TrusteeForm: windows.TRUSTEE_IS_SID,
|
||||
TrusteeType: windows.TRUSTEE_TYPE(trusteeType),
|
||||
TrusteeValue: windows.TrusteeValueFromSID(sid),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,12 @@ func SelfDelegatesTo() (Identity, bool) {
|
||||
return selfIdentity, true
|
||||
}
|
||||
|
||||
// The values PrivilegedActorKey returns.
|
||||
const (
|
||||
ActorKeyAdministrator = "administrator"
|
||||
ActorKeyRoot = "root"
|
||||
)
|
||||
|
||||
// PrivilegedActor names the principal a privileged operation requires, for use
|
||||
// in messages shown to the user.
|
||||
func PrivilegedActor() string {
|
||||
@@ -100,6 +106,16 @@ func PrivilegedActor() string {
|
||||
return "root"
|
||||
}
|
||||
|
||||
// PrivilegedActorKey identifies that principal without wording it, for a client
|
||||
// that writes its own message in the user's language. The words PrivilegedActor
|
||||
// returns are English, and a translated sentence cannot borrow them.
|
||||
func PrivilegedActorKey() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return ActorKeyAdministrator
|
||||
}
|
||||
return ActorKeyRoot
|
||||
}
|
||||
|
||||
// ElevatedCommand renders a command so that running it grants the privileges the
|
||||
// operation needs. Windows has no in-line equivalent of sudo, so the command is
|
||||
// returned unchanged and the user is expected to run it from an elevated
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=netbird-ui
|
||||
Name=NetBird
|
||||
Comment=NetBird desktop client
|
||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
||||
Icon=netbird-ui
|
||||
Categories=Development;
|
||||
Categories=Utility;Network;
|
||||
Terminal=false
|
||||
Keywords=wails
|
||||
Keywords=netbird;vpn;wireguard;
|
||||
Version=1.0
|
||||
StartupNotify=false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Name=Netbird
|
||||
Name=NetBird
|
||||
Comment=NetBird desktop client
|
||||
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||
Icon=netbird
|
||||
Type=Application
|
||||
|
||||
@@ -21,8 +21,17 @@ contents:
|
||||
dst: "/usr/local/bin/netbird-ui"
|
||||
- src: "./build/appicon.png"
|
||||
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
|
||||
# The name the polkit action's icon_name refers to, which the released packages
|
||||
# install as /usr/share/pixmaps/netbird.png.
|
||||
- src: "./build/appicon.png"
|
||||
dst: "/usr/share/icons/hicolor/128x128/apps/netbird.png"
|
||||
- src: "./build/linux/netbird-ui.desktop"
|
||||
dst: "/usr/share/applications/netbird-ui.desktop"
|
||||
# Names the polkit action for the elevation prompt the app raises when an
|
||||
# unprivileged user changes a privileged setting; without it the dialog shows a
|
||||
# raw command line.
|
||||
- src: "./build/linux/polkit/io.netbird.settings.policy"
|
||||
dst: "/usr/share/polkit-1/actions/io.netbird.settings.policy"
|
||||
|
||||
# Default dependencies for the GTK4 + WebKitGTK 6.0 stack (Ubuntu 24.04+ / Debian 13+)
|
||||
depends:
|
||||
|
||||
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
47
client/ui/build/linux/polkit/io.netbird.settings.policy
Normal file
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
|
||||
<!--
|
||||
Names the action behind the elevation prompt the desktop app raises for an SSH
|
||||
setting the daemon restricts to root; without it pkexec's generic dialog offers
|
||||
the raw command line instead. The argv1 annotation keeps this wording to the
|
||||
one-shot mode that applies those settings.
|
||||
|
||||
auth_admin rather than auth_admin_keep: each of these settings is its own grant
|
||||
of shell access, so a credential cache would let a second, unasked-for change
|
||||
ride along on the authorization given the first.
|
||||
|
||||
exec.path takes no wildcard and the binary's location depends on the package,
|
||||
hence one action per path.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>NetBird</vendor>
|
||||
<vendor_url>https://netbird.io</vendor_url>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
|
||||
<action id="io.netbird.settings.apply-privileged-local">
|
||||
<description>Change privileged NetBird settings</description>
|
||||
<message>Authentication is required to change NetBird settings that grant SSH access to this computer.</message>
|
||||
<icon_name>netbird</icon_name>
|
||||
<defaults>
|
||||
<allow_any>auth_admin</allow_any>
|
||||
<allow_inactive>auth_admin</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/local/bin/netbird-ui</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">--apply-privileged-settings</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -22,12 +22,18 @@ const logSaveError = (err: unknown) => console.error("[SettingsContext] save fai
|
||||
|
||||
export type AutostartState = { supported: boolean; enabled: boolean };
|
||||
|
||||
// GuardedField is a setting the daemon only accepts from root/administrator.
|
||||
// Turning one on goes through saveGuardedField, which asks the operating system
|
||||
// for the privileges rather than sending a request that would be refused.
|
||||
export type GuardedField = "serverSshAllowed" | "enableSshRoot" | "disableSshAuth";
|
||||
|
||||
type SettingsContextValue = {
|
||||
config: Config;
|
||||
guiVersion: string;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>, opts?: { preSharedKey?: string }) => Promise<void>;
|
||||
saveGuardedField: (k: GuardedField, v: boolean) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -63,6 +69,12 @@ const useSettingsState = () => {
|
||||
const [guiVersion, setGuiVersion] = useState<string>("—");
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const loadedRef = useRef<LoadedConfig | null>(null);
|
||||
// Set when the daemon's config changed while a save was pending, so the read
|
||||
// that was skipped to protect the pending edit happens once it is through.
|
||||
// Without it the form keeps values the daemon no longer has and the next save
|
||||
// submits them, which for a guarded setting means asking the user to authorize
|
||||
// a change they never made.
|
||||
const reloadOwed = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadedRef.current = loaded;
|
||||
@@ -73,6 +85,7 @@ const useSettingsState = () => {
|
||||
// update the daemon then rejected.
|
||||
const reload = useCallback(
|
||||
async (profileName: string) => {
|
||||
reloadOwed.current = false;
|
||||
try {
|
||||
const data = await SettingsSvc.GetConfig({ profileName, username });
|
||||
setLoaded({ profileName, data });
|
||||
@@ -94,7 +107,12 @@ const useSettingsState = () => {
|
||||
username,
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (saveTimer.current) return;
|
||||
// A pending edit outranks the daemon's copy until it is saved, so
|
||||
// the read is owed rather than dropped: see reloadOwed.
|
||||
if (saveTimer.current) {
|
||||
reloadOwed.current = true;
|
||||
return;
|
||||
}
|
||||
setLoaded({ profileName: activeProfileId, data });
|
||||
} catch (e) {
|
||||
if (cancelled || !showError) return;
|
||||
@@ -141,12 +159,17 @@ const useSettingsState = () => {
|
||||
async (profileName: string, next: Config, preSharedKey?: string) => {
|
||||
const preSharedKeyWrite = preSharedKey === undefined ? {} : { preSharedKey };
|
||||
try {
|
||||
await SettingsSvc.SetConfig({
|
||||
const { declined } = await SettingsSvc.SetConfig({
|
||||
...next,
|
||||
...preSharedKeyWrite,
|
||||
profileName,
|
||||
username,
|
||||
});
|
||||
// The change needed authorization and the user said no, so the
|
||||
// optimistic update is wrong. Nothing to report: they know.
|
||||
if (declined || reloadOwed.current) {
|
||||
await reload(profileName);
|
||||
}
|
||||
} catch (e) {
|
||||
// The optimistic update is wrong now: the daemon refused it
|
||||
// (a change that needs elevated privileges, an MDM-managed
|
||||
@@ -206,6 +229,59 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
// saveGuardedField applies a setting the daemon restricts to
|
||||
// root/administrator by having the Go side run the app again under the
|
||||
// platform's elevation prompt (UAC, the macOS authentication dialog, polkit).
|
||||
// The prompt is the user's, so the call is made straight from their gesture
|
||||
// and never from the debounce.
|
||||
const saveGuardedField = useCallback(
|
||||
async (k: GuardedField, v: boolean) => {
|
||||
const cur = loadedRef.current;
|
||||
if (!cur) return;
|
||||
|
||||
// Flush what the debounce still owes, before the optimistic update
|
||||
// below joins it: a later save carrying the guarded value would be
|
||||
// refused, and its error dialog would be the second one for a change
|
||||
// the user already authorized.
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
await save(cur.profileName, cur.data);
|
||||
}
|
||||
|
||||
const next: LoadedConfig = {
|
||||
profileName: cur.profileName,
|
||||
data: { ...cur.data, [k]: v },
|
||||
};
|
||||
loadedRef.current = next;
|
||||
setLoaded(next);
|
||||
|
||||
try {
|
||||
await SettingsSvc.SetGuardedSettings({
|
||||
profileName: cur.profileName,
|
||||
username,
|
||||
[k]: v,
|
||||
});
|
||||
} catch (e) {
|
||||
// The daemon is authoritative either way, so re-read before
|
||||
// reporting. A declined prompt is not an error and does not come
|
||||
// through here at all; this is a prompt that could not be raised,
|
||||
// which carries the command that would have done it.
|
||||
await reload(cur.profileName);
|
||||
await errorDialog({
|
||||
Title: i18next.t("settings.error.saveTitle"),
|
||||
Message: errorMessage(e),
|
||||
Command: errorCommand(e),
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Either the change went through or the user declined it. The daemon
|
||||
// says which.
|
||||
await reload(cur.profileName);
|
||||
},
|
||||
[username, save, reload],
|
||||
);
|
||||
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>, opts?: { preSharedKey?: string }) => {
|
||||
if (!loaded) return;
|
||||
@@ -225,15 +301,27 @@ const useSettingsState = () => {
|
||||
[loaded, save],
|
||||
);
|
||||
|
||||
return { config: loaded?.data ?? null, guiVersion, setField, saveField, saveFields, saveNow };
|
||||
return {
|
||||
config: loaded?.data ?? null,
|
||||
guiVersion,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveGuardedField,
|
||||
saveNow,
|
||||
};
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveNow } = useSettingsState();
|
||||
const { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
const value = useMemo<SettingsContextValue | null>(
|
||||
() => (config ? { config, guiVersion, setField, saveField, saveFields, saveNow } : null),
|
||||
[config, guiVersion, setField, saveField, saveFields, saveNow],
|
||||
() =>
|
||||
config
|
||||
? { config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow }
|
||||
: null,
|
||||
[config, guiVersion, setField, saveField, saveFields, saveGuardedField, saveNow],
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Settings as SettingsSvc } from "@bindings/services";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import { type Privilege } from "@bindings/services/models.js";
|
||||
|
||||
// usePrivilege reports whether this UI process may perform the changes the daemon
|
||||
// restricts to root/administrator. It is answered in-process from our own token
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { type TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CopyToClipboard } from "@/components/CopyToClipboard";
|
||||
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
|
||||
@@ -6,51 +7,91 @@ import { Input } from "@/components/inputs/Input";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { type GuardedField, useSettings } from "@/contexts/SettingsContext.tsx";
|
||||
import { usePrivilege } from "@/hooks/usePrivilege.ts";
|
||||
import { Privilege } from "@bindings/services/models.js";
|
||||
import type { Privilege } from "@bindings/services/models.js";
|
||||
import { type ChangeEvent, type ReactNode, useEffect, useId, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { t } = useTranslation();
|
||||
const { config, setField } = useSettings();
|
||||
const { config, setField, saveGuardedField } = useSettings();
|
||||
const privilege = usePrivilege();
|
||||
// The field whose elevation prompt is currently up, if any. The prompt is
|
||||
// modal to the operating system, not to us, so the guarded controls are held
|
||||
// still meanwhile rather than allowed to stack a second one behind it.
|
||||
const [authorizing, setAuthorizing] = useState<GuardedField | null>(null);
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
|
||||
const authorize = async (field: GuardedField, value: boolean) => {
|
||||
setAuthorizing(field);
|
||||
try {
|
||||
await saveGuardedField(field, value);
|
||||
} finally {
|
||||
setAuthorizing(null);
|
||||
}
|
||||
};
|
||||
|
||||
// The daemon restricts only the direction that hands out shells from a process
|
||||
// running as root. So for an unprivileged user a guarded control is either
|
||||
// unavailable (it is off and only they could turn it on) or a one-way switch
|
||||
// (it is on, they may turn it off, but not back on) — say which, either way.
|
||||
// running as root: for all three settings that is switching the field on.
|
||||
//
|
||||
// An unprivileged user gets that direction routed through the platform's
|
||||
// elevation prompt where there is one to raise, and otherwise the old
|
||||
// arrangement, where the control is either unavailable (it is off and only a
|
||||
// privileged caller could turn it on) or a one-way switch (it is on, they may
|
||||
// turn it off but not back on) with the command that does it.
|
||||
//
|
||||
// A null privilege means we could not determine it: leave the control alone
|
||||
// rather than greying it out with nothing to explain why. The daemon enforces
|
||||
// this regardless, and a rejected save reports its own guidance.
|
||||
const guarded = (
|
||||
guardedDirectionActive: boolean,
|
||||
field: GuardedField,
|
||||
command: (p: Privilege) => string,
|
||||
// inverted marks a control whose guarded direction is switching it off, so
|
||||
// the one-way warning has to read the other way round.
|
||||
inverted = false,
|
||||
) => {
|
||||
const plain = (value: boolean) => setField(field, value);
|
||||
if (!privilege || privilege.privileged) {
|
||||
return { disabled: false, hint: undefined };
|
||||
return { apply: plain, disabled: false, hint: undefined };
|
||||
}
|
||||
const hint = (
|
||||
<PrivilegeHint
|
||||
actor={privilege.actor}
|
||||
command={command(privilege)}
|
||||
|
||||
const guardedDirectionActive = config[field];
|
||||
const hint = (pending: boolean, command?: string) => (
|
||||
<GuardedHint
|
||||
actor={actorLabel(privilege, t)}
|
||||
oneWay={guardedDirectionActive}
|
||||
inverted={inverted}
|
||||
pending={pending}
|
||||
command={command}
|
||||
/>
|
||||
);
|
||||
return { disabled: !guardedDirectionActive, hint };
|
||||
|
||||
if (privilege.canElevate) {
|
||||
return {
|
||||
// Switching off is ours to do; only switching on is authorized.
|
||||
apply: (value: boolean) => {
|
||||
if (!value) {
|
||||
plain(value);
|
||||
return;
|
||||
}
|
||||
void authorize(field, value);
|
||||
},
|
||||
disabled: authorizing !== null,
|
||||
hint: hint(authorizing === field),
|
||||
};
|
||||
}
|
||||
return {
|
||||
apply: plain,
|
||||
disabled: !guardedDirectionActive,
|
||||
hint: hint(false, command(privilege)),
|
||||
};
|
||||
};
|
||||
|
||||
const sshServer = guarded(config.serverSshAllowed, (p) => p.allowSshServer);
|
||||
const sshRoot = guarded(config.enableSshRoot, (p) => p.enableSshRoot);
|
||||
const sshServer = guarded("serverSshAllowed", (p) => p.allowSshServer);
|
||||
const sshRoot = guarded("enableSshRoot", (p) => p.enableSshRoot);
|
||||
// Inverted control: the guarded direction is switching authentication off, so
|
||||
// it is the already-disabled state that is the one-way one.
|
||||
const sshAuth = guarded(config.disableSshAuth, (p) => p.disableSshAuth, true);
|
||||
const sshAuth = guarded("disableSshAuth", (p) => p.disableSshAuth, true);
|
||||
const jwtTtlId = useId();
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
@@ -84,7 +125,7 @@ export function SettingsSSH() {
|
||||
<SectionGroup title={t("settings.ssh.section.server")}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
onChange={sshServer.apply}
|
||||
disabled={sshServer.disabled}
|
||||
label={t("settings.ssh.server.label")}
|
||||
helpText={t("settings.ssh.server.help")}
|
||||
@@ -98,7 +139,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
onChange={sshRoot.apply}
|
||||
disabled={sshRoot.disabled}
|
||||
label={t("settings.ssh.root.label")}
|
||||
helpText={t("settings.ssh.root.help")}
|
||||
@@ -130,7 +171,7 @@ export function SettingsSSH() {
|
||||
>
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
onChange={(v) => sshAuth.apply(!v)}
|
||||
disabled={sshAuth.disabled}
|
||||
label={t("settings.ssh.jwt.label")}
|
||||
helpText={t("settings.ssh.jwt.help")}
|
||||
@@ -163,41 +204,81 @@ export function SettingsSSH() {
|
||||
);
|
||||
}
|
||||
|
||||
// PrivilegeHint explains what an unprivileged user can and cannot do with a
|
||||
// guarded control, and offers the command that does it with the privileges the
|
||||
// daemon requires. oneWay covers the control being in the guarded state already:
|
||||
// switching it back is the part that needs privileges.
|
||||
function PrivilegeHint({
|
||||
// actorLabel names the principal the daemon requires, in the user's language. The
|
||||
// Go side reports which one it is rather than wording it, because "administrator
|
||||
// privileges" is English and a translated sentence cannot borrow it.
|
||||
function actorLabel(privilege: Privilege, t: TFunction): string {
|
||||
return privilege.actorKey === "administrator"
|
||||
? t("settings.ssh.privilege.actorAdministrator")
|
||||
: t("settings.ssh.privilege.actorRoot");
|
||||
}
|
||||
|
||||
// GuardedHint is what a control the daemon guards says to an unprivileged user.
|
||||
// There are three things worth saying, and it says at most one:
|
||||
//
|
||||
// - A prompt is open. Worth a line because it can take a few seconds to appear,
|
||||
// long enough that a control which merely went inert would read as a hang.
|
||||
// - The setting is in its guarded state already (oneWay), so the user may switch
|
||||
// it back as they please and it is switching it away again that will ask. No
|
||||
// command either way: the direction they can take is theirs to take.
|
||||
// - Only a privileged caller can move it at all, and there is no prompt to
|
||||
// raise: the command that does it belongs here, and nothing else will do.
|
||||
//
|
||||
// Which leaves the case of a control whose guarded direction is still ahead of the
|
||||
// user and a prompt that can be raised for it: nothing to say, because clicking it
|
||||
// raises the prompt and the prompt explains itself.
|
||||
function GuardedHint({
|
||||
actor,
|
||||
command,
|
||||
oneWay,
|
||||
inverted,
|
||||
pending,
|
||||
command,
|
||||
}: {
|
||||
actor: string;
|
||||
command: string;
|
||||
oneWay: boolean;
|
||||
inverted: boolean;
|
||||
pending: boolean;
|
||||
command?: string;
|
||||
}): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (pending) {
|
||||
return <HintBox>{t("settings.ssh.privilege.authorizePending")}</HintBox>;
|
||||
}
|
||||
if (oneWay) {
|
||||
return (
|
||||
<HintBox>
|
||||
<span>
|
||||
{inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
if (!command) return null;
|
||||
return (
|
||||
<HintBox>
|
||||
<span>{t("settings.ssh.privilege.hint", { actor })}</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
</HintBox>
|
||||
);
|
||||
}
|
||||
|
||||
// HintBox is the box a guarded control puts its explanation in, directly under the
|
||||
// control it belongs to.
|
||||
function HintBox({ children }: { children: ReactNode }): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"-mt-2 flex flex-col gap-1 rounded-md bg-nb-gray-930 px-3 py-2 text-xs text-nb-gray-300"
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{!oneWay
|
||||
? t("settings.ssh.privilege.hint", { actor })
|
||||
: inverted
|
||||
? t("settings.ssh.privilege.oneWayInverted", { actor })
|
||||
: t("settings.ssh.privilege.oneWay", { actor })}
|
||||
</span>
|
||||
<CopyToClipboard message={command} alwaysShowIcon wrap variant={"bright"}>
|
||||
<code className={"select-text break-all font-mono text-xs text-nb-gray-200"}>
|
||||
{command}
|
||||
</code>
|
||||
</CopyToClipboard>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Alle sichtbaren Ressourcen umschalten"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Einstellungsbereiche"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Zu Profil \"{name}\" wechseln?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Debug-Paket fehlgeschlagen"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Einstellungsbereiche"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Allgemein"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Vorgang fehlgeschlagen."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird konnte auf diesem System nicht die nötigen Rechte anfordern. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Die Änderung konnte mit erhöhten Rechten nicht angewendet werden. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root-Rechte"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "Administratorrechte"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Erfordert {actor}. Führen Sie stattdessen dies aus:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Sie können dies deaktivieren, zum erneuten Aktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Sie können dies aktivieren, zum erneuten Deaktivieren sind {actor} erforderlich."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Warten auf Autorisierung…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1775,16 +1775,36 @@
|
||||
"message": "Operation failed.",
|
||||
"description": "Generic fallback error message used when no specific error applies."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird could not ask this system for the privileges the change needs. Run this instead:",
|
||||
"description": "Error: this computer has no way to prompt for elevated privileges. Followed by a copyable command that applies the setting from a terminal."
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "The change could not be applied with elevated privileges. Run this instead:",
|
||||
"description": "Error: the authorization succeeded but applying the setting afterwards failed. Followed by a copyable command that applies the setting from a terminal."
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root",
|
||||
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Linux, macOS and BSD, where the daemon requires the root account. 'root' is an account name and stays as it is; add the word for privileges or rights around it if the sentence needs one to read naturally."
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "administrator privileges",
|
||||
"description": "Fills {actor} in the settings.ssh.privilege.* messages on Windows, where the daemon requires an elevated administrator. The Windows term for the rights an account is asked to elevate to."
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requires {actor}. Run this instead:",
|
||||
"description": "Help text under an SSH setting the user cannot change: it needs elevated privileges. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "You can switch this off, but switching it back on needs {actor}:",
|
||||
"description": "Warning under an SSH setting an unprivileged user may disable but not re-enable. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"message": "You can switch this off, but switching it back on needs {actor}.",
|
||||
"description": "Help text under an SSH setting that is already on: an unprivileged user may switch it off freely, and switching it on again is what needs the privileges. No command follows, since the direction they can take is theirs to take. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "You can switch this on, but switching it back off needs {actor}:",
|
||||
"description": "Warning under the SSH authentication setting, which an unprivileged user may re-enable but not disable again. {actor} is 'root' on Linux/macOS or 'administrator privileges' on Windows. Followed by a copyable command."
|
||||
"message": "You can switch this on, but switching it back off needs {actor}.",
|
||||
"description": "Same as settings.ssh.privilege.oneWay, for the SSH authentication setting once it has been switched off: switching it off again is what needs the privileges."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Waiting for authorization…",
|
||||
"description": "Replaces the help text under a guarded SSH setting while the authorization prompt is open, which can take a few seconds to appear. Keep the trailing ellipsis."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Conmutar todos los recursos visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Secciones de configuración"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "¿Cambiar el perfil a «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Error en el paquete de diagnóstico"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Secciones de configuración"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "General"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "La operación falló."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird no pudo solicitar a este sistema los privilegios necesarios. Ejecute esto en su lugar:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "No se pudo aplicar el cambio con privilegios elevados. Ejecute esto en su lugar:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "privilegios de root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "privilegios de administrador"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requiere {actor}. Ejecute esto en su lugar:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Puede desactivarlo, pero volver a activarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Puede activarlo, pero volver a desactivarlo requiere {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Esperando la autorización…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Activer/désactiver toutes les ressources visibles"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sections des paramètres"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Basculer vers le profil « {name} » ?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Échec du lot de diagnostic"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sections des paramètres"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Général"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "L’opération a échoué."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird n’a pas pu demander à ce système les privilèges nécessaires. Exécutez plutôt ceci :"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "La modification n’a pas pu être appliquée avec des privilèges élevés. Exécutez plutôt ceci :"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "les privilèges root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "les privilèges administrateur"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Nécessite {actor}. Exécutez plutôt ceci :"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Vous pouvez le désactiver, mais le réactiver nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Vous pouvez l’activer, mais le désactiver de nouveau nécessite {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "En attente de l’autorisation…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Összes látható erőforrás be/ki"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Beállítások szakaszai"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Váltás a(z) \"{name}\" profilra?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Hibakeresési csomag sikertelen"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Beállítások szakaszai"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Általános"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A művelet meghiúsult."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "A NetBird nem tudta bekérni a rendszertől a szükséges jogosultságokat. Futtassa inkább ezt:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "A módosítást emelt szintű jogosultságokkal sem sikerült alkalmazni. Futtassa inkább ezt:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root jogosultság"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "rendszergazdai jogosultság"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor} szükséges hozzá. Futtassa inkább ezt:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Kikapcsolhatja, de a visszakapcsolásához {actor} szükséges."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Bekapcsolhatja, de az ismételt kikapcsolásához {actor} szükséges."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Várakozás az engedélyezésre…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Attiva/disattiva tutte le risorse visibili"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sezioni delle impostazioni"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Passare al profilo «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Pacchetto di debug non riuscito"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Sezioni delle impostazioni"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Generale"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Operazione non riuscita."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird non ha potuto richiedere a questo sistema i privilegi necessari. Esegua invece questo:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Non è stato possibile applicare la modifica con privilegi elevati. Esegua invece questo:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "i privilegi di root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "i privilegi di amministratore"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Richiede {actor}. Esegua invece questo:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Può disabilitarlo, ma riabilitarlo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Può abilitarlo, ma disabilitarlo di nuovo richiede {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "In attesa dell'autorizzazione…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1304,6 +1304,9 @@
|
||||
"daemon.outdated.description": {
|
||||
"message": "このアプリを使用するには NetBird サービスを更新してください。"
|
||||
},
|
||||
"daemon.outdated.download": {
|
||||
"message": "最新版をダウンロード"
|
||||
},
|
||||
"error.jwt_clock_skew": {
|
||||
"message": "サインインに失敗しました: このデバイスの時計がサーバーと同期していません。システムの時計を同期してからもう一度お試しください。"
|
||||
},
|
||||
@@ -1327,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作に失敗しました。"
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird はこのシステムに必要な権限を要求できませんでした。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "昇格した権限でも変更を適用できませんでした。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root 権限"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "管理者権限"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "{actor}が必要です。代わりに次のコマンドを実行してください:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "無効にはできますが、再度有効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "有効にはできますが、再度無効にするには{actor}が必要です。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "承認を待っています…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Alternar todos os recursos visíveis"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Seções das configurações"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Alternar perfil para \"{name}\"?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Falha no pacote de depuração"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Seções das configurações"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Geral"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "A operação falhou."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "O NetBird não conseguiu solicitar a este sistema os privilégios necessários. Execute isto em vez disso:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Não foi possível aplicar a alteração com privilégios elevados. Execute isto em vez disso:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "privilégios de root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "privilégios de administrador"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Requer {actor}. Execute isto em vez disso:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Você pode desativar isto, mas ativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Você pode ativar isto, mas desativar novamente requer {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Aguardando a autorização…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "Переключить все видимые ресурсы"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Разделы настроек"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "Переключиться на профиль «{name}»?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "Не удалось создать отладочный пакет"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "Разделы настроек"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "Общие"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "Не удалось выполнить операцию."
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird не смог запросить у этой системы нужные права. Выполните вместо этого:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "Не удалось применить изменение с повышенными правами. Выполните вместо этого:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "права root"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "права администратора"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "Требуются {actor}. Выполните вместо этого:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "Отключить можно, но чтобы включить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Включить можно, но чтобы отключить снова, нужны {actor}."
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Ожидание авторизации…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,9 +401,6 @@
|
||||
"networks.bulk.label": {
|
||||
"message": "切换所有可见资源"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "设置部分"
|
||||
},
|
||||
"profile.switch.title": {
|
||||
"message": "切换到配置文件“{name}”?"
|
||||
},
|
||||
@@ -497,6 +494,9 @@
|
||||
"settings.error.debugBundleTitle": {
|
||||
"message": "创建调试包失败"
|
||||
},
|
||||
"settings.nav.label": {
|
||||
"message": "设置部分"
|
||||
},
|
||||
"settings.tabs.general": {
|
||||
"message": "常规"
|
||||
},
|
||||
@@ -1330,5 +1330,29 @@
|
||||
},
|
||||
"error.unknown": {
|
||||
"message": "操作失败。"
|
||||
},
|
||||
"error.elevation_unavailable": {
|
||||
"message": "NetBird 无法向此系统请求所需的权限。请改为运行:"
|
||||
},
|
||||
"error.elevation_failed": {
|
||||
"message": "即使使用提升的权限也无法应用此更改。请改为运行:"
|
||||
},
|
||||
"settings.ssh.privilege.actorRoot": {
|
||||
"message": "root 权限"
|
||||
},
|
||||
"settings.ssh.privilege.actorAdministrator": {
|
||||
"message": "管理员权限"
|
||||
},
|
||||
"settings.ssh.privilege.hint": {
|
||||
"message": "需要{actor}。请改为运行:"
|
||||
},
|
||||
"settings.ssh.privilege.oneWay": {
|
||||
"message": "您可以关闭此项,但重新开启需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "您可以开启此项,但再次关闭需要{actor}。"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "正在等待授权…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
@@ -79,6 +80,14 @@ func init() {
|
||||
}
|
||||
|
||||
func main() {
|
||||
// The one-shot that applies the settings the daemon restricts to
|
||||
// root/administrator, which this binary runs itself as under the platform's
|
||||
// elevation prompt. Handled before anything GUI so no window, tray or
|
||||
// single-instance lock is involved.
|
||||
if services.IsPrivilegedSettingsRun(os.Args[1:]) {
|
||||
os.Exit(runPrivilegedSettings(os.Args[1:]))
|
||||
}
|
||||
|
||||
daemonAddr, userSetLogFile := parseFlagsAndInitLog()
|
||||
conn := NewConn(daemonAddr)
|
||||
|
||||
|
||||
27
client/ui/privileged_settings.go
Normal file
27
client/ui/privileged_settings.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/ui/services"
|
||||
)
|
||||
|
||||
// The one-shot mode this binary runs itself in, elevated, to apply the settings the
|
||||
// daemon restricts to root/administrator. It is handled before anything GUI, so no
|
||||
// window, tray or single-instance lock is involved.
|
||||
//
|
||||
// Only the wiring is here: what the mode accepts and does lives beside the code
|
||||
// that asks for it, in services.RunPrivilegedSettings, so the settings it will
|
||||
// apply are declared once. There is nothing privileged about the mode itself; it
|
||||
// sends the same request the frontend would have sent, and the daemon authorizes it
|
||||
// from the identity the kernel reports on the control channel exactly as it does
|
||||
// for `sudo netbird up`.
|
||||
func runPrivilegedSettings(args []string) int {
|
||||
return services.RunPrivilegedSettings(args, func(addr string) (proto.DaemonServiceClient, error) {
|
||||
if addr == "" {
|
||||
addr = DaemonAddr()
|
||||
}
|
||||
return NewConn(addr).Client()
|
||||
})
|
||||
}
|
||||
231
client/ui/services/guarded.go
Normal file
231
client/ui/services/guarded.go
Normal file
@@ -0,0 +1,231 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
)
|
||||
|
||||
// The command line of the one-shot mode this binary runs itself in, elevated, to
|
||||
// apply a setting the daemon restricts to root/administrator. The setting flags
|
||||
// spell the same words as `netbird up`, so the command a user is shown and what
|
||||
// runs behind the prompt read alike. Parsed in oneshot.go.
|
||||
const (
|
||||
FlagApplyPrivilegedSettings = "apply-privileged-settings"
|
||||
FlagDaemonAddr = "daemon-addr"
|
||||
FlagProfile = "profile"
|
||||
FlagUser = "user"
|
||||
FlagLogLevel = "log-level"
|
||||
FlagManagementURL = "management-url"
|
||||
FlagAllowServerSSH = "allow-server-ssh"
|
||||
FlagEnableSSHRoot = "enable-ssh-root"
|
||||
FlagDisableSSHAuth = "disable-ssh-auth"
|
||||
)
|
||||
|
||||
// Error codes for the ways asking for privileges can fail.
|
||||
const (
|
||||
CodeElevationUnavailable = "elevation_unavailable"
|
||||
CodeElevationFailed = "elevation_failed"
|
||||
)
|
||||
|
||||
// elevationTimeout bounds the wait for a prompt and the change behind it, so a
|
||||
// dialog nobody answers does not leave its control disabled for the session. Long
|
||||
// enough to find a password manager, and no shorter than the platforms' own prompt
|
||||
// timeouts: Windows gives up on its consent dialog after two minutes by itself.
|
||||
//
|
||||
// It always ends our waiting, and not always the prompt: Security.framework offers
|
||||
// no way to withdraw a request, so on macOS the system's own timeout is what closes
|
||||
// the dialog.
|
||||
const elevationTimeout = 5 * time.Minute
|
||||
|
||||
// elevator raises the platform's privilege prompt and runs the change behind it.
|
||||
// An interface so tests can answer without a prompt.
|
||||
type elevator interface {
|
||||
// Run runs this binary again, elevated, with the given arguments.
|
||||
Run(ctx context.Context, args ...string) error
|
||||
// Available reports whether there is a prompt to raise on this host at all.
|
||||
Available() bool
|
||||
}
|
||||
|
||||
// osElevator is the real thing: see the elevate package.
|
||||
type osElevator struct{}
|
||||
|
||||
func (osElevator) Run(ctx context.Context, args ...string) error {
|
||||
return elevate.Run(ctx, args...)
|
||||
}
|
||||
|
||||
func (osElevator) Available() bool {
|
||||
return elevate.Available()
|
||||
}
|
||||
|
||||
// SaveOutcome reports what became of a change that needed authorization.
|
||||
//
|
||||
// A declined prompt is a result, not an error: the user was asked and said no, so
|
||||
// nothing was applied and nothing went wrong. Reporting it as an error would have
|
||||
// every cancelled prompt logged as one.
|
||||
type SaveOutcome struct {
|
||||
// Declined is set when the user dismissed the authorization prompt, or was
|
||||
// refused by policy. Nothing was changed.
|
||||
Declined bool `json:"declined"`
|
||||
}
|
||||
|
||||
// GuardedSettings is the subset of the config the daemon restricts to
|
||||
// root/administrator. Only the fields that are set are changed: a nil pointer, or
|
||||
// an empty management URL, leaves that setting alone.
|
||||
//
|
||||
// The management URL is in here because pointing a host with the SSH server
|
||||
// running at another management identity hands the decision of who may open a
|
||||
// shell on it to whoever runs that server, which is the same power as enabling
|
||||
// the SSH server in the first place.
|
||||
type GuardedSettings struct {
|
||||
ProfileName string `json:"profileName"`
|
||||
Username string `json:"username"`
|
||||
ManagementURL string `json:"managementUrl,omitempty"`
|
||||
ServerSSHAllowed *bool `json:"serverSshAllowed,omitempty"`
|
||||
EnableSSHRoot *bool `json:"enableSshRoot,omitempty"`
|
||||
DisableSSHAuth *bool `json:"disableSshAuth,omitempty"`
|
||||
}
|
||||
|
||||
// guardedSetting is one setting to change, in the two spellings this needs: the
|
||||
// one-shot's own flag, and the `netbird up` flag that does the same thing from a
|
||||
// terminal, for when there is no prompt to raise.
|
||||
type guardedSetting struct {
|
||||
arg string
|
||||
flag string
|
||||
}
|
||||
|
||||
// SetGuardedSettings applies settings the daemon refuses from an unprivileged
|
||||
// caller, by having the operating system run this binary again, elevated, to send
|
||||
// the same request the frontend would have sent itself.
|
||||
//
|
||||
// The user authorizes it at the platform's own prompt: the UAC consent dialog,
|
||||
// the macOS authentication dialog, or the polkit agent's. Any credentials are the
|
||||
// operating system's business; NetBird neither sees nor asks for them. Nothing
|
||||
// about the daemon's rules changes, and the elevated process is authorized like
|
||||
// any other privileged caller, from the identity the kernel reports for it.
|
||||
//
|
||||
// A declined prompt comes back as SaveOutcome.Declined with no error. When there is
|
||||
// no prompt to raise, or the elevated run failed, the error carries the command
|
||||
// that does the same thing from a terminal.
|
||||
func (s *Settings) SetGuardedSettings(ctx context.Context, p GuardedSettings) (SaveOutcome, error) {
|
||||
settings := guardedSettings(p)
|
||||
if len(settings) == 0 {
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationFailed,
|
||||
Short: "no setting to apply",
|
||||
Long: "no setting to apply",
|
||||
}
|
||||
}
|
||||
|
||||
// The elevated run has no window and, on Linux, an environment pkexec has
|
||||
// cleared, so what it writes to stderr is all there is to go on. It follows
|
||||
// this process's level so that starting the app with --log-level debug says
|
||||
// something about the run behind the prompt too.
|
||||
args := append([]string{
|
||||
"--" + FlagApplyPrivilegedSettings,
|
||||
"--" + FlagDaemonAddr, s.daemonAddr,
|
||||
"--" + FlagProfile, p.ProfileName,
|
||||
"--" + FlagUser, p.Username,
|
||||
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||
}, oneShotArgs(settings)...)
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, elevationTimeout)
|
||||
defer cancel()
|
||||
|
||||
// These changes hand out shells on this host, so both ends are logged: when the
|
||||
// prompt went up, and what came of it. It is also the only account of a prompt
|
||||
// that was slow to appear or never answered.
|
||||
log.Infof("asking for privileges to apply %s", guardedSummary(p))
|
||||
|
||||
if err := s.elevator.Run(ctx, args...); err != nil {
|
||||
return s.elevationOutcome(err, p)
|
||||
}
|
||||
|
||||
log.Infof("applied %s with the privileges the user authorized", guardedSummary(p))
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// elevationOutcome sorts what came back into the one normal ending and the two
|
||||
// that need reporting, with the command that does the same thing by hand.
|
||||
func (s *Settings) elevationOutcome(err error, p GuardedSettings) (SaveOutcome, error) {
|
||||
switch {
|
||||
case errors.Is(err, elevate.ErrDeclined):
|
||||
// With the reason: an account that may not elevate at all lands here too,
|
||||
// and the log is the only place that says which it was.
|
||||
log.Infof("the elevation prompt for %s was declined: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{Declined: true}, nil
|
||||
case errors.Is(err, elevate.ErrUnavailable):
|
||||
log.Warnf("cannot ask for privileges to apply %s: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationUnavailable,
|
||||
Short: s.classifier.translateShort(CodeElevationUnavailable),
|
||||
Long: err.Error(),
|
||||
Command: guardedCommand(p),
|
||||
}
|
||||
default:
|
||||
log.Errorf("applying %s with elevated privileges failed: %v", guardedSummary(p), err)
|
||||
return SaveOutcome{}, &ClientError{
|
||||
Code: CodeElevationFailed,
|
||||
Short: s.classifier.translateShort(CodeElevationFailed),
|
||||
Long: err.Error(),
|
||||
Command: guardedCommand(p),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// guardedSettings renders the settings that are actually being changed, from the
|
||||
// same table the one-shot parses them with: see oneshot.go.
|
||||
func guardedSettings(p GuardedSettings) []guardedSetting {
|
||||
var settings []guardedSetting
|
||||
for _, field := range guardedFields {
|
||||
value, ok := field.read(p)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
settings = append(settings, guardedSetting{
|
||||
arg: "--" + field.flag + "=" + value,
|
||||
flag: field.up(value),
|
||||
})
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
func oneShotArgs(settings []guardedSetting) []string {
|
||||
args := make([]string, 0, len(settings))
|
||||
for _, setting := range settings {
|
||||
args = append(args, setting.arg)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func upFlags(settings []guardedSetting) []string {
|
||||
flags := make([]string, 0, len(settings))
|
||||
for _, setting := range settings {
|
||||
flags = append(flags, setting.flag)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
// guardedCommand is the elevated command line equivalent to the requested
|
||||
// change, the same shape the daemon names in its own refusals.
|
||||
func guardedCommand(p GuardedSettings) string {
|
||||
settings := guardedSettings(p)
|
||||
if len(settings) == 0 {
|
||||
return ""
|
||||
}
|
||||
return ipcauth.UpCommand(strings.Join(upFlags(settings), " "))
|
||||
}
|
||||
|
||||
// guardedSummary names the change for the log.
|
||||
func guardedSummary(p GuardedSettings) string {
|
||||
return fmt.Sprintf("%v for profile %q", oneShotArgs(guardedSettings(p)), p.ProfileName)
|
||||
}
|
||||
355
client/ui/services/guarded_test.go
Normal file
355
client/ui/services/guarded_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/ipcauth"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
// A Unix socket, so the daemon address is one that carries a caller's identity and
|
||||
// elevation is worth offering at all: see Settings.canElevate.
|
||||
const testDaemonAddr = "unix:///var/run/netbird.sock"
|
||||
|
||||
// storedManagementURL is what the stub daemon already holds, so that a request
|
||||
// naming a different one is a change: see Settings.guardedChanges.
|
||||
const storedManagementURL = "https://stored.example.com"
|
||||
|
||||
// stubElevator stands in for the platform's prompt: it records what would have run
|
||||
// and answers with a fixed outcome.
|
||||
type stubElevator struct {
|
||||
outcome error
|
||||
available bool
|
||||
calls [][]string
|
||||
}
|
||||
|
||||
func (e *stubElevator) Run(_ context.Context, args ...string) error {
|
||||
e.calls = append(e.calls, args)
|
||||
return e.outcome
|
||||
}
|
||||
|
||||
func (e *stubElevator) Available() bool { return e.available }
|
||||
|
||||
// stubDaemon implements only the RPCs under test. The embedded interface is nil, so
|
||||
// any other call panics rather than passing quietly.
|
||||
type stubDaemon struct {
|
||||
proto.DaemonServiceClient
|
||||
setConfig func(*proto.SetConfigRequest) error
|
||||
// stored is what GetConfig reports, which is what a refused request's guarded
|
||||
// settings are compared against.
|
||||
stored *proto.GetConfigResponse
|
||||
requests []*proto.SetConfigRequest
|
||||
}
|
||||
|
||||
func (d *stubDaemon) SetConfig(_ context.Context, in *proto.SetConfigRequest, _ ...grpc.CallOption) (*proto.SetConfigResponse, error) {
|
||||
d.requests = append(d.requests, in)
|
||||
if err := d.setConfig(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &proto.SetConfigResponse{}, nil
|
||||
}
|
||||
|
||||
func (d *stubDaemon) GetConfig(_ context.Context, _ *proto.GetConfigRequest, _ ...grpc.CallOption) (*proto.GetConfigResponse, error) {
|
||||
return d.stored, nil
|
||||
}
|
||||
|
||||
type stubConn struct{ client proto.DaemonServiceClient }
|
||||
|
||||
func (c stubConn) Client() (proto.DaemonServiceClient, error) { return c.client, nil }
|
||||
|
||||
// privilegeRefusal is the error the daemon raises for a change it restricts to
|
||||
// root, detail and all: see server.privilegeError.
|
||||
func privilegeRefusal(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
st, err := gstatus.New(codes.PermissionDenied, "Changing the management URL requires root.").
|
||||
WithDetails(&errdetails.ErrorInfo{
|
||||
Reason: ipcauth.ErrorReasonPrivilegeRequired,
|
||||
Domain: ipcauth.ErrorDomain,
|
||||
Metadata: map[string]string{
|
||||
ipcauth.ErrorMetaSummary: "Changing the management URL requires root.",
|
||||
ipcauth.ErrorMetaCommand: "sudo netbird down; sudo netbird up -m https://mgmt.example.com",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "build the refusal detail")
|
||||
return st.Err()
|
||||
}
|
||||
|
||||
func settingsWithElevation(t *testing.T, outcome error) (*Settings, *stubElevator) {
|
||||
t.Helper()
|
||||
|
||||
elev := &stubElevator{outcome: outcome, available: true}
|
||||
return &Settings{daemonAddr: testDaemonAddr, elevator: elev}, elev
|
||||
}
|
||||
|
||||
// settingsRefusingOnce returns a Settings whose daemon refuses the first SetConfig
|
||||
// for want of privileges and accepts anything after it. Its stored config holds
|
||||
// another management server and no SSH grants, so a request naming either is a
|
||||
// change rather than a restatement.
|
||||
func settingsRefusingOnce(t *testing.T, elev *stubElevator) (*Settings, *stubDaemon) {
|
||||
t.Helper()
|
||||
|
||||
refusal := privilegeRefusal(t)
|
||||
daemon := &stubDaemon{stored: &proto.GetConfigResponse{ManagementUrl: storedManagementURL}}
|
||||
daemon.setConfig = func(*proto.SetConfigRequest) error {
|
||||
if len(daemon.requests) == 1 {
|
||||
return refusal
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return &Settings{conn: stubConn{client: daemon}, daemonAddr: testDaemonAddr, elevator: elev}, daemon
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsPassesOnlyTheChangedSettings(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
root := true
|
||||
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "work",
|
||||
Username: "vma",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||
|
||||
want := []string{
|
||||
"--" + FlagApplyPrivilegedSettings,
|
||||
"--" + FlagDaemonAddr, testDaemonAddr,
|
||||
"--" + FlagProfile, "work",
|
||||
"--" + FlagUser, "vma",
|
||||
"--" + FlagLogLevel, log.GetLevel().String(),
|
||||
"--" + FlagEnableSSHRoot + "=true",
|
||||
}
|
||||
require.Len(t, elev.calls, 1, "one prompt for one change")
|
||||
assert.Equal(t, want, elev.calls[0], "elevated arguments")
|
||||
|
||||
// argv[1] is what the polkit action is pinned to, so the marker has to stay
|
||||
// first however the rest of the line grows.
|
||||
assert.Equal(t, "--"+FlagApplyPrivilegedSettings, elev.calls[0][0], "the flag polkit matches on")
|
||||
}
|
||||
|
||||
// Turning a setting off has to be as explicit as turning it on: a bare flag would
|
||||
// read as "on" to the one-shot's parser.
|
||||
func TestSetGuardedSettingsSpellsOutFalse(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
off := false
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
ServerSSHAllowed: &off,
|
||||
DisableSSHAuth: &off,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
args := elev.calls[0]
|
||||
assert.Contains(t, args, "--"+FlagAllowServerSSH+"=false", "the setting being switched off")
|
||||
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=false", "the setting being switched off")
|
||||
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "no flag for a setting nobody touched")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsPassesTheManagementURL(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com:33073",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com:33073",
|
||||
"the management URL to point the profile at")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsWithoutASettingDoesNotElevate(t *testing.T) {
|
||||
s, elev := settingsWithElevation(t, nil)
|
||||
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{ProfileName: "default"})
|
||||
|
||||
require.Error(t, err, "nothing to apply is not something to prompt for")
|
||||
assert.Empty(t, elev.calls, "no prompt at all")
|
||||
}
|
||||
|
||||
// A declined prompt is the one ending that is not an error: reporting it as one
|
||||
// would have every cancelled prompt logged as a failure.
|
||||
func TestSetGuardedSettingsReportsADeclinedPromptAsAnOutcome(t *testing.T) {
|
||||
s, _ := settingsWithElevation(t, elevate.ErrDeclined)
|
||||
|
||||
root := true
|
||||
outcome, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
|
||||
require.NoError(t, err, "the user was asked and answered; nothing went wrong")
|
||||
assert.True(t, outcome.Declined, "nothing was applied")
|
||||
}
|
||||
|
||||
func TestSetGuardedSettingsMapsFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
outcome error
|
||||
wantCode string
|
||||
}{
|
||||
{
|
||||
// Nothing to raise a prompt with: the user needs the command.
|
||||
name: "no mechanism falls back to the command",
|
||||
outcome: elevate.ErrUnavailable,
|
||||
wantCode: CodeElevationUnavailable,
|
||||
},
|
||||
{
|
||||
name: "a failed run falls back to the command",
|
||||
outcome: errors.New("elevated netbird exited with 1"),
|
||||
wantCode: CodeElevationFailed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s, _ := settingsWithElevation(t, tt.outcome)
|
||||
|
||||
root := true
|
||||
_, err := s.SetGuardedSettings(context.Background(), GuardedSettings{
|
||||
ProfileName: "default",
|
||||
EnableSSHRoot: &root,
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr, "the frontend needs a code to act on")
|
||||
assert.Equal(t, tt.wantCode, clientErr.Code, "error code")
|
||||
assert.Contains(t, clientErr.Command, "--"+FlagEnableSSHRoot+"=true",
|
||||
"the setting in the fallback command")
|
||||
assert.Contains(t, clientErr.Command, "netbird up", "the fallback command")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Changing the management URL is only privileged while the host runs the SSH
|
||||
// server, which no control can know up front, so the refusal is what triggers the
|
||||
// prompt. The original request goes again afterwards, so the fields the one-shot
|
||||
// does not understand are applied too.
|
||||
func TestSetConfigElevatesAfterARefusalAndRetries(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, daemon := settingsRefusingOnce(t, elev)
|
||||
|
||||
mtu := int64(1280)
|
||||
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
MTU: &mtu,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, outcome.Declined, "the prompt was answered")
|
||||
|
||||
require.Len(t, elev.calls, 1, "one prompt")
|
||||
assert.Contains(t, elev.calls[0], "--"+FlagManagementURL+"=https://mgmt.example.com",
|
||||
"the guarded part of the request")
|
||||
require.Len(t, daemon.requests, 2, "the refused request and the retry")
|
||||
assert.Equal(t, mtu, daemon.requests[1].GetMtu(),
|
||||
"the retry carries the rest of the request, which the one-shot does not understand")
|
||||
}
|
||||
|
||||
func TestSetConfigDoesNotRetryWhenTheUserDeclines(t *testing.T) {
|
||||
elev := &stubElevator{outcome: elevate.ErrDeclined, available: true}
|
||||
s, daemon := settingsRefusingOnce(t, elev)
|
||||
|
||||
outcome, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
})
|
||||
|
||||
require.NoError(t, err, "a declined prompt is not an error")
|
||||
assert.True(t, outcome.Declined, "nothing was applied")
|
||||
assert.Len(t, daemon.requests, 1, "only the refused request")
|
||||
}
|
||||
|
||||
// With no prompt to raise, the refusal is reported as the daemon wrote it, which is
|
||||
// the guidance that was there before elevation existed.
|
||||
func TestSetConfigReportsTheRefusalWhenItCannotElevate(t *testing.T) {
|
||||
elev := &stubElevator{available: false}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: "https://mgmt.example.com",
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Contains(t, clientErr.Command, "netbird up -m https://mgmt.example.com",
|
||||
"the daemon's own command")
|
||||
assert.Empty(t, elev.calls, "no prompt where there is none to raise")
|
||||
}
|
||||
|
||||
// One authorization must buy only the change the user made. A settings form
|
||||
// submits every field it holds, so most of a refused request restates what the
|
||||
// daemon already has, and elevating those too would apply a guarded setting the
|
||||
// user never touched — a value gone stale since the form loaded above all.
|
||||
func TestSetConfigElevatesOnlyTheGuardedSettingsThatChange(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
on, off := true, false
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: storedManagementURL,
|
||||
ServerSSHAllowed: &off,
|
||||
EnableSSHRoot: &off,
|
||||
DisableSSHAuth: &on,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, elev.calls, 1, "one prompt")
|
||||
args := elev.calls[0]
|
||||
assert.Contains(t, args, "--"+FlagDisableSSHAuth+"=true", "the setting that changes")
|
||||
assert.NotContains(t, args, "--"+FlagManagementURL+"="+storedManagementURL,
|
||||
"a management URL the daemon already holds")
|
||||
assert.NotContains(t, args, "--"+FlagAllowServerSSH+"=false", "a setting already off")
|
||||
assert.NotContains(t, args, "--"+FlagEnableSSHRoot+"=false", "a setting already off")
|
||||
}
|
||||
|
||||
// A request that changes no guarded setting has nothing an elevated run could
|
||||
// apply, so the refusal must have come from somewhere a prompt cannot reach.
|
||||
func TestSetConfigDoesNotElevateWhenNoGuardedSettingChanges(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
off := false
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{
|
||||
ProfileName: "default",
|
||||
ManagementURL: storedManagementURL,
|
||||
ServerSSHAllowed: &off,
|
||||
})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Empty(t, elev.calls, "no prompt for a change nobody made")
|
||||
}
|
||||
|
||||
// A refusal with nothing in the request the one-shot could apply: the daemon
|
||||
// cannot see who is calling, and being root would not help either.
|
||||
func TestSetConfigReportsARefusalWithNothingToElevate(t *testing.T) {
|
||||
elev := &stubElevator{available: true}
|
||||
s, _ := settingsRefusingOnce(t, elev)
|
||||
|
||||
_, err := s.SetConfig(context.Background(), SetConfigParams{ProfileName: "default"})
|
||||
|
||||
var clientErr *ClientError
|
||||
require.ErrorAs(t, err, &clientErr)
|
||||
assert.Equal(t, "privilege_required", clientErr.Code, "error code")
|
||||
assert.Empty(t, elev.calls, "no prompt")
|
||||
}
|
||||
239
client/ui/services/oneshot.go
Normal file
239
client/ui/services/oneshot.go
Normal file
@@ -0,0 +1,239 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/elevate"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
// The other end of SetGuardedSettings: the mode this binary runs itself in,
|
||||
// elevated, to apply the settings the daemon restricts to root/administrator.
|
||||
//
|
||||
// Both ends are here on purpose. What may be changed this way is an allowlist, and
|
||||
// an allowlist declared twice is one that will eventually disagree with itself, so
|
||||
// the arguments are rendered and parsed from a single table: guardedFields. Adding
|
||||
// a setting is one row; nothing generic passes through, and no field outside the
|
||||
// table can be reached with an elevated request no matter what lands on the command
|
||||
// line.
|
||||
|
||||
// oneShotTimeout bounds the whole one-shot: connect, one RPC, exit. Generous
|
||||
// because the user has just waited for an authentication dialog, and a failure here
|
||||
// costs them the entire round trip.
|
||||
const oneShotTimeout = 30 * time.Second
|
||||
|
||||
// Exit codes the parent reads where the platform gives it one.
|
||||
const (
|
||||
exitOK = 0
|
||||
exitFailure = 1
|
||||
exitUsage = 2
|
||||
)
|
||||
|
||||
// guardedField is one setting the one-shot understands, in the two spellings it
|
||||
// needs and with the two halves of its plumbing.
|
||||
type guardedField struct {
|
||||
// flag names it on the one-shot's command line.
|
||||
flag string
|
||||
usage string
|
||||
// read returns the value to send and whether the caller asked for this setting
|
||||
// at all.
|
||||
read func(GuardedSettings) (string, bool)
|
||||
// write parses a value from the command line onto the request. It is the only
|
||||
// thing that validates the value, so it fails on anything it does not
|
||||
// recognise rather than guessing.
|
||||
write func(*proto.SetConfigRequest, string) error
|
||||
// up renders the equivalent `netbird up` flag, for the fallback command shown
|
||||
// when there is no prompt to raise.
|
||||
up func(value string) string
|
||||
}
|
||||
|
||||
var guardedFields = []guardedField{
|
||||
{
|
||||
flag: FlagManagementURL,
|
||||
usage: "Management server the profile registers with.",
|
||||
read: func(p GuardedSettings) (string, bool) { return p.ManagementURL, p.ManagementURL != "" },
|
||||
write: func(req *proto.SetConfigRequest, value string) error {
|
||||
// Parsed with the config layer's own parser, so what the elevated run
|
||||
// accepts cannot drift from what the daemon would store.
|
||||
if _, err := profilemanager.ParseServiceURL("Management URL", value); err != nil {
|
||||
return err
|
||||
}
|
||||
req.ManagementUrl = value
|
||||
return nil
|
||||
},
|
||||
// The daemon names this one as `-m <url>` in its own refusals.
|
||||
up: func(value string) string { return "-m " + value },
|
||||
},
|
||||
boolField(FlagAllowServerSSH, "Run the NetBird SSH server.",
|
||||
func(p GuardedSettings) *bool { return p.ServerSSHAllowed },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.ServerSSHAllowed = v }),
|
||||
boolField(FlagEnableSSHRoot, "Allow SSH sessions to privileged accounts.",
|
||||
func(p GuardedSettings) *bool { return p.EnableSSHRoot },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.EnableSSHRoot = v }),
|
||||
boolField(FlagDisableSSHAuth, "Accept SSH sessions without authentication.",
|
||||
func(p GuardedSettings) *bool { return p.DisableSSHAuth },
|
||||
func(req *proto.SetConfigRequest, v *bool) { req.DisableSSHAuth = v }),
|
||||
}
|
||||
|
||||
// fieldValue is a flag that remembers whether it was given, and requires a value:
|
||||
// the renderer always writes one, so a bare flag is a caller that got it wrong.
|
||||
type fieldValue struct {
|
||||
set bool
|
||||
value string
|
||||
}
|
||||
|
||||
func (v *fieldValue) String() string {
|
||||
if v == nil {
|
||||
return ""
|
||||
}
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *fieldValue) Set(value string) error {
|
||||
v.set, v.value = true, value
|
||||
return nil
|
||||
}
|
||||
|
||||
// boolField describes a setting that is on or off. The value is always spelled out,
|
||||
// so that turning a setting off is as unambiguous as turning it on and a flag with
|
||||
// no value is a mistake rather than an "on".
|
||||
func boolField(
|
||||
name, usage string,
|
||||
read func(GuardedSettings) *bool,
|
||||
write func(*proto.SetConfigRequest, *bool),
|
||||
) guardedField {
|
||||
return guardedField{
|
||||
flag: name,
|
||||
usage: usage,
|
||||
read: func(p GuardedSettings) (string, bool) {
|
||||
value := read(p)
|
||||
if value == nil {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatBool(*value), true
|
||||
},
|
||||
write: func(req *proto.SetConfigRequest, value string) error {
|
||||
parsed, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %q as a boolean: %w", value, err)
|
||||
}
|
||||
write(req, &parsed)
|
||||
return nil
|
||||
},
|
||||
up: func(value string) string { return "--" + name + "=" + value },
|
||||
}
|
||||
}
|
||||
|
||||
// IsPrivilegedSettingsRun reports whether this process was started as the one-shot.
|
||||
// The flag is a marker rather than a value, so only the bare forms count: reading a
|
||||
// value would mean "--flag=false" started it too.
|
||||
func IsPrivilegedSettingsRun(args []string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--"+FlagApplyPrivilegedSettings || arg == "-"+FlagApplyPrivilegedSettings {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RunPrivilegedSettings applies the requested settings and returns the process exit
|
||||
// code. connect dials the daemon, which is the caller's business because only it
|
||||
// knows how this build talks to it.
|
||||
//
|
||||
// Everything it reports goes to stderr, which is what the parent captures where the
|
||||
// platform lets it. On success it says so on standard output, because macOS gives
|
||||
// the parent no exit status to read: see elevate.AppliedMarker.
|
||||
func RunPrivilegedSettings(args []string, connect func(addr string) (proto.DaemonServiceClient, error)) int {
|
||||
fs := flag.NewFlagSet("netbird-ui --"+FlagApplyPrivilegedSettings, flag.ContinueOnError)
|
||||
fs.Bool(FlagApplyPrivilegedSettings, false, "Apply the settings the daemon restricts to root/administrator and exit.")
|
||||
daemonAddr := fs.String(FlagDaemonAddr, "", "Daemon gRPC address: unix:///path, npipe://name or tcp://host:port")
|
||||
logLevel := fs.String(FlagLogLevel, "info", "Log level: trace|debug|info|warn|error.")
|
||||
profile := fs.String(FlagProfile, "", "Profile to change.")
|
||||
username := fs.String(FlagUser, "", "Owner of the profile.")
|
||||
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
fs.Var(&values[i], field.flag, field.usage)
|
||||
}
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if err := util.InitLog(*logLevel, "console"); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "init log: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
req, err := privilegedRequest(*profile, *username, values)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", err)
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), oneShotTimeout)
|
||||
defer cancel()
|
||||
|
||||
if err := applyPrivilegedSettings(ctx, *daemonAddr, req, connect); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "apply settings: %v\n", err)
|
||||
return exitFailure
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stdout, elevate.AppliedMarker)
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// privilegedRequest builds the request from the flags that were given, and refuses
|
||||
// one that asks for nothing.
|
||||
func privilegedRequest(profile, username string, values []fieldValue) (*proto.SetConfigRequest, error) {
|
||||
req := &proto.SetConfigRequest{ProfileName: profile, Username: username}
|
||||
|
||||
given := 0
|
||||
for i, field := range guardedFields {
|
||||
if !values[i].set {
|
||||
continue
|
||||
}
|
||||
if err := field.write(req, values[i].value); err != nil {
|
||||
return nil, fmt.Errorf("--%s: %w", field.flag, err)
|
||||
}
|
||||
given++
|
||||
}
|
||||
if given == 0 {
|
||||
return nil, errors.New("no setting to apply")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func applyPrivilegedSettings(
|
||||
ctx context.Context,
|
||||
daemonAddr string,
|
||||
req *proto.SetConfigRequest,
|
||||
connect func(addr string) (proto.DaemonServiceClient, error),
|
||||
) error {
|
||||
client, err := connect(daemonAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := client.SetConfig(ctx, req); err != nil {
|
||||
// Unwrapped: the daemon's message is written for a person, and a refusal
|
||||
// elevation cannot fix has to say so where the parent can read it off
|
||||
// stderr.
|
||||
return errors.New(gstatus.Convert(err).Message())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// interface guard: the one-shot's flags are flag.Value.
|
||||
var _ flag.Value = (*fieldValue)(nil)
|
||||
151
client/ui/services/oneshot_test.go
Normal file
151
client/ui/services/oneshot_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
func TestIsPrivilegedSettingsRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{name: "no arguments"},
|
||||
{name: "double dash", args: []string{"--" + FlagApplyPrivilegedSettings}, want: true},
|
||||
{name: "single dash", args: []string{"-" + FlagApplyPrivilegedSettings}, want: true},
|
||||
{
|
||||
name: "among other flags",
|
||||
args: []string{"--daemon-addr", "unix:///tmp/x.sock", "--" + FlagApplyPrivilegedSettings},
|
||||
want: true,
|
||||
},
|
||||
// A marker, not a value: the caller never passes one, and reading a value
|
||||
// would mean "--flag=false" started the one-shot too.
|
||||
{name: "with a value", args: []string{"--" + FlagApplyPrivilegedSettings + "=true"}},
|
||||
{name: "unrelated flags", args: []string{"--log-level", "debug"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, IsPrivilegedSettingsRun(tt.args), "args %v", tt.args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// What SetGuardedSettings renders has to be what the one-shot reads back, for every
|
||||
// setting in the table. This is the property that keeps the two ends of an allowlist
|
||||
// from drifting, so it is checked field by field rather than by example.
|
||||
func TestGuardedFieldsRoundTrip(t *testing.T) {
|
||||
on, off := true, false
|
||||
tests := []struct {
|
||||
name string
|
||||
settings GuardedSettings
|
||||
want func(*testing.T, *proto.SetConfigRequest)
|
||||
}{
|
||||
{
|
||||
name: "management url",
|
||||
settings: GuardedSettings{ManagementURL: "https://mgmt.example.com:33073"},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
assert.Equal(t, "https://mgmt.example.com:33073", req.GetManagementUrl())
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh server on",
|
||||
settings: GuardedSettings{ServerSSHAllowed: &on},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.ServerSSHAllowed)
|
||||
assert.True(t, *req.ServerSSHAllowed)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh root off",
|
||||
settings: GuardedSettings{EnableSSHRoot: &off},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.EnableSSHRoot, "an explicit false must survive, not read as absent")
|
||||
assert.False(t, *req.EnableSSHRoot)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ssh auth off",
|
||||
settings: GuardedSettings{DisableSSHAuth: &on},
|
||||
want: func(t *testing.T, req *proto.SetConfigRequest) {
|
||||
require.NotNil(t, req.DisableSSHAuth)
|
||||
assert.True(t, *req.DisableSSHAuth)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req := parseRendered(t, tt.settings)
|
||||
tt.want(t, req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A setting nobody asked about must not arrive at the daemon at all: sending its
|
||||
// zero value would change it.
|
||||
func TestGuardedFieldsCarryOnlyWhatWasAsked(t *testing.T) {
|
||||
on := true
|
||||
req := parseRendered(t, GuardedSettings{ProfileName: "work", EnableSSHRoot: &on})
|
||||
|
||||
assert.Equal(t, "work", req.GetProfileName(), "profile")
|
||||
require.NotNil(t, req.EnableSSHRoot)
|
||||
assert.Nil(t, req.ServerSSHAllowed, "untouched setting")
|
||||
assert.Nil(t, req.DisableSSHAuth, "untouched setting")
|
||||
assert.Empty(t, req.GetManagementUrl(), "untouched setting")
|
||||
}
|
||||
|
||||
func TestPrivilegedRequestRejectsAnEmptyChange(t *testing.T) {
|
||||
_, err := privilegedRequest("default", "vma", make([]fieldValue, len(guardedFields)))
|
||||
require.Error(t, err, "nothing to apply is not a request worth sending as root")
|
||||
}
|
||||
|
||||
// A value the table cannot parse is refused rather than guessed at.
|
||||
func TestPrivilegedRequestRejectsAnUnparseableValue(t *testing.T) {
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
if field.flag != FlagEnableSSHRoot {
|
||||
continue
|
||||
}
|
||||
require.NoError(t, values[i].Set("perhaps"))
|
||||
}
|
||||
|
||||
_, err := privilegedRequest("default", "vma", values)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), FlagEnableSSHRoot, "which flag was wrong")
|
||||
}
|
||||
|
||||
// parseRendered puts the settings through both ends: rendered as the arguments the
|
||||
// elevated process is given, then parsed by a flag set registered from the same
|
||||
// table, which is what the one-shot itself parses them with. Anything hand-rolled
|
||||
// here would pin down a parser nothing uses.
|
||||
func parseRendered(t *testing.T, p GuardedSettings) *proto.SetConfigRequest {
|
||||
t.Helper()
|
||||
|
||||
rendered := guardedSettings(p)
|
||||
require.NotEmpty(t, rendered, "nothing rendered for %+v", p)
|
||||
|
||||
args := make([]string, 0, len(rendered))
|
||||
for _, setting := range rendered {
|
||||
args = append(args, setting.arg)
|
||||
}
|
||||
|
||||
fs := flag.NewFlagSet(t.Name(), flag.ContinueOnError)
|
||||
values := make([]fieldValue, len(guardedFields))
|
||||
for i, field := range guardedFields {
|
||||
fs.Var(&values[i], field.flag, field.usage)
|
||||
}
|
||||
require.NoError(t, fs.Parse(args), "the one-shot's own flag set must accept %v", args)
|
||||
|
||||
req, err := privilegedRequest(p.ProfileName, p.Username, values)
|
||||
require.NoError(t, err)
|
||||
return req
|
||||
}
|
||||
@@ -44,12 +44,19 @@ type Restrictions struct {
|
||||
}
|
||||
|
||||
// Privilege tells the frontend whether this process may perform the changes the
|
||||
// daemon restricts to root/administrator, and carries the command for each so a
|
||||
// disabled control can show the way to do it.
|
||||
// daemon restricts to root/administrator, whether it can ask the operating
|
||||
// system for the privileges instead, and the command for each so a control that
|
||||
// can do neither can still show the way.
|
||||
type Privilege struct {
|
||||
Privileged bool `json:"privileged"`
|
||||
// Actor names what the operation requires ("root", "administrator privileges").
|
||||
Actor string `json:"actor"`
|
||||
// ActorKey identifies the principal the operation requires without wording it,
|
||||
// so the frontend can name it in the user's language: see
|
||||
// ipcauth.PrivilegedActorKey. The words are not sent, because English ones
|
||||
// cannot be dropped into a translated sentence.
|
||||
ActorKey string `json:"actorKey"`
|
||||
// CanElevate reports whether a guarded control can offer to authorize the
|
||||
// change through the platform's own prompt: see SetGuardedSettings.
|
||||
CanElevate bool `json:"canElevate"`
|
||||
// Commands equivalent to the settings the daemon guards, ready to copy.
|
||||
AllowSSHServer string `json:"allowSshServer"`
|
||||
EnableSSHRoot string `json:"enableSshRoot"`
|
||||
@@ -128,6 +135,9 @@ type Settings struct {
|
||||
// daemonAddr is where the daemon listens, used to tell whether it runs as
|
||||
// this user and would therefore authorize us: see Privilege.
|
||||
daemonAddr string
|
||||
// elevator raises the platform's privilege prompt when a change needs more
|
||||
// rights than this process has.
|
||||
elevator elevator
|
||||
}
|
||||
|
||||
func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePreference, daemonAddr string) *Settings {
|
||||
@@ -135,6 +145,7 @@ func NewSettings(conn DaemonConn, translator ErrorTranslator, prefs LanguagePref
|
||||
conn: conn,
|
||||
classifier: errorClassifier{translator: translator, prefs: prefs},
|
||||
daemonAddr: daemonAddr,
|
||||
elevator: osElevator{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,10 +191,10 @@ func (s *Settings) GetConfig(ctx context.Context, p ConfigParams) (Config, error
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) (SaveOutcome, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
return SaveOutcome{}, err
|
||||
}
|
||||
req := &proto.SetConfigRequest{
|
||||
ProfileName: p.ProfileName,
|
||||
@@ -215,19 +226,92 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
SshJWTCacheTTL: p.SSHJWTCacheTTL,
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
if _, refused := privilegeErrorInfo(err); refused {
|
||||
return s.setConfigElevated(ctx, p, req, err)
|
||||
}
|
||||
// Classified so the frontend gets the daemon's guidance instead of the
|
||||
// gRPC envelope, which is what a refused privileged change looks like.
|
||||
return s.classifier.classify(err)
|
||||
// gRPC envelope.
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
return nil
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// setConfigElevated answers a request the daemon refused for want of privileges by
|
||||
// asking the user to authorize it, and sending it again if they do. It is the same
|
||||
// offer the SSH settings make up front, for the changes a control cannot know are
|
||||
// guarded until it is told: repointing a profile at another management server is
|
||||
// only privileged while that host runs the SSH server.
|
||||
//
|
||||
// Two steps, because the elevated one-shot deliberately understands only the
|
||||
// settings the daemon guards: it applies those, and the original request then goes
|
||||
// through as this user, its privileged parts now asking for nothing that is not
|
||||
// already stored. Nothing was applied by the refused attempt — the daemon decides
|
||||
// before it writes — so there is no half-applied state to undo either way.
|
||||
func (s *Settings) setConfigElevated(ctx context.Context, p SetConfigParams, req *proto.SetConfigRequest, refusal error) (SaveOutcome, error) {
|
||||
if !s.canElevate() {
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
|
||||
guarded, err := s.guardedChanges(ctx, p)
|
||||
if err != nil {
|
||||
log.Warnf("cannot tell which guarded settings this request changes: %v", err)
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
if len(guardedSettings(guarded)) == 0 {
|
||||
// Refused over something no prompt can settle, such as a control channel
|
||||
// that carries no caller identity. Report the daemon's own guidance.
|
||||
return SaveOutcome{}, s.classifier.classify(refusal)
|
||||
}
|
||||
|
||||
outcome, err := s.SetGuardedSettings(ctx, guarded)
|
||||
if err != nil || outcome.Declined {
|
||||
return outcome, err
|
||||
}
|
||||
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
return SaveOutcome{}, err
|
||||
}
|
||||
if _, err := cli.SetConfig(ctx, req); err != nil {
|
||||
return SaveOutcome{}, s.classifier.classify(err)
|
||||
}
|
||||
return SaveOutcome{}, nil
|
||||
}
|
||||
|
||||
// guardedChanges is the guarded part of a request, reduced to what it actually
|
||||
// changes.
|
||||
//
|
||||
// A settings form submits every field it holds, so a request restates values the
|
||||
// daemon already has. Carrying those into the elevated run would spend one
|
||||
// authorization on more than the user asked for, and a value that has gone stale
|
||||
// since the form was loaded would spend it on something they never asked about.
|
||||
func (s *Settings) guardedChanges(ctx context.Context, p SetConfigParams) (GuardedSettings, error) {
|
||||
stored, err := s.GetConfig(ctx, ConfigParams{ProfileName: p.ProfileName, Username: p.Username})
|
||||
if err != nil {
|
||||
return GuardedSettings{}, fmt.Errorf("read the stored config: %w", err)
|
||||
}
|
||||
|
||||
guarded := GuardedSettings{
|
||||
ProfileName: p.ProfileName,
|
||||
Username: p.Username,
|
||||
ServerSSHAllowed: changedFlag(p.ServerSSHAllowed, stored.ServerSSHAllowed),
|
||||
EnableSSHRoot: changedFlag(p.EnableSSHRoot, stored.EnableSSHRoot),
|
||||
DisableSSHAuth: changedFlag(p.DisableSSHAuth, stored.DisableSSHAuth),
|
||||
}
|
||||
// An empty URL leaves the setting alone, which is the daemon's rule too.
|
||||
if p.ManagementURL != "" && p.ManagementURL != stored.ManagementURL {
|
||||
guarded.ManagementURL = p.ManagementURL
|
||||
}
|
||||
return guarded, nil
|
||||
}
|
||||
|
||||
// Privilege reports whether this UI process could carry out the changes the
|
||||
// daemon restricts to root/administrator, and the command that performs the one
|
||||
// users hit in the SSH settings. It applies the daemon's own rule to what it can
|
||||
// see locally, so the frontend can present those controls as unavailable up front
|
||||
// instead of letting a save fail. No daemon round-trip, so it also works while the
|
||||
// daemon is down.
|
||||
// daemon restricts to root/administrator, whether it can instead ask the
|
||||
// operating system for the privileges when the user wants one of them, and the
|
||||
// command that performs the ones users hit in the SSH settings. It applies the
|
||||
// daemon's own rule to what it can see locally, so the frontend can decide up
|
||||
// front how to present those controls instead of letting a save fail. No daemon
|
||||
// round-trip, so it also works while the daemon is down.
|
||||
//
|
||||
// Being root or an elevated administrator is one way. The other is running as the
|
||||
// daemon's own user while the daemon is unprivileged, which the daemon accepts
|
||||
@@ -237,26 +321,40 @@ func (s *Settings) SetConfig(ctx context.Context, p SetConfigParams) error {
|
||||
func (s *Settings) Privilege() Privilege {
|
||||
id, err := ipcauth.CurrentProcessIdentity()
|
||||
if err != nil {
|
||||
// Fail closed: report unprivileged, which only ever disables controls.
|
||||
// Fail closed: report unprivileged, which only ever asks for more.
|
||||
log.Warnf("cannot read this process's identity, treating it as unprivileged: %v", err)
|
||||
return newPrivilege(false)
|
||||
return s.newPrivilege(false)
|
||||
}
|
||||
if id.IsPrivileged() {
|
||||
return newPrivilege(true)
|
||||
return s.newPrivilege(true)
|
||||
}
|
||||
return newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
return s.newPrivilege(daemonaddr.DaemonRunsAsSelf(s.daemonAddr))
|
||||
}
|
||||
|
||||
func newPrivilege(privileged bool) Privilege {
|
||||
func (s *Settings) newPrivilege(privileged bool) Privilege {
|
||||
return Privilege{
|
||||
Privileged: privileged,
|
||||
Actor: ipcauth.PrivilegedActor(),
|
||||
ActorKey: ipcauth.PrivilegedActorKey(),
|
||||
CanElevate: s.canElevate(),
|
||||
AllowSSHServer: ipcauth.UpCommand("--allow-server-ssh"),
|
||||
EnableSSHRoot: ipcauth.UpCommand("--enable-ssh-root"),
|
||||
DisableSSHAuth: ipcauth.UpCommand("--disable-ssh-auth"),
|
||||
}
|
||||
}
|
||||
|
||||
// canElevate reports whether offering the platform's elevation prompt would get
|
||||
// the user anywhere. It needs a mechanism to raise the prompt with and a control
|
||||
// channel that tells the daemon who is calling: on loopback TCP the daemon
|
||||
// refuses these changes to everybody, root included, so a prompt there would
|
||||
// only waste the user's password.
|
||||
func (s *Settings) canElevate() bool {
|
||||
if !daemonaddr.CarriesIdentity(s.daemonAddr) {
|
||||
log.Debugf("not offering elevation: the daemon address %s carries no caller identity", s.daemonAddr)
|
||||
return false
|
||||
}
|
||||
return s.elevator.Available()
|
||||
}
|
||||
|
||||
func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
cli, err := s.conn.Client()
|
||||
if err != nil {
|
||||
@@ -289,6 +387,15 @@ func (s *Settings) GetRestrictions(ctx context.Context) (Restrictions, error) {
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// changedFlag returns requested only when it differs from what is stored, so a
|
||||
// setting the request merely restates is left out of the elevated run.
|
||||
func changedFlag(requested *bool, stored bool) *bool {
|
||||
if requested == nil || *requested == stored {
|
||||
return nil
|
||||
}
|
||||
return requested
|
||||
}
|
||||
|
||||
func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) {
|
||||
managed := cfgResp.GetMDMManagedFields()
|
||||
if len(managed) == 0 {
|
||||
|
||||
@@ -50,9 +50,7 @@ func (m *managerImpl) reconcile(ctx context.Context, accountID string) {
|
||||
if svc == nil || svc.ID == "" {
|
||||
continue
|
||||
}
|
||||
// Synthesised gateways live on the cluster's own domain, which the
|
||||
// account never has to prove it owns.
|
||||
current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg, true)
|
||||
current[svc.ID] = svc.ToProtoMapping(rpservice.Update, "", oidcCfg)
|
||||
}
|
||||
|
||||
m.reconcileMu.Lock()
|
||||
|
||||
@@ -34,7 +34,7 @@ func TestSynthesizeServices_RealStore_LogCollectionOff_SuppressesAccessLog(t *te
|
||||
assert.True(t, services[0].Targets[0].Options.DisableAccessLog,
|
||||
"EnableLogCollection=false (default) must produce DisableAccessLog=true on the synth target")
|
||||
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}, true)
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path")
|
||||
assert.True(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(),
|
||||
"proto mapping must propagate DisableAccessLog=true so the proxy suppresses access-log emission")
|
||||
@@ -63,7 +63,7 @@ func TestSynthesizeServices_RealStore_LogCollectionOn_PermitsAccessLog(t *testin
|
||||
assert.False(t, services[0].Targets[0].Options.DisableAccessLog,
|
||||
"EnableLogCollection=true must leave DisableAccessLog=false on the synth target")
|
||||
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}, true)
|
||||
mapping := services[0].ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
require.NotEmpty(t, mapping.GetPath(), "proto mapping must carry a path")
|
||||
assert.False(t, mapping.GetPath()[0].GetOptions().GetDisableAccessLog(),
|
||||
"proto mapping must propagate DisableAccessLog=false so access-log emission stays on")
|
||||
|
||||
@@ -76,7 +76,7 @@ func TestSynthesizeServices_RealStore_SurvivesStatusToggle(t *testing.T) {
|
||||
assert.True(t, svc.Private, "%s: synth service must be Private after store round-trip", stage)
|
||||
assert.Equal(t, []string{"grp-eng"}, svc.AccessGroups, "%s: AccessGroups must survive the round-trip", stage)
|
||||
|
||||
m := svc.ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{}, true)
|
||||
m := svc.ToProtoMapping(rpservice.Update, "", rpproxy.OIDCValidationConfig{})
|
||||
assert.True(t, m.GetPrivate(), "%s: proto mapping Private must be true (proxy gates tunnel-peer auth on it)", stage)
|
||||
|
||||
cfg := decodeServiceRouterConfig(t, svc)
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestSynthesizedService_WireShape(t *testing.T) {
|
||||
require.Len(t, services, 1)
|
||||
|
||||
svc := services[0]
|
||||
mapping := svc.ToProtoMapping(rpservice.Create, "test-token", proxy.OIDCValidationConfig{}, true)
|
||||
mapping := svc.ToProtoMapping(rpservice.Create, "test-token", proxy.OIDCValidationConfig{})
|
||||
|
||||
// Identifiers — account-scoped service ID, settings-derived domain.
|
||||
assert.Equal(t, "agent-net-svc-acct-1", mapping.GetId(), "stable account-scoped virtual service ID")
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
@@ -10,18 +8,12 @@ const (
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID string `gorm:"unique;primaryKey;autoIncrement"`
|
||||
// A claim is unique per account only. Global uniqueness applies once a row
|
||||
// is validated and is enforced by the manager, because proving control of
|
||||
// the DNS zone is what decides which account may serve the hostname.
|
||||
Domain string `gorm:"uniqueIndex:idx_domains_account_id_domain,priority:2"`
|
||||
AccountID string `gorm:"index;uniqueIndex:idx_domains_account_id_domain,priority:1"`
|
||||
ID string `gorm:"unique;primaryKey;autoIncrement"`
|
||||
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
|
||||
AccountID string `gorm:"index"`
|
||||
TargetCluster string // The proxy cluster this domain should be validated against
|
||||
Type Type `gorm:"-"`
|
||||
Validated bool
|
||||
// ClaimedAt is when the account last staked its claim on the domain. It
|
||||
// bounds how long an unvalidated row may hold the name.
|
||||
ClaimedAt time.Time
|
||||
// SupportsCustomPorts is populated at query time for free domains from the
|
||||
// proxy cluster capabilities. Not persisted.
|
||||
SupportsCustomPorts *bool `gorm:"-"`
|
||||
|
||||
@@ -66,8 +66,8 @@ func TestExtractClusterFromFreeDomain(t *testing.T) {
|
||||
|
||||
func TestExtractClusterFromCustomDomains(t *testing.T) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: true},
|
||||
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io", Validated: true},
|
||||
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io"},
|
||||
{Domain: "proxy.corp.io", TargetCluster: "us1.proxy.netbird.io"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -120,50 +120,19 @@ func TestExtractClusterFromCustomDomains(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
if !tc.wantOK {
|
||||
assert.Equal(t, customDomainNoMatch, match, "unrelated domain should not match any custom domain")
|
||||
return
|
||||
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.Equal(t, tc.wantOK, ok)
|
||||
if ok {
|
||||
assert.Equal(t, tc.wantVal, cluster)
|
||||
}
|
||||
assert.Equal(t, customDomainValidated, match, "validated custom domain should resolve a cluster")
|
||||
assert.Equal(t, tc.wantVal, cluster)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// An unvalidated row must never yield a cluster: the account has not proven it
|
||||
// controls the name, so nothing may be routed for it.
|
||||
func TestExtractClusterFromCustomDomains_UnvalidatedDomainRefused(t *testing.T) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "eu1.proxy.netbird.io", Validated: false},
|
||||
}
|
||||
|
||||
for _, serviceDomain := range []string{"example.com", "app.example.com"} {
|
||||
t.Run(serviceDomain, func(t *testing.T) {
|
||||
cluster, match := extractClusterFromCustomDomains(serviceDomain, customDomains)
|
||||
assert.Equal(t, customDomainUnvalidated, match, "unvalidated row must be reported as such")
|
||||
assert.Empty(t, cluster, "unvalidated row must not resolve a cluster")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A more specific unvalidated row must not shadow a validated parent domain the
|
||||
// account already proved it owns.
|
||||
func TestExtractClusterFromCustomDomains_ValidatedParentWinsOverUnvalidatedChild(t *testing.T) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
|
||||
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: false},
|
||||
}
|
||||
|
||||
cluster, match := extractClusterFromCustomDomains("app.example.com", customDomains)
|
||||
assert.Equal(t, customDomainValidated, match)
|
||||
assert.Equal(t, "cluster-generic", cluster, "validated parent domain should provide the cluster")
|
||||
}
|
||||
|
||||
func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
|
||||
customDomains := []*domain.Domain{
|
||||
{Domain: "example.com", TargetCluster: "cluster-generic", Validated: true},
|
||||
{Domain: "app.example.com", TargetCluster: "cluster-app", Validated: true},
|
||||
{Domain: "example.com", TargetCluster: "cluster-generic"},
|
||||
{Domain: "app.example.com", TargetCluster: "cluster-app"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
@@ -195,8 +164,8 @@ func TestExtractClusterFromCustomDomains_OverlappingDomains(t *testing.T) {
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cluster, match := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.Equal(t, customDomainValidated, match)
|
||||
cluster, ok := extractClusterFromCustomDomains(tc.domain, customDomains)
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, tc.wantVal, cluster)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -20,36 +18,13 @@ import (
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
// claimTTL bounds how long a domain may be held by a row that has never
|
||||
// reached Validated. Past it the claim is evicted and the name is free again.
|
||||
claimTTL = 24 * time.Hour
|
||||
|
||||
// maxCustomDomainsPerAccount caps how many custom domains an account may
|
||||
// hold. Every domain turns into a certificate order against the cluster's
|
||||
// ACME issuance rate limit, which is shared by every account on it.
|
||||
maxCustomDomainsPerAccount = 100
|
||||
|
||||
// maintenanceInterval is how often claims are expired and validated
|
||||
// domains are re-checked against their target cluster.
|
||||
maintenanceInterval = time.Hour
|
||||
|
||||
// revalidationFailureThreshold is how many consecutive failed re-checks a
|
||||
// validated domain must accumulate before it is flipped back. A single
|
||||
// failed lookup is far more often a transient resolver problem than a
|
||||
// deliberate DNS change.
|
||||
revalidationFailureThreshold = 3
|
||||
)
|
||||
|
||||
type store interface {
|
||||
GetAccount(ctx context.Context, accountID string) (*types.Account, error)
|
||||
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
|
||||
ListCustomDomainsByName(ctx context.Context, domainName string) ([]*domain.Domain, error)
|
||||
ListAllCustomDomains(ctx context.Context) ([]*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool, claimedAt time.Time) (*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
}
|
||||
@@ -63,42 +38,12 @@ type proxyManager interface {
|
||||
ClusterSupportsPrivate(ctx context.Context, clusterAddr string) *bool
|
||||
}
|
||||
|
||||
// failureTracker counts consecutive revalidation failures per domain ID. It is
|
||||
// held by pointer so copies of the value-typed Manager share one instance.
|
||||
type failureTracker struct {
|
||||
mu sync.Mutex
|
||||
counts map[string]int
|
||||
}
|
||||
|
||||
// record increments the failure count for a domain and returns the new total.
|
||||
func (f *failureTracker) record(domainID string) int {
|
||||
if f == nil {
|
||||
return 1
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.counts[domainID]++
|
||||
return f.counts[domainID]
|
||||
}
|
||||
|
||||
// clear forgets the failure history of a domain.
|
||||
func (f *failureTracker) clear(domainID string) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.counts, domainID)
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
store store
|
||||
validator domain.Validator
|
||||
proxyManager proxyManager
|
||||
permissionsManager permissions.Manager
|
||||
accountManager account.Manager
|
||||
now func() time.Time
|
||||
revalidationFails *failureTracker
|
||||
}
|
||||
|
||||
func NewManager(store store, proxyMgr proxyManager, permissionsManager permissions.Manager, accountManager account.Manager) Manager {
|
||||
@@ -108,20 +53,9 @@ func NewManager(store store, proxyMgr proxyManager, permissionsManager permissio
|
||||
validator: domain.Validator{Resolver: net.DefaultResolver},
|
||||
permissionsManager: permissionsManager,
|
||||
accountManager: accountManager,
|
||||
now: time.Now,
|
||||
revalidationFails: &failureTracker{counts: make(map[string]int)},
|
||||
}
|
||||
}
|
||||
|
||||
// currentTime returns the manager's clock, tolerating a zero-valued Manager
|
||||
// built directly in tests.
|
||||
func (m Manager) currentTime() time.Time {
|
||||
if m.now == nil {
|
||||
return time.Now()
|
||||
}
|
||||
return m.now().UTC()
|
||||
}
|
||||
|
||||
func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*domain.Domain, error) {
|
||||
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Read)
|
||||
if err != nil {
|
||||
@@ -212,23 +146,15 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
|
||||
return nil, fmt.Errorf("target cluster %s is not available", targetCluster)
|
||||
}
|
||||
|
||||
if err := m.checkAccountQuota(ctx, accountID, domainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := m.checkDomainClaimable(ctx, accountID, domainName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Attempt an initial validation against the specified cluster only
|
||||
var validated bool
|
||||
if m.validator.IsValid(ctx, domainName, []string{targetCluster}) {
|
||||
validated = true
|
||||
}
|
||||
|
||||
d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated, m.currentTime())
|
||||
d, err := m.store.CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create domain in store: %w", err)
|
||||
return d, fmt.Errorf("create domain in store: %w", err)
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, d.ID, accountID, activity.DomainAdded, d.EventMeta())
|
||||
@@ -236,46 +162,6 @@ func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// checkAccountQuota rejects a new claim once the account already holds the
|
||||
// maximum number of custom domains, or already holds this one.
|
||||
func (m Manager) checkAccountQuota(ctx context.Context, accountID, domainName string) error {
|
||||
owned, err := m.store.ListCustomDomains(ctx, accountID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list custom domains: %w", err)
|
||||
}
|
||||
|
||||
for _, d := range owned {
|
||||
if d.Domain == domainName {
|
||||
return status.Errorf(status.AlreadyExists, "domain %s is already registered", domainName)
|
||||
}
|
||||
}
|
||||
|
||||
if len(owned) >= maxCustomDomainsPerAccount {
|
||||
return status.Errorf(status.PreconditionFailed, "account has reached the maximum of %d custom domains", maxCustomDomainsPerAccount)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkDomainClaimable rejects a claim on a domain another account already
|
||||
// serves. Unvalidated claims by other accounts do not block: proving control of
|
||||
// the DNS zone is what settles ownership, and until then nobody has. The error
|
||||
// deliberately says nothing about who holds the domain.
|
||||
func (m Manager) checkDomainClaimable(ctx context.Context, accountID, domainName string) error {
|
||||
holders, err := m.store.ListCustomDomainsByName(ctx, domainName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list domain holders: %w", err)
|
||||
}
|
||||
|
||||
for _, d := range holders {
|
||||
if d.AccountID != accountID && d.Validated {
|
||||
return status.Errorf(status.AlreadyExists, "domain %s is already in use", domainName)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Manager) DeleteDomain(ctx context.Context, accountID, userID, domainID string) error {
|
||||
ok, ctx, err := m.permissionsManager.ValidateUserPermissions(ctx, accountID, userID, modules.Services, operations.Delete)
|
||||
if err != nil {
|
||||
@@ -313,8 +199,7 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
}).Error("validate domain: permission denied")
|
||||
return
|
||||
}).WithError(err).Error("validate domain")
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
@@ -349,46 +234,31 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
"targetCluster": targetCluster,
|
||||
}).Info("validating domain against target cluster")
|
||||
|
||||
if !m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
|
||||
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).Info("domain validated successfully")
|
||||
d.Validated = true
|
||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("update custom domain in store")
|
||||
return
|
||||
}
|
||||
|
||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
} else {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
"targetCluster": targetCluster,
|
||||
}).Warn("domain validation failed - CNAME does not match target cluster")
|
||||
return
|
||||
}
|
||||
|
||||
// The CNAME proves control of the zone but says nothing about which account
|
||||
// asked, so an account that is late to validate a name another one already
|
||||
// serves must not take it over.
|
||||
if err := m.checkDomainClaimable(context.Background(), accountID, d.Domain); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Warn("domain validation refused")
|
||||
return
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).Info("domain validated successfully")
|
||||
|
||||
d.Validated = true
|
||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("update custom domain in store")
|
||||
return
|
||||
}
|
||||
m.revalidationFails.clear(domainID)
|
||||
|
||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
}
|
||||
|
||||
// GetClusterDomains returns a list of proxy cluster domains.
|
||||
@@ -424,31 +294,14 @@ func (m Manager) DeriveClusterFromDomain(ctx context.Context, accountID, domain
|
||||
return "", fmt.Errorf("list custom domains: %w", err)
|
||||
}
|
||||
|
||||
targetCluster, match := extractClusterFromCustomDomains(domain, customDomains)
|
||||
switch match {
|
||||
case customDomainValidated:
|
||||
targetCluster, valid := extractClusterFromCustomDomains(domain, customDomains)
|
||||
if valid {
|
||||
return targetCluster, nil
|
||||
case customDomainUnvalidated:
|
||||
return "", status.Errorf(status.PreconditionFailed, "domain %s is not validated", domain)
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("domain %s does not match any available proxy cluster", domain)
|
||||
}
|
||||
|
||||
// IsDomainValidated reports whether the account is currently allowed to serve
|
||||
// the domain: free cluster domains always are, custom domains only once their
|
||||
// CNAME validation has succeeded. Any lookup failure denies.
|
||||
func (m Manager) IsDomainValidated(ctx context.Context, accountID, domain string) bool {
|
||||
if _, err := m.DeriveClusterFromDomain(ctx, accountID, domain); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domain": domain,
|
||||
}).WithError(err).Debug("domain is not servable")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]string, error) {
|
||||
byopAddresses, err := m.proxyManager.GetActiveClusterAddressesForAccount(ctx, accountID)
|
||||
if err != nil {
|
||||
@@ -477,158 +330,19 @@ func (m Manager) getClusterAllowList(ctx context.Context, accountID string) ([]s
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// customDomainMatch describes how a service domain relates to the account's
|
||||
// custom domain rows.
|
||||
type customDomainMatch int
|
||||
|
||||
const (
|
||||
customDomainNoMatch customDomainMatch = iota
|
||||
customDomainUnvalidated
|
||||
customDomainValidated
|
||||
)
|
||||
|
||||
// extractClusterFromCustomDomains finds the longest custom domain covering the
|
||||
// service domain and reports its target cluster. Only a validated row yields a
|
||||
// cluster: a row whose CNAME check has never succeeded proves no control over
|
||||
// the name, so serving traffic for it would route a hostname the account has
|
||||
// not shown it owns.
|
||||
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, customDomainMatch) {
|
||||
func extractClusterFromCustomDomains(serviceDomain string, customDomains []*domain.Domain) (string, bool) {
|
||||
bestCluster := ""
|
||||
bestLen := -1
|
||||
matched := false
|
||||
for _, cd := range customDomains {
|
||||
if serviceDomain != cd.Domain && !strings.HasSuffix(serviceDomain, "."+cd.Domain) {
|
||||
continue
|
||||
}
|
||||
matched = true
|
||||
if !cd.Validated {
|
||||
continue
|
||||
}
|
||||
if l := len(cd.Domain); l > bestLen {
|
||||
bestLen = l
|
||||
bestCluster = cd.TargetCluster
|
||||
}
|
||||
}
|
||||
|
||||
switch {
|
||||
case bestLen >= 0:
|
||||
return bestCluster, customDomainValidated
|
||||
case matched:
|
||||
return "", customDomainUnvalidated
|
||||
default:
|
||||
return "", customDomainNoMatch
|
||||
}
|
||||
}
|
||||
|
||||
// StartMaintenance runs claim expiry and revalidation until the context is
|
||||
// cancelled. A first pass runs immediately so a restart does not leave stale
|
||||
// claims parked for a whole interval.
|
||||
func (m Manager) StartMaintenance(ctx context.Context) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(maintenanceInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
m.RunMaintenance(ctx)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.RunMaintenance(ctx)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// RunMaintenance evicts expired claims and re-checks validated domains once.
|
||||
func (m Manager) RunMaintenance(ctx context.Context) {
|
||||
domains, err := m.store.ListAllCustomDomains(ctx)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).WithError(err).Error("list custom domains for maintenance")
|
||||
return
|
||||
}
|
||||
|
||||
for _, d := range domains {
|
||||
if !d.Validated {
|
||||
m.evictExpiredClaim(ctx, d)
|
||||
continue
|
||||
}
|
||||
m.revalidate(ctx, d)
|
||||
}
|
||||
}
|
||||
|
||||
// evictExpiredClaim deletes an unvalidated row that has held its name for
|
||||
// longer than claimTTL. A row with no claim date is left alone: it predates the
|
||||
// column and the migration backfill dates it from the upgrade.
|
||||
func (m Manager) evictExpiredClaim(ctx context.Context, d *domain.Domain) {
|
||||
if d.ClaimedAt.IsZero() || m.currentTime().Sub(d.ClaimedAt) <= claimTTL {
|
||||
return
|
||||
}
|
||||
|
||||
if err := m.store.DeleteCustomDomain(ctx, d.AccountID, d.ID); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": d.AccountID,
|
||||
"domainID": d.ID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("evict expired domain claim")
|
||||
return
|
||||
}
|
||||
|
||||
m.revalidationFails.clear(d.ID)
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": d.AccountID,
|
||||
"domainID": d.ID,
|
||||
"domain": d.Domain,
|
||||
}).Info("evicted domain claim that was never validated")
|
||||
|
||||
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID, activity.DomainDeleted, d.EventMeta())
|
||||
}
|
||||
|
||||
// revalidate re-checks a validated domain against its target cluster and, once
|
||||
// the check has failed revalidationFailureThreshold times in a row, marks it
|
||||
// unvalidated and restarts its claim window. Without this an account that moves
|
||||
// its DNS elsewhere keeps the name reserved forever.
|
||||
func (m Manager) revalidate(ctx context.Context, d *domain.Domain) {
|
||||
if d.TargetCluster == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if m.validator.IsValid(ctx, d.Domain, []string{d.TargetCluster}) {
|
||||
m.revalidationFails.clear(d.ID)
|
||||
return
|
||||
}
|
||||
|
||||
failures := m.revalidationFails.record(d.ID)
|
||||
if failures < revalidationFailureThreshold {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": d.AccountID,
|
||||
"domainID": d.ID,
|
||||
"domain": d.Domain,
|
||||
"failures": failures,
|
||||
}).Warn("domain revalidation failed, waiting for confirmation")
|
||||
return
|
||||
}
|
||||
|
||||
d.Validated = false
|
||||
d.ClaimedAt = m.currentTime()
|
||||
if _, err := m.store.UpdateCustomDomain(ctx, d.AccountID, d); err != nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": d.AccountID,
|
||||
"domainID": d.ID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("mark domain unvalidated after revalidation failure")
|
||||
return
|
||||
}
|
||||
|
||||
m.revalidationFails.clear(d.ID)
|
||||
log.WithContext(ctx).WithFields(log.Fields{
|
||||
"accountID": d.AccountID,
|
||||
"domainID": d.ID,
|
||||
"domain": d.Domain,
|
||||
"targetCluster": d.TargetCluster,
|
||||
}).Warn("domain marked unvalidated, CNAME no longer points at the target cluster")
|
||||
|
||||
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID, activity.DomainInvalidated, d.EventMeta())
|
||||
return bestCluster, bestLen >= 0
|
||||
}
|
||||
|
||||
// ExtractClusterFromFreeDomain extracts the cluster address from a free domain.
|
||||
|
||||
@@ -1,407 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const (
|
||||
testCluster = "eu.proxy.test"
|
||||
accountA = "account-a"
|
||||
accountAUser = "account-a-admin"
|
||||
accountB = "account-b"
|
||||
accountBUser = "account-b-admin"
|
||||
)
|
||||
|
||||
// stubResolver answers CNAME lookups from a table the test controls, so DNS
|
||||
// state can change between calls without touching a real resolver.
|
||||
type stubResolver struct {
|
||||
mu sync.Mutex
|
||||
cnames map[string]string
|
||||
}
|
||||
|
||||
func (r *stubResolver) LookupCNAME(_ context.Context, host string) (string, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cname, ok := r.cnames[host]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("lookup %s: no such host", host)
|
||||
}
|
||||
return cname + ".", nil
|
||||
}
|
||||
|
||||
func (r *stubResolver) set(host, cname string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.cnames[host] = cname
|
||||
}
|
||||
|
||||
func (r *stubResolver) remove(host string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
delete(r.cnames, host)
|
||||
}
|
||||
|
||||
// fakeClock drives claim expiry without sleeping.
|
||||
type fakeClock struct {
|
||||
mu sync.Mutex
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (c *fakeClock) Now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.now
|
||||
}
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.now = c.now.Add(d)
|
||||
}
|
||||
|
||||
type domainTestEnv struct {
|
||||
manager Manager
|
||||
store nbstore.Store
|
||||
resolver *stubResolver
|
||||
clock *fakeClock
|
||||
}
|
||||
|
||||
// setupDomainTest builds the domain manager on a real SQLite store with two
|
||||
// accounts and one active public proxy cluster.
|
||||
func setupDomainTest(t *testing.T) *domainTestEnv {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
testStore, cleanup, err := nbstore.NewTestStoreFromSQL(ctx, "", t.TempDir())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
for accountID, userID := range map[string]string{accountA: accountAUser, accountB: accountBUser} {
|
||||
require.NoError(t, testStore.SaveAccount(ctx, &types.Account{
|
||||
Id: accountID,
|
||||
CreatedBy: userID,
|
||||
Settings: &types.Settings{},
|
||||
Users: map[string]*types.User{
|
||||
userID: {
|
||||
Id: userID,
|
||||
AccountID: accountID,
|
||||
Role: types.UserRoleAdmin,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", testCluster, "127.0.0.1", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resolver := &stubResolver{cnames: make(map[string]string)}
|
||||
clock := &fakeClock{now: time.Now().UTC()}
|
||||
|
||||
mgr := Manager{
|
||||
store: testStore,
|
||||
proxyManager: proxyMgr,
|
||||
validator: domain.Validator{Resolver: resolver},
|
||||
permissionsManager: permissions.NewManager(testStore),
|
||||
accountManager: &mock_server.MockAccountManager{
|
||||
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
|
||||
},
|
||||
now: clock.Now,
|
||||
revalidationFails: &failureTracker{counts: make(map[string]int)},
|
||||
}
|
||||
|
||||
return &domainTestEnv{manager: mgr, store: testStore, resolver: resolver, clock: clock}
|
||||
}
|
||||
|
||||
// storedDomain reads a domain row back through the store so assertions are made
|
||||
// on what was persisted rather than on the value the manager returned.
|
||||
func storedDomain(t *testing.T, s nbstore.Store, accountID, domainName string) *domain.Domain {
|
||||
t.Helper()
|
||||
|
||||
domains, err := s.ListCustomDomains(context.Background(), accountID)
|
||||
require.NoError(t, err)
|
||||
for _, d := range domains {
|
||||
if d.Domain == domainName {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A domain whose CNAME check fails must be stored unvalidated and must not be
|
||||
// usable to derive a proxy cluster, which is what service creation gates on.
|
||||
func TestCreateDomain_FailedLookupIsNotServable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "apps.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, created.Validated, "a domain whose CNAME lookup fails must not be created validated")
|
||||
|
||||
stored := storedDomain(t, env.store, accountA, "apps.example.com")
|
||||
require.NotNil(t, stored, "domain row should exist")
|
||||
assert.False(t, stored.Validated, "persisted row must be unvalidated")
|
||||
assert.False(t, stored.ClaimedAt.IsZero(), "claim must be dated so it can expire")
|
||||
|
||||
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "apps.example.com")
|
||||
require.Error(t, err, "an unvalidated domain must not resolve a cluster")
|
||||
assert.Empty(t, cluster)
|
||||
assert.Contains(t, err.Error(), "not validated", "error should tell the caller what to fix")
|
||||
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "error should be a typed status error")
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type())
|
||||
|
||||
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "apps.example.com"))
|
||||
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "sub.apps.example.com"),
|
||||
"subdomains of an unvalidated custom domain are not servable either")
|
||||
}
|
||||
|
||||
// Two accounts may both request a name nobody has proven they own. The second
|
||||
// request must be an ordinary success, never a database-level failure.
|
||||
func TestCreateDomain_UnvalidatedClaimDoesNotBlockOtherAccounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "shared.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
second, err := env.manager.CreateDomain(ctx, accountB, accountBUser, "shared.example.com", testCluster)
|
||||
require.NoError(t, err, "an unvalidated claim by another account must not block the request")
|
||||
assert.False(t, second.Validated)
|
||||
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountA, "shared.example.com"))
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountB, "shared.example.com"))
|
||||
}
|
||||
|
||||
// Once an account serves a name, another account asking for it gets a clean
|
||||
// conflict that says nothing about who holds it.
|
||||
func TestCreateDomain_ValidatedDomainConflictsForOtherAccounts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation.owned.example.com", testCluster)
|
||||
|
||||
first, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "owned.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.True(t, first.Validated, "matching CNAME should validate on create")
|
||||
|
||||
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "owned.example.com", testCluster)
|
||||
require.Error(t, err)
|
||||
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "conflict must be a typed status error, not a raw database error")
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "conflict should map to 409, not 500")
|
||||
assert.NotContains(t, sErr.Message, accountA, "the response must not reveal the holding account")
|
||||
|
||||
assert.Nil(t, storedDomain(t, env.store, accountB, "owned.example.com"), "no row should be written on conflict")
|
||||
}
|
||||
|
||||
// Re-requesting a domain the account already holds is a conflict too.
|
||||
func TestCreateDomain_SameAccountDuplicateConflicts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = env.manager.CreateDomain(ctx, accountA, accountAUser, "dup.example.com", testCluster)
|
||||
require.Error(t, err)
|
||||
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type())
|
||||
}
|
||||
|
||||
// The negative control: a domain that genuinely validates still derives its
|
||||
// cluster exactly as before.
|
||||
func TestCreateDomain_ValidatedDomainDerivesCluster(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation.valid.example.com", testCluster)
|
||||
|
||||
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "valid.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.True(t, created.Validated)
|
||||
|
||||
cluster, err := env.manager.DeriveClusterFromDomain(ctx, accountA, "valid.example.com")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, testCluster, cluster)
|
||||
|
||||
cluster, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "app.valid.example.com")
|
||||
require.NoError(t, err, "subdomains of a validated custom domain resolve too")
|
||||
assert.Equal(t, testCluster, cluster)
|
||||
|
||||
assert.True(t, env.manager.IsDomainValidated(ctx, accountA, "valid.example.com"))
|
||||
}
|
||||
|
||||
// Free cluster domains never need a custom domain row.
|
||||
func TestIsDomainValidated_FreeDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
assert.True(t, env.manager.IsDomainValidated(ctx, accountA, "myapp.abc123."+testCluster))
|
||||
assert.False(t, env.manager.IsDomainValidated(ctx, accountA, "myapp.unknown.test"))
|
||||
}
|
||||
|
||||
// An account that never proves control loses the name after claimTTL, freeing
|
||||
// it instead of parking it forever.
|
||||
func TestRunMaintenance_EvictsExpiredClaim(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "stale.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.clock.advance(claimTTL - time.Minute)
|
||||
env.manager.RunMaintenance(ctx)
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountA, "stale.example.com"),
|
||||
"claim must survive while it is still inside the window")
|
||||
|
||||
env.clock.advance(2 * time.Minute)
|
||||
env.manager.RunMaintenance(ctx)
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, "stale.example.com"),
|
||||
"claim must be evicted once it has outlived the window")
|
||||
}
|
||||
|
||||
// A validated domain is never evicted, however old the claim.
|
||||
func TestRunMaintenance_KeepsValidatedDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation.live.example.com", testCluster)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "live.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.clock.advance(30 * 24 * time.Hour)
|
||||
env.manager.RunMaintenance(ctx)
|
||||
|
||||
stored := storedDomain(t, env.store, accountA, "live.example.com")
|
||||
require.NotNil(t, stored, "a validated domain must not be evicted")
|
||||
assert.True(t, stored.Validated)
|
||||
}
|
||||
|
||||
// Moving DNS away flips the domain back to unvalidated and restarts its claim
|
||||
// window, so the name eventually becomes available again.
|
||||
func TestRunMaintenance_RevalidationFlipsAndThenEvicts(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation.moved.example.com", testCluster)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "moved.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.True(t, storedDomain(t, env.store, accountA, "moved.example.com").Validated)
|
||||
|
||||
env.resolver.remove("validation.moved.example.com")
|
||||
|
||||
// A single failed lookup is treated as a transient resolver problem.
|
||||
env.manager.RunMaintenance(ctx)
|
||||
assert.True(t, storedDomain(t, env.store, accountA, "moved.example.com").Validated,
|
||||
"one failed lookup must not unvalidate a live domain")
|
||||
|
||||
for i := 1; i < revalidationFailureThreshold; i++ {
|
||||
env.manager.RunMaintenance(ctx)
|
||||
}
|
||||
|
||||
flipped := storedDomain(t, env.store, accountA, "moved.example.com")
|
||||
require.NotNil(t, flipped)
|
||||
assert.False(t, flipped.Validated, "repeated failures must unvalidate the domain")
|
||||
assert.Equal(t, env.clock.Now(), flipped.ClaimedAt.UTC(), "the claim window restarts on the flip")
|
||||
|
||||
_, err = env.manager.DeriveClusterFromDomain(ctx, accountA, "moved.example.com")
|
||||
assert.Error(t, err, "an unvalidated domain stops resolving a cluster")
|
||||
|
||||
env.clock.advance(claimTTL + time.Minute)
|
||||
env.manager.RunMaintenance(ctx)
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, "moved.example.com"),
|
||||
"the flipped claim expires like any other unvalidated claim")
|
||||
}
|
||||
|
||||
// DNS coming back before the threshold clears the failure history.
|
||||
func TestRunMaintenance_RecoveredLookupResetsFailures(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation.flaky.example.com", testCluster)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "flaky.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.resolver.remove("validation.flaky.example.com")
|
||||
for i := 1; i < revalidationFailureThreshold; i++ {
|
||||
env.manager.RunMaintenance(ctx)
|
||||
}
|
||||
|
||||
env.resolver.set("validation.flaky.example.com", testCluster)
|
||||
for i := 0; i < revalidationFailureThreshold; i++ {
|
||||
env.manager.RunMaintenance(ctx)
|
||||
}
|
||||
|
||||
stored := storedDomain(t, env.store, accountA, "flaky.example.com")
|
||||
require.NotNil(t, stored)
|
||||
assert.True(t, stored.Validated, "a recovered lookup must not carry stale failure counts")
|
||||
}
|
||||
|
||||
// A validated domain another account already serves cannot be taken over, even
|
||||
// when the second account's CNAME check passes: the check proves zone control,
|
||||
// not who asked.
|
||||
func TestValidateDomain_DoesNotStealValidatedDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountB, accountBUser, "contested.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.resolver.set("validation.contested.example.com", testCluster)
|
||||
first, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "contested.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.True(t, first.Validated)
|
||||
|
||||
claim := storedDomain(t, env.store, accountB, "contested.example.com")
|
||||
require.NotNil(t, claim)
|
||||
env.manager.ValidateDomain(ctx, accountB, accountBUser, claim.ID)
|
||||
|
||||
assert.False(t, storedDomain(t, env.store, accountB, "contested.example.com").Validated,
|
||||
"the late claimant must not validate a name another account already serves")
|
||||
assert.True(t, storedDomain(t, env.store, accountA, "contested.example.com").Validated)
|
||||
}
|
||||
|
||||
// Domains are bounded per account: every one of them becomes an ACME order
|
||||
// against an issuance rate limit shared with every other account on the cluster.
|
||||
func TestCreateDomain_EnforcesPerAccountQuota(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
|
||||
for i := 0; i < maxCustomDomainsPerAccount; i++ {
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, fmt.Sprintf("app-%d.example.com", i), testCluster)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "one-too-many.example.com", testCluster)
|
||||
require.Error(t, err)
|
||||
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type())
|
||||
|
||||
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, "other-account.example.com", testCluster)
|
||||
assert.NoError(t, err, "the quota is per account")
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel/metric/noop"
|
||||
|
||||
domainmanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
proxymanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy/manager"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
"github.com/netbirdio/netbird/management/server/permissions"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
const validationTestCluster = "eu.proxy.test"
|
||||
|
||||
// withRealDomainManager swaps the stub cluster deriver for the real domain
|
||||
// manager backed by the same store, so service creation is gated by the actual
|
||||
// domain rows rather than by a test double that always agrees.
|
||||
func withRealDomainManager(t *testing.T, mgr *Manager, testStore store.Store) {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
proxyMgr, err := proxymanager.NewManager(testStore, noop.NewMeterProvider().Meter(""))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = proxyMgr.Connect(ctx, "proxy-1", "session-1", validationTestCluster, "127.0.0.1", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
accountMgr := &mock_server.MockAccountManager{
|
||||
StoreEventFunc: func(context.Context, string, string, string, activity.ActivityDescriber, map[string]any) {},
|
||||
}
|
||||
domainMgr := domainmanager.NewManager(testStore, proxyMgr, permissions.NewManager(testStore), accountMgr)
|
||||
mgr.clusterDeriver = domainMgr
|
||||
}
|
||||
|
||||
func newTestService(domain string) *rpservice.Service {
|
||||
return &rpservice.Service{
|
||||
Name: "test-service",
|
||||
Domain: domain,
|
||||
Enabled: true,
|
||||
Mode: rpservice.ModeHTTP,
|
||||
Targets: []*rpservice.Target{{
|
||||
Host: "10.0.0.1",
|
||||
Port: 8080,
|
||||
Protocol: "http",
|
||||
TargetId: testPeerID,
|
||||
TargetType: "peer",
|
||||
Enabled: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// A service must not bind to a domain the account never proved it owns, and
|
||||
// nothing may be persisted for the attempt.
|
||||
func TestCreateService_RefusesUnvalidatedDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
|
||||
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = mgr.CreateService(ctx, testAccountID, testUserID, newTestService("unproven.example.com"))
|
||||
require.Error(t, err, "an unvalidated domain must not bind a service")
|
||||
assert.Contains(t, err.Error(), "not validated", "the API error should name the actual problem")
|
||||
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "error should be a typed status error")
|
||||
assert.Equal(t, status.PreconditionFailed, sErr.Type())
|
||||
|
||||
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, services, "no service row should be written for a refused domain")
|
||||
}
|
||||
|
||||
// The negative control: a validated domain still binds a service and derives
|
||||
// its cluster exactly as before.
|
||||
func TestCreateService_ValidatedDomainBindsService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
|
||||
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, validationTestCluster, created.ProxyCluster, "service should bind to the domain's target cluster")
|
||||
|
||||
services, err := testStore.GetAccountServices(ctx, store.LockingStrengthNone, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, services, 1, "the service should be persisted")
|
||||
assert.Equal(t, "app.proven.example.com", services[0].Domain)
|
||||
}
|
||||
|
||||
// The mapping the proxy receives carries management's verdict, so the proxy can
|
||||
// refuse the route on its own if this gate is ever regressed.
|
||||
func TestProtoMapping_CarriesDomainValidation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
|
||||
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
_, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
validated := newTestService("proven.example.com")
|
||||
validated.AccountID = testAccountID
|
||||
mapping := mgr.protoMapping(ctx, rpservice.Create, validated, proxy.OIDCValidationConfig{})
|
||||
assert.True(t, mapping.GetDomainValidated(), "a validated domain should be advertised as such")
|
||||
|
||||
unvalidated := newTestService("unproven.example.com")
|
||||
unvalidated.AccountID = testAccountID
|
||||
mapping = mgr.protoMapping(ctx, rpservice.Create, unvalidated, proxy.OIDCValidationConfig{})
|
||||
assert.False(t, mapping.GetDomainValidated(), "an unvalidated domain must be advertised as unvalidated")
|
||||
|
||||
free := newTestService("myapp.abc123." + validationTestCluster)
|
||||
free.AccountID = testAccountID
|
||||
mapping = mgr.protoMapping(ctx, rpservice.Create, free, proxy.OIDCValidationConfig{})
|
||||
assert.True(t, mapping.GetDomainValidated(), "free cluster domains need no ownership proof")
|
||||
}
|
||||
|
||||
// Without a cluster deriver there is nothing that can answer the ownership
|
||||
// question, so the mapping must be advertised as unvalidated.
|
||||
func TestProtoMapping_DeniesWithoutClusterDeriver(t *testing.T) {
|
||||
mgr := &Manager{}
|
||||
|
||||
svc := newTestService("apps.example.com")
|
||||
svc.AccountID = testAccountID
|
||||
|
||||
mapping := mgr.protoMapping(context.Background(), rpservice.Create, svc, proxy.OIDCValidationConfig{})
|
||||
assert.False(t, mapping.GetDomainValidated())
|
||||
}
|
||||
|
||||
// An update must not be a way around the creation gate: moving a live service
|
||||
// onto an unvalidated domain has to fail rather than silently keep the old
|
||||
// cluster and start serving the new hostname.
|
||||
func TestUpdateService_RefusesMoveToUnvalidatedDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mgr, testStore := setupIntegrationTest(t)
|
||||
withRealDomainManager(t, mgr, testStore)
|
||||
|
||||
_, err := testStore.CreateCustomDomain(ctx, testAccountID, "proven.example.com", validationTestCluster, true, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
_, err = testStore.CreateCustomDomain(ctx, testAccountID, "unproven.example.com", validationTestCluster, false, time.Now().UTC())
|
||||
require.NoError(t, err)
|
||||
|
||||
created, err := mgr.CreateService(ctx, testAccountID, testUserID, newTestService("app.proven.example.com"))
|
||||
require.NoError(t, err)
|
||||
|
||||
moved := *created
|
||||
moved.Domain = "app.unproven.example.com"
|
||||
_, err = mgr.UpdateService(ctx, testAccountID, testUserID, &moved)
|
||||
require.Error(t, err, "moving to an unvalidated domain must fail")
|
||||
assert.Contains(t, err.Error(), "not validated")
|
||||
|
||||
stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "app.proven.example.com", stored.Domain, "the service must keep its original domain")
|
||||
}
|
||||
@@ -27,7 +27,6 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/permissions/operations"
|
||||
"github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -72,12 +71,10 @@ func portFromEnv(key string, fallback uint16) uint16 {
|
||||
|
||||
const unknownHostPlaceholder = "unknown"
|
||||
|
||||
// ClusterDeriver derives the proxy cluster from a domain and reports whether
|
||||
// the account is allowed to serve it.
|
||||
// ClusterDeriver derives the proxy cluster from a domain.
|
||||
type ClusterDeriver interface {
|
||||
DeriveClusterFromDomain(ctx context.Context, accountID, domain string) (string, error)
|
||||
GetClusterDomains() []string
|
||||
IsDomainValidated(ctx context.Context, accountID, domain string) bool
|
||||
}
|
||||
|
||||
// CapabilityProvider queries proxy cluster capabilities from the database.
|
||||
@@ -269,28 +266,13 @@ func (m *Manager) CreateService(ctx context.Context, accountID, userID string, s
|
||||
return nil, fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
|
||||
}
|
||||
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// protoMapping renders a service into the wire form the proxy consumes,
|
||||
// resolving whether the account may currently serve the service's domain.
|
||||
func (m *Manager) protoMapping(ctx context.Context, operation service.Operation, s *service.Service, oidcCfg proxy.OIDCValidationConfig) *proto.ProxyMapping {
|
||||
return s.ToProtoMapping(operation, "", oidcCfg, m.domainValidated(ctx, s))
|
||||
}
|
||||
|
||||
// domainValidated reports whether the service's domain may be served. Without a
|
||||
// cluster deriver there is nothing that can answer the question, so it denies.
|
||||
func (m *Manager) domainValidated(ctx context.Context, s *service.Service) bool {
|
||||
if m.clusterDeriver == nil {
|
||||
return false
|
||||
}
|
||||
return m.clusterDeriver.IsDomainValidated(ctx, s.AccountID, s.Domain)
|
||||
}
|
||||
|
||||
func (m *Manager) initializeServiceForCreate(ctx context.Context, accountID string, service *service.Service) error {
|
||||
if m.clusterDeriver != nil {
|
||||
proxyCluster, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, service.Domain)
|
||||
@@ -624,19 +606,16 @@ func (m *Manager) resolveEffectiveCluster(ctx context.Context, accountID string,
|
||||
return existing.ProxyCluster, nil
|
||||
}
|
||||
|
||||
if m.clusterDeriver == nil {
|
||||
return existing.ProxyCluster, nil
|
||||
if m.clusterDeriver != nil {
|
||||
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("could not derive cluster from domain %s", svc.Domain)
|
||||
} else {
|
||||
return derived, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Falling back to the old cluster here would let an update move a service
|
||||
// onto a domain the account has not proven it owns, bypassing the same
|
||||
// check creation makes.
|
||||
derived, err := m.clusterDeriver.DeriveClusterFromDomain(ctx, accountID, svc.Domain)
|
||||
if err != nil {
|
||||
return "", status.Errorf(status.PreconditionFailed, "could not derive cluster from domain %s: %v", svc.Domain, err)
|
||||
}
|
||||
|
||||
return derived, nil
|
||||
return existing.ProxyCluster, nil
|
||||
}
|
||||
|
||||
func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.Store, accountID string, service *service.Service, updateInfo *serviceUpdateInfo, customPorts *bool, effectiveCluster string) error {
|
||||
@@ -807,14 +786,14 @@ func (m *Manager) sendServiceUpdateNotifications(ctx context.Context, accountID
|
||||
|
||||
switch {
|
||||
case updateInfo.domainChanged || updateInfo.oldCluster != s.ProxyCluster:
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, oidcCfg), updateInfo.oldCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, oidcCfg), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), updateInfo.oldCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster)
|
||||
case !s.Enabled && updateInfo.serviceEnabledChanged:
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, oidcCfg), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", oidcCfg), s.ProxyCluster)
|
||||
case s.Enabled && updateInfo.serviceEnabledChanged:
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, s, oidcCfg), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Create, "", oidcCfg), s.ProxyCluster)
|
||||
default:
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, oidcCfg), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -914,7 +893,7 @@ func (m *Manager) DeleteService(ctx context.Context, accountID, userID, serviceI
|
||||
|
||||
m.accountManager.StoreEvent(ctx, userID, serviceID, accountID, activity.ServiceDeleted, s.EventMeta())
|
||||
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
|
||||
|
||||
@@ -958,7 +937,7 @@ func (m *Manager) DeleteAllServices(ctx context.Context, accountID, userID strin
|
||||
|
||||
for _, svc := range services {
|
||||
m.accountManager.StoreEvent(ctx, userID, svc.ID, accountID, activity.ServiceDeleted, svc.EventMeta())
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, oidcCfg), svc.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", oidcCfg), svc.ProxyCluster)
|
||||
}
|
||||
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
|
||||
@@ -1015,7 +994,7 @@ func (m *Manager) ReloadService(ctx context.Context, accountID, serviceID string
|
||||
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
|
||||
}
|
||||
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", m.proxyController.GetOIDCValidationConfig()), s.ProxyCluster)
|
||||
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationUpdate})
|
||||
|
||||
@@ -1035,7 +1014,7 @@ func (m *Manager) ReloadAllServicesForAccount(ctx context.Context, accountID str
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to replace host by lookup for service %s: %w", s.ID, err)
|
||||
}
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Update, s, oidcCfg), s.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, s.ToProtoMapping(service.Update, "", oidcCfg), s.ProxyCluster)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1204,7 +1183,7 @@ func (m *Manager) CreateServiceFromPeer(ctx context.Context, accountID, peerID s
|
||||
return nil, fmt.Errorf("replace host by lookup for service %s: %w", svc.ID, err)
|
||||
}
|
||||
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Create, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Create, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationCreate})
|
||||
|
||||
serviceURL := "https://" + svc.Domain
|
||||
@@ -1319,7 +1298,7 @@ func (m *Manager) deletePeerService(ctx context.Context, accountID, peerID, serv
|
||||
|
||||
m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activityCode, meta)
|
||||
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
|
||||
|
||||
@@ -1375,7 +1354,7 @@ func (m *Manager) deleteExpiredPeerService(ctx context.Context, accountID, peerI
|
||||
|
||||
meta := addPeerInfoToEventMeta(svc.EventMeta(), peer)
|
||||
m.accountManager.StoreEvent(ctx, peerID, serviceID, accountID, activity.PeerServiceExposeExpired, meta)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, m.protoMapping(ctx, service.Delete, svc, m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
m.proxyController.SendServiceUpdateToCluster(ctx, accountID, svc.ToProtoMapping(service.Delete, "", m.proxyController.GetOIDCValidationConfig()), svc.ProxyCluster)
|
||||
m.accountManager.UpdateAccountPeers(ctx, accountID, types.UpdateReason{Resource: types.UpdateResourceService, Operation: types.UpdateOperationDelete})
|
||||
|
||||
return nil
|
||||
|
||||
@@ -655,10 +655,6 @@ func (d *testClusterDeriver) GetClusterDomains() []string {
|
||||
return d.domains
|
||||
}
|
||||
|
||||
func (d *testClusterDeriver) IsDomainValidated(_ context.Context, _, _ string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
const (
|
||||
testAccountID = "test-account"
|
||||
testPeerID = "test-peer-1"
|
||||
|
||||
@@ -370,10 +370,7 @@ func (s *Service) ToAPIResponse() *api.Service {
|
||||
}
|
||||
|
||||
// ToProtoMapping converts the service into the wire format the proxy consumes.
|
||||
// domainValidated tells the proxy whether the account is allowed to serve the
|
||||
// service's domain; it is a required argument rather than a struct field so
|
||||
// every producer of a mapping has to answer the question.
|
||||
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig, domainValidated bool) *proto.ProxyMapping {
|
||||
func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConfig proxy.OIDCValidationConfig) *proto.ProxyMapping {
|
||||
pathMappings := s.buildPathMappings()
|
||||
|
||||
auth := &proto.Authentication{
|
||||
@@ -415,7 +412,6 @@ func (s *Service) ToProtoMapping(operation Operation, authToken string, oidcConf
|
||||
Mode: s.Mode,
|
||||
ListenPort: int32(s.ListenPort), //nolint:gosec
|
||||
Private: s.Private,
|
||||
DomainValidated: domainValidated,
|
||||
}
|
||||
|
||||
if r := restrictionsToProto(s.Restrictions); r != nil {
|
||||
|
||||
@@ -238,7 +238,7 @@ func TestToProtoMapping_TargetOptions(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||
require.Len(t, pm.Path, 1)
|
||||
|
||||
opts := pm.Path[0].Options
|
||||
@@ -266,7 +266,7 @@ func TestToProtoMapping_NoOptionsWhenDefault(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||
require.Len(t, pm.Path, 1)
|
||||
assert.Nil(t, pm.Path[0].Options, "options should be nil when all defaults")
|
||||
}
|
||||
@@ -447,7 +447,7 @@ func TestToProtoMapping_PortInTargetURL(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
pm := rp.ToProtoMapping(Create, "token", oidcConfig, true)
|
||||
pm := rp.ToProtoMapping(Create, "token", oidcConfig)
|
||||
require.Len(t, pm.Path, 1, "should have one path mapping")
|
||||
assert.Equal(t, tt.wantTarget, pm.Path[0].Target)
|
||||
})
|
||||
@@ -464,7 +464,7 @@ func TestToProtoMapping_DisabledTargetSkipped(t *testing.T) {
|
||||
{TargetId: "peer-2", TargetType: TargetTypePeer, Host: "10.0.0.2", Port: 9090, Protocol: "http", Enabled: true},
|
||||
},
|
||||
}
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}, true)
|
||||
pm := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{})
|
||||
require.Len(t, pm.Path, 1)
|
||||
assert.Equal(t, "http://10.0.0.2:9090/", pm.Path[0].Target)
|
||||
}
|
||||
@@ -481,7 +481,7 @@ func TestToProtoMapping_OperationTypes(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.op), func(t *testing.T) {
|
||||
pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{}, true)
|
||||
pm := rp.ToProtoMapping(tt.op, "", proxy.OIDCValidationConfig{})
|
||||
assert.Equal(t, tt.want, pm.Type)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -231,7 +231,6 @@ func (s *BaseServer) ReverseProxyGRPCServer() *nbgrpc.ProxyServiceServer {
|
||||
proxyService := nbgrpc.NewProxyServiceServer(s.AccessLogsManager(), s.ProxyTokenStore(), s.PKCEVerifierStore(), s.proxyOIDCConfig(), s.PeersManager(), s.UsersManager(), s.IdpManager(), s.ProxyManager(), s.Store())
|
||||
s.AfterInit(func(s *BaseServer) {
|
||||
proxyService.SetServiceManager(s.ServiceManager())
|
||||
proxyService.SetDomainValidator(s.ReverseProxyDomainManager())
|
||||
proxyService.SetProxyController(s.ServiceProxyController())
|
||||
proxyService.SetAgentNetworkSynthesizer(newAgentNetworkSynthesizer(s.Store()))
|
||||
proxyService.SetAgentNetworkLimitsService(s.AgentNetworkManager())
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain/manager"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
recordsManager "github.com/netbirdio/netbird/management/internals/modules/zones/records/manager"
|
||||
"github.com/netbirdio/netbird/management/server"
|
||||
"github.com/netbirdio/netbird/management/server/account"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/geolocation"
|
||||
"github.com/netbirdio/netbird/management/server/groups"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
@@ -244,7 +244,6 @@ func (s *BaseServer) ProxyManager() proxy.Manager {
|
||||
func (s *BaseServer) ReverseProxyDomainManager() *manager.Manager {
|
||||
return Create(s, func() *manager.Manager {
|
||||
m := manager.NewManager(s.Store(), s.ProxyManager(), s.PermissionsManager(), s.AccountManager())
|
||||
m.StartMaintenance(context.Background())
|
||||
return &m
|
||||
})
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/peers"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/accesslogs"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy"
|
||||
@@ -37,6 +36,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
|
||||
"github.com/netbirdio/netbird/management/server/idp"
|
||||
"github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
"github.com/netbirdio/netbird/management/server/users"
|
||||
proxyauth "github.com/netbirdio/netbird/proxy/auth"
|
||||
@@ -71,13 +71,6 @@ type AgentNetworkSynthesizer interface {
|
||||
SynthesizeServiceForDomain(ctx context.Context, domain string) (*rpservice.Service, error)
|
||||
}
|
||||
|
||||
// DomainValidator reports whether an account is allowed to serve a domain:
|
||||
// free cluster domains always, custom domains only once their CNAME ownership
|
||||
// check has passed.
|
||||
type DomainValidator interface {
|
||||
IsDomainValidated(ctx context.Context, accountID, domain string) bool
|
||||
}
|
||||
|
||||
// AgentNetworkLimitsService is the minimal slice of agentnetwork.Manager the
|
||||
// gRPC layer needs for CheckLLMPolicyLimits + RecordLLMUsage — kept narrow so
|
||||
// the grpc package doesn't take a hard import on the full manager.
|
||||
@@ -106,11 +99,6 @@ type ProxyServiceServer struct {
|
||||
// and the post-flight consumption write (RecordLLMUsage). Optional — when
|
||||
// nil both RPCs return Unimplemented.
|
||||
agentNetworkLimits AgentNetworkLimitsService
|
||||
// domainValidator answers whether an account may serve a given domain.
|
||||
// Optional only in the sense that partial wiring must not panic: while
|
||||
// unset the snapshot marks every mapping unvalidated and the proxy drops it.
|
||||
domainValidator DomainValidator
|
||||
|
||||
// ProxyController for service updates and cluster management
|
||||
proxyController proxy.Controller
|
||||
|
||||
@@ -248,15 +236,6 @@ func (s *ProxyServiceServer) SetServiceManager(manager rpservice.Manager) {
|
||||
s.serviceManager = manager
|
||||
}
|
||||
|
||||
// SetDomainValidator wires the source of truth for domain ownership. Must be
|
||||
// called before serving, otherwise every snapshot ships mappings the proxy
|
||||
// refuses to route.
|
||||
func (s *ProxyServiceServer) SetDomainValidator(validator DomainValidator) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.domainValidator = validator
|
||||
}
|
||||
|
||||
// SetAgentNetworkSynthesizer wires the agent-network service synthesiser.
|
||||
// Optional — when nil the snapshot path skips agent-network synthesis. The
|
||||
// modules layer injects this after both the proxy server and the agent-network
|
||||
@@ -283,12 +262,6 @@ func (s *ProxyServiceServer) agentNetworkSynthesizer() AgentNetworkSynthesizer {
|
||||
return s.agentNetworkSynth
|
||||
}
|
||||
|
||||
func (s *ProxyServiceServer) domainValidatorRef() DomainValidator {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.domainValidator
|
||||
}
|
||||
|
||||
// CheckLLMPolicyLimits is the pre-flight policy gate the proxy calls before
|
||||
// forwarding an LLM request upstream. Delegates to the agent-network selector,
|
||||
// which scores applicable policies by remaining headroom and returns the
|
||||
@@ -822,14 +795,6 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *
|
||||
}
|
||||
|
||||
oidcCfg := s.GetOIDCValidationConfig()
|
||||
validator := s.domainValidatorRef()
|
||||
if validator == nil {
|
||||
log.WithContext(ctx).Error("domain validator is not wired, snapshot mappings will be rejected by the proxy")
|
||||
}
|
||||
// One verdict per distinct domain: a snapshot can carry many services and
|
||||
// the lookup hits the store.
|
||||
validated := make(map[string]bool)
|
||||
|
||||
var mappings []*proto.ProxyMapping
|
||||
for _, service := range services {
|
||||
if !service.Enabled || service.ProxyCluster == "" || service.ProxyCluster != conn.address {
|
||||
@@ -842,14 +807,7 @@ func (s *ProxyServiceServer) snapshotServiceMappings(ctx context.Context, conn *
|
||||
continue
|
||||
}
|
||||
|
||||
key := service.AccountID + "\x00" + service.Domain
|
||||
ok, seen := validated[key]
|
||||
if !seen {
|
||||
ok = validator != nil && validator.IsDomainValidated(ctx, service.AccountID, service.Domain)
|
||||
validated[key] = ok
|
||||
}
|
||||
|
||||
m := service.ToProtoMapping(rpservice.Create, "", oidcCfg, ok)
|
||||
m := service.ToProtoMapping(rpservice.Create, "", oidcCfg)
|
||||
if !proxyAcceptsMapping(conn, m) {
|
||||
continue
|
||||
}
|
||||
@@ -1206,7 +1164,6 @@ func shallowCloneMapping(m *proto.ProxyMapping) *proto.ProxyMapping {
|
||||
ListenPort: m.ListenPort,
|
||||
AccessRestrictions: m.AccessRestrictions,
|
||||
Private: m.Private,
|
||||
DomainValidated: m.DomainValidated,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -281,10 +281,6 @@ const (
|
||||
// AccountMetricsPushDisabled indicates that a user disabled metrics push for the account
|
||||
AccountMetricsPushDisabled Activity = 141
|
||||
|
||||
// DomainInvalidated indicates that a custom domain stopped pointing at its
|
||||
// target cluster and was marked unvalidated
|
||||
DomainInvalidated Activity = 142
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -461,10 +457,9 @@ var activityMap = map[Activity]Code{
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
DomainInvalidated: {"Domain invalidated", "domain.invalidate"},
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
}
|
||||
|
||||
// StringCode returns a string code of the activity
|
||||
|
||||
@@ -381,38 +381,6 @@ func DropIndex[T any](ctx context.Context, db *gorm.DB, indexName string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// DropUniqueConstraint removes a single-column unique constraint that a model
|
||||
// no longer declares. Dialects disagree on how such a constraint is stored:
|
||||
// SQLite and Postgres keep it as a table constraint, MySQL exposes it as an
|
||||
// index, so both are attempted before giving up.
|
||||
func DropUniqueConstraint[T any](ctx context.Context, db *gorm.DB, constraintName string) error {
|
||||
var model T
|
||||
|
||||
if !db.Migrator().HasTable(&model) {
|
||||
log.WithContext(ctx).Debugf("table for %T does not exist, no migration needed", model)
|
||||
return nil
|
||||
}
|
||||
|
||||
if db.Migrator().HasConstraint(&model, constraintName) {
|
||||
if err := db.Migrator().DropConstraint(&model, constraintName); err != nil {
|
||||
return fmt.Errorf("drop constraint %s: %w", constraintName, err)
|
||||
}
|
||||
log.WithContext(ctx).Infof("dropped unique constraint %s from table %T", constraintName, model)
|
||||
return nil
|
||||
}
|
||||
|
||||
if db.Migrator().HasIndex(&model, constraintName) {
|
||||
if err := db.Migrator().DropIndex(&model, constraintName); err != nil {
|
||||
return fmt.Errorf("drop index %s: %w", constraintName, err)
|
||||
}
|
||||
log.WithContext(ctx).Infof("dropped unique index %s from table %T", constraintName, model)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Debugf("unique constraint %s does not exist on table %T, no migration needed", constraintName, model)
|
||||
return nil
|
||||
}
|
||||
|
||||
func CreateIndexIfNotExists[T any](ctx context.Context, db *gorm.DB, indexName string, columns ...string) error {
|
||||
var model T
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/migration"
|
||||
nbpeer "github.com/netbirdio/netbird/management/server/peer"
|
||||
"github.com/netbirdio/netbird/management/server/testutil"
|
||||
@@ -737,83 +736,3 @@ func TestFoldCostAggregatesIntoBuckets_SkipsAlreadyMigrated(t *testing.T) {
|
||||
assert.InDelta(t, 0.004, row.OutputCostUSD, 1e-9)
|
||||
assert.InDelta(t, 0.01, row.TotalCostUSD(), 1e-9, "derived total sums the four buckets")
|
||||
}
|
||||
|
||||
// legacyDomain mirrors the domains table as it was before claims became
|
||||
// per-account: the domain column carries a global unique constraint and there
|
||||
// is no claim date.
|
||||
type legacyDomain struct {
|
||||
ID string `gorm:"unique;primaryKey"`
|
||||
Domain string `gorm:"unique"`
|
||||
AccountID string `gorm:"index"`
|
||||
TargetCluster string
|
||||
Validated bool
|
||||
}
|
||||
|
||||
func (legacyDomain) TableName() string { return "domains" }
|
||||
|
||||
// A database carrying an unvalidated row that blocks the name for everyone must
|
||||
// come out of the migration with that row dated and the name claimable by other
|
||||
// accounts, while duplicates within one account stay rejected.
|
||||
func TestDomainUniquenessMigration_WithBlockingUnvalidatedRow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Migrator().DropTable("domains"))
|
||||
})
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&legacyDomain{}), "failed to create the legacy domains table")
|
||||
|
||||
require.NoError(t, db.Create(&legacyDomain{
|
||||
ID: "dom-1",
|
||||
Domain: "shared.example.com",
|
||||
AccountID: "account-a",
|
||||
TargetCluster: "eu.proxy.test",
|
||||
}).Error)
|
||||
|
||||
blocked := db.Create(&legacyDomain{
|
||||
ID: "dom-2",
|
||||
Domain: "shared.example.com",
|
||||
AccountID: "account-b",
|
||||
}).Error
|
||||
require.Error(t, blocked, "the legacy schema must block the second account, otherwise this test proves nothing")
|
||||
|
||||
require.NoError(t, migration.DropUniqueConstraint[domain.Domain](ctx, db, "uni_domains_domain"))
|
||||
require.NoError(t, db.AutoMigrate(&domain.Domain{}))
|
||||
|
||||
migratedAt := time.Now().UTC()
|
||||
require.NoError(t, migration.MigrateNewField[domain.Domain](ctx, db, "claimed_at", migratedAt))
|
||||
|
||||
var existing domain.Domain
|
||||
require.NoError(t, db.Take(&existing, "id = ?", "dom-1").Error)
|
||||
assert.False(t, existing.ClaimedAt.IsZero(), "the pre-existing claim must be dated so it can expire")
|
||||
|
||||
require.NoError(t, db.Create(&domain.Domain{
|
||||
ID: "dom-3",
|
||||
Domain: "shared.example.com",
|
||||
AccountID: "account-b",
|
||||
}).Error, "another account must be able to claim a name nobody has validated")
|
||||
|
||||
duplicate := db.Create(&domain.Domain{
|
||||
ID: "dom-4",
|
||||
Domain: "shared.example.com",
|
||||
AccountID: "account-a",
|
||||
}).Error
|
||||
assert.Error(t, duplicate, "an account must not hold the same domain twice")
|
||||
}
|
||||
|
||||
// The migration is a no-op on a database that never had the global constraint.
|
||||
func TestDomainUniquenessMigration_Idempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
db := setupDatabase(t)
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, db.Migrator().DropTable("domains"))
|
||||
})
|
||||
|
||||
require.NoError(t, db.AutoMigrate(&domain.Domain{}))
|
||||
|
||||
require.NoError(t, migration.DropUniqueConstraint[domain.Domain](ctx, db, "uni_domains_domain"))
|
||||
require.NoError(t, migration.DropUniqueConstraint[domain.Domain](ctx, db, "uni_domains_domain"))
|
||||
|
||||
require.NoError(t, db.Create(&domain.Domain{ID: "dom-1", Domain: "a.example.com", AccountID: "account-a"}).Error)
|
||||
require.NoError(t, db.Create(&domain.Domain{ID: "dom-2", Domain: "a.example.com", AccountID: "account-b"}).Error)
|
||||
}
|
||||
|
||||
@@ -5658,32 +5658,7 @@ func (s *SqlStore) ListCustomDomains(ctx context.Context, accountID string) ([]*
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// ListCustomDomainsByName returns every custom domain row holding the given
|
||||
// name, across all accounts.
|
||||
func (s *SqlStore) ListCustomDomainsByName(ctx context.Context, domainName string) ([]*domain.Domain, error) {
|
||||
var domains []*domain.Domain
|
||||
result := s.db.Find(&domains, "domain = ?", domainName)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get reverse proxy custom domains by name from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store")
|
||||
}
|
||||
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
// ListAllCustomDomains returns every custom domain row in the store.
|
||||
func (s *SqlStore) ListAllCustomDomains(ctx context.Context) ([]*domain.Domain, error) {
|
||||
var domains []*domain.Domain
|
||||
result := s.db.Find(&domains)
|
||||
if result.Error != nil {
|
||||
log.WithContext(ctx).Errorf("failed to get all reverse proxy custom domains from the store: %s", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to get reverse proxy custom domains from store")
|
||||
}
|
||||
|
||||
return domains, nil
|
||||
}
|
||||
|
||||
func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool, claimedAt time.Time) (*domain.Domain, error) {
|
||||
func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error) {
|
||||
newDomain := &domain.Domain{
|
||||
ID: xid.New().String(), // Generate our own ID because gorm doesn't always configure the database to handle this for us.
|
||||
Domain: domainName,
|
||||
@@ -5691,18 +5666,9 @@ func (s *SqlStore) CreateCustomDomain(ctx context.Context, accountID string, dom
|
||||
TargetCluster: targetCluster,
|
||||
Type: domain.TypeCustom,
|
||||
Validated: validated,
|
||||
ClaimedAt: claimedAt,
|
||||
}
|
||||
result := s.db.Create(newDomain)
|
||||
if result.Error != nil {
|
||||
// The account/domain unique index is the backstop for a create that
|
||||
// races the manager's pre-check. Report it as a conflict rather than
|
||||
// letting a constraint violation surface as an internal error.
|
||||
var count int64
|
||||
if err := s.db.Model(&domain.Domain{}).Where("account_id = ? AND domain = ?", accountID, domainName).Count(&count).Error; err == nil && count > 0 {
|
||||
return nil, status.Errorf(status.AlreadyExists, "domain is already registered")
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Errorf("failed to create reverse proxy custom domain to store: %v", result.Error)
|
||||
return nil, status.Errorf(status.Internal, "failed to create reverse proxy custom domain to store")
|
||||
}
|
||||
|
||||
@@ -62,9 +62,9 @@ func assertGetAccountLoadsCustomDomains(t *testing.T, store Store) {
|
||||
accountID := "acct-custom-domains"
|
||||
require.NoError(t, store.SaveAccount(ctx, newAccountWithId(ctx, accountID, "user-1", "")))
|
||||
|
||||
_, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true, time.Now().UTC())
|
||||
_, err := store.CreateCustomDomain(ctx, accountID, "example.com", "eu.proxy.netbird.io", true)
|
||||
require.NoError(t, err, "creating the first custom domain must succeed")
|
||||
_, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false, time.Now().UTC())
|
||||
_, err = store.CreateCustomDomain(ctx, accountID, "apps.acme.io", "us.proxy.netbird.io", false)
|
||||
require.NoError(t, err, "creating the second custom domain must succeed")
|
||||
|
||||
account, err := store.GetAccount(ctx, accountID)
|
||||
|
||||
@@ -294,9 +294,7 @@ type Store interface {
|
||||
GetCustomDomain(ctx context.Context, accountID string, domainID string) (*domain.Domain, error)
|
||||
ListFreeDomains(ctx context.Context, accountID string) ([]string, error)
|
||||
ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error)
|
||||
ListCustomDomainsByName(ctx context.Context, domainName string) ([]*domain.Domain, error)
|
||||
ListAllCustomDomains(ctx context.Context) ([]*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool, claimedAt time.Time) (*domain.Domain, error)
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
|
||||
@@ -586,12 +584,6 @@ func getMigrationsPreAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.CleanupOrphanedResources[domain.Domain, types.Account](ctx, db, "account_id")
|
||||
},
|
||||
// Runs pre-auto because dropping the constraint rebuilds the table on
|
||||
// SQLite, which discards the table's indexes. AutoMigrate then recreates
|
||||
// them together with the per-account unique index.
|
||||
func(db *gorm.DB) error {
|
||||
return migration.DropUniqueConstraint[domain.Domain](ctx, db, "uni_domains_domain")
|
||||
},
|
||||
func(db *gorm.DB) error {
|
||||
return migration.BackfillPublicIDs[types.Policy](ctx, db)
|
||||
},
|
||||
@@ -666,12 +658,6 @@ func getMigrationsPostAuto(ctx context.Context) []migrationFunc {
|
||||
func(db *gorm.DB) error {
|
||||
return migration.FoldCostAggregatesIntoBuckets[agentNetworkTypes.AgentNetworkUsage](ctx, db)
|
||||
},
|
||||
// Rows written before claims were timed have no claim date, which would
|
||||
// exempt them from claim expiry forever. Dating them from the upgrade
|
||||
// gives every pre-existing unvalidated claim a full window to validate.
|
||||
func(db *gorm.DB) error {
|
||||
return migration.MigrateNewField[domain.Domain](ctx, db, "claimed_at", time.Now().UTC())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -283,18 +283,18 @@ func (mr *MockStoreMockRecorder) CreateAgentNetworkUsage(ctx, usage, groups inte
|
||||
}
|
||||
|
||||
// CreateCustomDomain mocks base method.
|
||||
func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool, claimedAt time.Time) (*domain.Domain, error) {
|
||||
func (m *MockStore) CreateCustomDomain(ctx context.Context, accountID, domainName, targetCluster string, validated bool) (*domain.Domain, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "CreateCustomDomain", ctx, accountID, domainName, targetCluster, validated, claimedAt)
|
||||
ret := m.ctrl.Call(m, "CreateCustomDomain", ctx, accountID, domainName, targetCluster, validated)
|
||||
ret0, _ := ret[0].(*domain.Domain)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// CreateCustomDomain indicates an expected call of CreateCustomDomain.
|
||||
func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated, claimedAt interface{}) *gomock.Call {
|
||||
func (mr *MockStoreMockRecorder) CreateCustomDomain(ctx, accountID, domainName, targetCluster, validated interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustomDomain", reflect.TypeOf((*MockStore)(nil).CreateCustomDomain), ctx, accountID, domainName, targetCluster, validated, claimedAt)
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreateCustomDomain", reflect.TypeOf((*MockStore)(nil).CreateCustomDomain), ctx, accountID, domainName, targetCluster, validated)
|
||||
}
|
||||
|
||||
// CreateDNSRecord mocks base method.
|
||||
@@ -3073,21 +3073,6 @@ func (mr *MockStoreMockRecorder) ListAgentNetworkConsumption(ctx, lockStrength,
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAgentNetworkConsumption", reflect.TypeOf((*MockStore)(nil).ListAgentNetworkConsumption), ctx, lockStrength, accountID)
|
||||
}
|
||||
|
||||
// ListAllCustomDomains mocks base method.
|
||||
func (m *MockStore) ListAllCustomDomains(ctx context.Context) ([]*domain.Domain, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListAllCustomDomains", ctx)
|
||||
ret0, _ := ret[0].([]*domain.Domain)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListAllCustomDomains indicates an expected call of ListAllCustomDomains.
|
||||
func (mr *MockStoreMockRecorder) ListAllCustomDomains(ctx interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListAllCustomDomains", reflect.TypeOf((*MockStore)(nil).ListAllCustomDomains), ctx)
|
||||
}
|
||||
|
||||
// ListCustomDomains mocks base method.
|
||||
func (m *MockStore) ListCustomDomains(ctx context.Context, accountID string) ([]*domain.Domain, error) {
|
||||
m.ctrl.T.Helper()
|
||||
@@ -3103,21 +3088,6 @@ func (mr *MockStoreMockRecorder) ListCustomDomains(ctx, accountID interface{}) *
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomDomains", reflect.TypeOf((*MockStore)(nil).ListCustomDomains), ctx, accountID)
|
||||
}
|
||||
|
||||
// ListCustomDomainsByName mocks base method.
|
||||
func (m *MockStore) ListCustomDomainsByName(ctx context.Context, domainName string) ([]*domain.Domain, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ListCustomDomainsByName", ctx, domainName)
|
||||
ret0, _ := ret[0].([]*domain.Domain)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// ListCustomDomainsByName indicates an expected call of ListCustomDomainsByName.
|
||||
func (mr *MockStoreMockRecorder) ListCustomDomainsByName(ctx, domainName interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCustomDomainsByName", reflect.TypeOf((*MockStore)(nil).ListCustomDomainsByName), ctx, domainName)
|
||||
}
|
||||
|
||||
// ListFreeDomains mocks base method.
|
||||
func (m *MockStore) ListFreeDomains(ctx context.Context, accountID string) ([]string, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
@@ -299,21 +299,11 @@ func (mgr *Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate
|
||||
// static wildcard certificate) and the method returns true. All other
|
||||
// domains go through ACME prefetch and the method returns false.
|
||||
//
|
||||
// domainValidated carries management's ownership verdict for the domain. A
|
||||
// domain that failed it is not registered and no order is placed: an ACME
|
||||
// order for a name the account has not proven it controls burns the cluster's
|
||||
// issuance rate limit and would hand out a certificate for someone else's host.
|
||||
//
|
||||
// When AddDomain returns true the caller is responsible for sending any
|
||||
// certificate-ready notifications after the surrounding operation (e.g.
|
||||
// mapping update) has committed successfully.
|
||||
func (mgr *Manager) AddDomain(d domain.Domain, accountID types.AccountID, serviceID types.ServiceID, domainValidated bool) (wildcardHit bool) {
|
||||
func (mgr *Manager) AddDomain(d domain.Domain, accountID types.AccountID, serviceID types.ServiceID) (wildcardHit bool) {
|
||||
name := d.PunycodeString()
|
||||
|
||||
if !domainValidated {
|
||||
mgr.logger.Warnf("refusing certificate management for unvalidated domain %q", name)
|
||||
return false
|
||||
}
|
||||
if e := mgr.findWildcardEntry(name); e != nil {
|
||||
mgr.mu.Lock()
|
||||
mgr.domains[d] = &domainInfo{
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
func TestHostPolicy(t *testing.T) {
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
mgr.AddDomain("example.com", types.AccountID("acc1"), types.ServiceID("rp1"), true)
|
||||
mgr.AddDomain("example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||
|
||||
// Wait for the background prefetch goroutine to finish so the temp dir
|
||||
// can be cleaned up without a race.
|
||||
@@ -94,8 +94,8 @@ func TestDomainStates(t *testing.T) {
|
||||
|
||||
// AddDomain starts as pending, then the prefetch goroutine will fail
|
||||
// (no real ACME server) and transition to failed.
|
||||
mgr.AddDomain("a.example.com", types.AccountID("acc1"), types.ServiceID("rp1"), true)
|
||||
mgr.AddDomain("b.example.com", types.AccountID("acc1"), types.ServiceID("rp1"), true)
|
||||
mgr.AddDomain("a.example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||
mgr.AddDomain("b.example.com", types.AccountID("acc1"), types.ServiceID("rp1"))
|
||||
|
||||
assert.Equal(t, 2, mgr.TotalDomains(), "two domains registered")
|
||||
|
||||
@@ -211,12 +211,12 @@ func TestWildcardAddDomainSkipsACME(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add a wildcard-matching domain — should be immediately ready.
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"), true)
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||
assert.Equal(t, 0, mgr.PendingCerts(), "wildcard domain should not be pending")
|
||||
assert.Equal(t, []string{"foo.example.com"}, mgr.ReadyDomains())
|
||||
|
||||
// Add a non-wildcard domain — should go through ACME (pending then failed).
|
||||
mgr.AddDomain("other.net", types.AccountID("acc2"), types.ServiceID("svc2"), true)
|
||||
mgr.AddDomain("other.net", types.AccountID("acc2"), types.ServiceID("svc2"))
|
||||
assert.Equal(t, 2, mgr.TotalDomains())
|
||||
|
||||
// Wait for the ACME prefetch to fail.
|
||||
@@ -236,7 +236,7 @@ func TestWildcardGetCertificate(t *testing.T) {
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: acmeDir, ACMEURL: "https://acme.example.com/directory", WildcardDir: wcDir}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"), true)
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||
|
||||
// GetCertificate for a wildcard-matching domain should return the static cert.
|
||||
cert, err := mgr.GetCertificate(&tls.ClientHelloInfo{ServerName: "foo.example.com"})
|
||||
@@ -257,8 +257,8 @@ func TestMultipleWildcards(t *testing.T) {
|
||||
assert.ElementsMatch(t, []string{"*.example.com", "*.other.org"}, mgr.WildcardPatterns())
|
||||
|
||||
// Both wildcards should resolve.
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"), true)
|
||||
mgr.AddDomain("bar.other.org", types.AccountID("acc2"), types.ServiceID("svc2"), true)
|
||||
mgr.AddDomain("foo.example.com", types.AccountID("acc1"), types.ServiceID("svc1"))
|
||||
mgr.AddDomain("bar.other.org", types.AccountID("acc2"), types.ServiceID("svc2"))
|
||||
|
||||
assert.Equal(t, 0, mgr.PendingCerts())
|
||||
assert.ElementsMatch(t, []string{"foo.example.com", "bar.other.org"}, mgr.ReadyDomains())
|
||||
@@ -273,7 +273,7 @@ func TestMultipleWildcards(t *testing.T) {
|
||||
assert.Contains(t, cert2.Leaf.DNSNames, "*.other.org")
|
||||
|
||||
// Non-matching domain falls through to ACME.
|
||||
mgr.AddDomain("custom.net", types.AccountID("acc3"), types.ServiceID("svc3"), true)
|
||||
mgr.AddDomain("custom.net", types.AccountID("acc3"), types.ServiceID("svc3"))
|
||||
assert.Eventually(t, func() bool {
|
||||
return mgr.PendingCerts() == 0
|
||||
}, 30*time.Second, 100*time.Millisecond)
|
||||
@@ -304,19 +304,3 @@ func TestNoWildcardDir(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, mgr.WildcardPatterns())
|
||||
}
|
||||
|
||||
// An unvalidated domain must not be registered and must not start an ACME
|
||||
// order: the account has not proven it controls the name, and every order
|
||||
// consumes the cluster's shared issuance rate limit.
|
||||
func TestAddDomain_RefusesUnvalidatedDomain(t *testing.T) {
|
||||
mgr, err := NewManager(ManagerConfig{CertDir: t.TempDir(), ACMEURL: "https://acme.example.com/directory"}, nil, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
wildcardHit := mgr.AddDomain("unproven.example.com", types.AccountID("acc1"), types.ServiceID("svc1"), false)
|
||||
|
||||
assert.False(t, wildcardHit, "an unvalidated domain is never certificate-ready")
|
||||
assert.Equal(t, 0, mgr.PendingCerts(), "no certificate order should be started")
|
||||
|
||||
err = mgr.hostPolicy(context.Background(), "unproven.example.com")
|
||||
assert.Error(t, err, "the host policy must not accept an unvalidated domain")
|
||||
}
|
||||
|
||||
@@ -644,13 +644,7 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
|
||||
|
||||
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
|
||||
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
|
||||
// domainValidated carries management's ownership verdict for the domain; a domain
|
||||
// that failed it is never registered, so requests for it find no route.
|
||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private, domainValidated bool) error {
|
||||
if !domainValidated {
|
||||
return fmt.Errorf("domain %s is not validated", domain)
|
||||
}
|
||||
|
||||
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool) error {
|
||||
if len(schemes) == 0 {
|
||||
mw.domainsMux.Lock()
|
||||
defer mw.domainsMux.Unlock()
|
||||
|
||||
@@ -64,7 +64,7 @@ func TestAddDomain_ValidKey(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -81,7 +81,7 @@ func TestAddDomain_EmptyKey(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestAddDomain_InvalidBase64(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "not-valid-base64!!!", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "decode session public key")
|
||||
|
||||
@@ -110,7 +110,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
|
||||
shortKey := base64.StdEncoding.EncodeToString([]byte("tooshort"))
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, shortKey, time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "invalid session public key size")
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestAddDomain_WrongKeySize(t *testing.T) {
|
||||
func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err, "domains with no auth schemes should not require a key")
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
@@ -132,21 +132,6 @@ func TestAddDomain_NoSchemes_NoKeyRequired(t *testing.T) {
|
||||
assert.True(t, exists)
|
||||
}
|
||||
|
||||
// A domain management has not marked validated is never registered, so no
|
||||
// request for it can reach a backend even if the mapping was delivered.
|
||||
func TestAddDomain_RefusesUnvalidatedDomain(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("unproven.example.com", nil, "", time.Hour, "", "", nil, false, false)
|
||||
require.Error(t, err, "an unvalidated domain must be rejected")
|
||||
assert.Contains(t, err.Error(), "not validated")
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
_, exists := mw.domains["unproven.example.com"]
|
||||
mw.domainsMux.RUnlock()
|
||||
assert.False(t, exists, "an unvalidated domain must not be registered")
|
||||
}
|
||||
|
||||
func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
kp1 := generateTestKeyPair(t)
|
||||
@@ -154,8 +139,8 @@ func TestAddDomain_OverwritesPreviousConfig(t *testing.T) {
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp2.PublicKey, 2*time.Hour, "", "", nil, false))
|
||||
|
||||
mw.domainsMux.RLock()
|
||||
config := mw.domains["example.com"]
|
||||
@@ -171,7 +156,7 @@ func TestRemoveDomain(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
mw.RemoveDomain("example.com")
|
||||
|
||||
@@ -195,7 +180,7 @@ func TestProtect_UnknownDomainPassesThrough(t *testing.T) {
|
||||
|
||||
func TestProtect_DomainWithNoSchemesPassesThrough(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", nil, "", time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -212,7 +197,7 @@ func TestProtect_UnauthenticatedRequestIsBlocked(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -233,7 +218,7 @@ func TestProtect_HostWithPortIsMatched(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -254,7 +239,7 @@ func TestProtect_ValidSessionCookiePassesThrough(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
require.NoError(t, err)
|
||||
@@ -287,7 +272,7 @@ func TestProtect_SessionCookieGroupsPropagate(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
groups := []string{"engineering", "sre"}
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, groups, nil, time.Hour)
|
||||
@@ -352,7 +337,7 @@ func TestProtect_PrivateService_TunnelPeerGroupsPropagate(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
// Private service: no operator schemes — auth gates solely on the tunnel peer.
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, true))
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
||||
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14")) // CGNAT tunnel source
|
||||
@@ -392,7 +377,7 @@ func TestProtect_PrivateService_TunnelPeerDenied(t *testing.T) {
|
||||
}}
|
||||
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
||||
kp := generateTestKeyPair(t)
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true, true))
|
||||
require.NoError(t, mw.AddDomain("agent.example.com", nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", nil, true))
|
||||
|
||||
cd := proxy.NewCapturedData("")
|
||||
cd.SetClientIP(netip.MustParseAddr("100.90.1.14"))
|
||||
@@ -420,7 +405,7 @@ func TestProtect_ExpiredSessionCookieIsRejected(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Sign a token that expired 1 second ago.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, -time.Second)
|
||||
@@ -446,7 +431,7 @@ func TestProtect_WrongDomainCookieIsRejected(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Token signed for a different domain audience.
|
||||
token, err := sessionkey.SignToken(kp.PrivateKey, "test-user", "", "other.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
@@ -473,7 +458,7 @@ func TestProtect_WrongKeyCookieIsRejected(t *testing.T) {
|
||||
kp2 := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp1.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Token signed with a different private key.
|
||||
token, err := sessionkey.SignToken(kp2.PrivateKey, "test-user", "", "example.com", auth.MethodPIN, nil, nil, time.Hour)
|
||||
@@ -510,7 +495,7 @@ func TestProtect_SchemeAuthRedirectsWithCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -563,7 +548,7 @@ func TestProtect_FailedAuthDoesNotSetCookie(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -599,7 +584,7 @@ func TestProtect_MultipleSchemes(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{pinScheme, passwordScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
backend := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -629,7 +614,7 @@ func TestProtect_InvalidTokenFromSchemeReturns400(t *testing.T) {
|
||||
return "invalid-jwt-token", "", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -653,7 +638,7 @@ func TestAddDomain_RandomBytes32NotEd25519(t *testing.T) {
|
||||
key := base64.StdEncoding.EncodeToString(randomBytes)
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
|
||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false, true)
|
||||
err = mw.AddDomain("example.com", []Scheme{scheme}, key, time.Hour, "", "", nil, false)
|
||||
require.NoError(t, err, "any 32-byte key should be accepted at registration time")
|
||||
}
|
||||
|
||||
@@ -662,10 +647,10 @@ func TestAddDomain_InvalidKeyDoesNotCorruptExistingConfig(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
// Attempt to overwrite with an invalid key.
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false, true)
|
||||
err := mw.AddDomain("example.com", []Scheme{scheme}, "bad", time.Hour, "", "", nil, false)
|
||||
require.Error(t, err)
|
||||
|
||||
// The original valid config should still be intact.
|
||||
@@ -689,7 +674,7 @@ func TestProtect_FailedPinAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -716,7 +701,7 @@ func TestProtect_FailedPasswordAuthCapturesAuthMethod(t *testing.T) {
|
||||
return "", "password", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -743,7 +728,7 @@ func TestProtect_NoCredentialsDoesNotCaptureAuthMethod(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -831,7 +816,7 @@ func TestCheckIPRestrictions_UnparseableAddress(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false, true)
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -867,7 +852,7 @@ func TestCheckIPRestrictions_UsesCapturedDataClientIP(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false, true)
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"203.0.113.0/24"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -908,7 +893,7 @@ func TestCheckIPRestrictions_NilGeoWithCountryRules(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false, true)
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCountries: []string{"US"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -935,7 +920,7 @@ func TestCheckIPRestrictions_OverlayOriginSkipsCountryRules(t *testing.T) {
|
||||
restrict.ParseFilter(restrict.FilterConfig{
|
||||
AllowedCIDRs: []string{"100.64.0.0/10"},
|
||||
AllowedCountries: []string{"US"},
|
||||
}), false, true)
|
||||
}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -969,7 +954,7 @@ func TestCheckIPRestrictions_OverlayOriginRespectsCIDR(t *testing.T) {
|
||||
mw := NewMiddleware(log.StandardLogger(), nil, nil)
|
||||
|
||||
err := mw.AddDomain("example.com", nil, "", 0, "acc1", "svc1",
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false, true)
|
||||
restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}}), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -997,7 +982,7 @@ func TestProtect_OIDCOnlyRedirectsDirectly(t *testing.T) {
|
||||
return "", oidcURL, nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1026,7 +1011,7 @@ func TestProtect_OIDCWithOtherMethodShowsLoginPage(t *testing.T) {
|
||||
return "", "pin", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{oidcScheme, pinScheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1070,7 +1055,7 @@ func TestProtect_HeaderAuth_ForwardsOnSuccess(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
@@ -1113,7 +1098,7 @@ func TestProtect_HeaderAuth_MissingHeaderFallsThrough(t *testing.T) {
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
// Also add a PIN scheme so we can verify fallthrough behavior.
|
||||
pinScheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr, pinScheme}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1133,7 +1118,7 @@ func TestProtect_HeaderAuth_WrongValueReturns401(t *testing.T) {
|
||||
return &proto.AuthenticateResponse{Success: false}, nil
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
capturedData := proxy.NewCapturedData("")
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
@@ -1156,7 +1141,7 @@ func TestProtect_HeaderAuth_InfraErrorReturns502(t *testing.T) {
|
||||
return nil, errors.New("gRPC unavailable")
|
||||
}}
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "X-API-Key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1173,7 +1158,7 @@ func TestProtect_HeaderAuth_SubsequentRequestUsesSessionCookie(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
hdr := newHeaderSchemeWithToken(t, kp, "X-API-Key", "secret-key")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1233,7 +1218,7 @@ func TestProtect_HeaderAuth_MultipleValuesSameHeader(t *testing.T) {
|
||||
|
||||
// Single Header scheme (as if one entry existed), but the mock checks both values.
|
||||
hdr := NewHeader(mock, "svc1", "acc1", "Authorization")
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{hdr}, kp.PublicKey, time.Hour, "acc1", "svc1", nil, false))
|
||||
|
||||
var backendCalled bool
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -1291,7 +1276,7 @@ func TestProtect_OIDCOnPlainHTTP_BlockedWith400(t *testing.T) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1315,7 +1300,7 @@ func TestProtect_OIDCOverTLS_NotBlocked(t *testing.T) {
|
||||
return "", "https://idp.example.com/authorize", nil
|
||||
},
|
||||
}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1335,7 +1320,7 @@ func TestProtect_NonOIDCSchemes_PlainHTTP_NotBlocked(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1365,7 +1350,7 @@ func TestProtect_TunnelPeerFastPath_RequiresInboundMarker(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
@@ -1400,7 +1385,7 @@ func TestProtect_TunnelPeerFastPath_TakesPathWithInboundMarker(t *testing.T) {
|
||||
kp := generateTestKeyPair(t)
|
||||
|
||||
scheme := &stubScheme{method: auth.MethodPIN, promptID: "pin"}
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, kp.PublicKey, time.Hour, "", "", nil, false))
|
||||
|
||||
handler := mw.Protect(newPassthroughHandler())
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *stubSessionValidator) ValidateTunnelPeer(_ context.Context, in *proto.V
|
||||
func newTunnelMiddleware(t *testing.T, validator SessionValidator) *Middleware {
|
||||
t.Helper()
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("svc.example", nil, "", 0, "acct-1", "svc-1", nil, false))
|
||||
return mw
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ func TestForwardWithTunnelPeer_RoutesAccountIDIntoCacheKey(t *testing.T) {
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
|
||||
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false, true))
|
||||
require.NoError(t, mw.AddDomain("svc-a.example", nil, "", 0, "acct-a", "svc-a", nil, false))
|
||||
require.NoError(t, mw.AddDomain("svc-b.example", nil, "", 0, "acct-b", "svc-b", nil, false))
|
||||
|
||||
// The fast-path requires the inbound-listener marker on the context.
|
||||
// The peerstore lookup itself is account-agnostic at this level
|
||||
@@ -299,7 +299,7 @@ func TestForwardWithTunnelPeer_LocalLookupShortCircuitDoesNotPopulateCache(t *te
|
||||
|
||||
func TestPrivateService_FailsClosedOnTunnelPeerFailure(t *testing.T) {
|
||||
mw := NewMiddleware(log.New(), nil, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, true))
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
@@ -328,7 +328,7 @@ func TestPrivateService_ForwardsOnTunnelPeerSuccess(t *testing.T) {
|
||||
},
|
||||
}
|
||||
mw := NewMiddleware(log.New(), validator, nil)
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true, true))
|
||||
require.NoError(t, mw.AddDomain("private.svc", nil, "", 0, "acct-1", "svc-1", nil, true))
|
||||
|
||||
called := false
|
||||
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
@@ -132,7 +132,6 @@ func setupBYOPIntegrationTest(t *testing.T) *byopTestSetup {
|
||||
|
||||
svcMgr := &storeBackedServiceManager{store: testStore, tokenStore: tokenStore}
|
||||
proxyService.SetServiceManager(svcMgr)
|
||||
proxyService.SetDomainValidator(testDomainValidator{})
|
||||
|
||||
proxyController := &testProxyController{}
|
||||
proxyService.SetProxyController(proxyController)
|
||||
|
||||
@@ -148,7 +148,6 @@ func setupIntegrationTest(t *testing.T) *integrationTestSetup {
|
||||
// Use store-backed service manager
|
||||
svcMgr := &storeBackedServiceManager{store: testStore, tokenStore: tokenStore}
|
||||
proxyService.SetServiceManager(svcMgr)
|
||||
proxyService.SetDomainValidator(testDomainValidator{})
|
||||
|
||||
proxyController := &testProxyController{}
|
||||
proxyService.SetProxyController(proxyController)
|
||||
@@ -265,14 +264,6 @@ func (m *testProxyManager) DeleteAccountCluster(_ context.Context, _, _ string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// testDomainValidator accepts every domain. These suites exercise mapping
|
||||
// delivery, not domain ownership, which has its own coverage in management.
|
||||
type testDomainValidator struct{}
|
||||
|
||||
func (testDomainValidator) IsDomainValidated(_ context.Context, _, _ string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// testProxyController is a mock implementation of rpservice.ProxyController for testing.
|
||||
type testProxyController struct{}
|
||||
|
||||
@@ -580,7 +571,6 @@ func TestIntegration_ProxyConnection_ReconnectDoesNotDuplicateState(t *testing.T
|
||||
proxytypes.ServiceID(mapping.GetId()),
|
||||
nil,
|
||||
mapping.GetPrivate(),
|
||||
mapping.GetDomainValidated(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
// The proxy is the last line of defence for domain ownership: a mapping that
|
||||
// management has not marked validated is refused before any route, relay or
|
||||
// certificate order is set up, whatever mode it claims.
|
||||
func TestSetupMappingRoutes_RefusesUnvalidatedDomain(t *testing.T) {
|
||||
s := &Server{Logger: quietLogger()}
|
||||
|
||||
for _, mode := range []string{"", "http", "tcp", "udp", "tls"} {
|
||||
name := mode
|
||||
if name == "" {
|
||||
name = "unset"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
mapping := &proto.ProxyMapping{
|
||||
Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED,
|
||||
Id: "svc-1",
|
||||
AccountId: "acc-1",
|
||||
Domain: "unproven.example.com",
|
||||
Mode: mode,
|
||||
DomainValidated: false,
|
||||
Path: []*proto.PathMapping{{Path: "/", Target: "http://10.0.0.1:8080"}},
|
||||
}
|
||||
|
||||
err := s.setupMappingRoutes(context.Background(), mapping)
|
||||
require.Error(t, err, "an unvalidated mapping must not be set up")
|
||||
assert.Contains(t, err.Error(), "not validated")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1646,13 +1646,6 @@ func (s *Server) modifyMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
// setupMappingRoutes configures the appropriate routes or relays for the given
|
||||
// service mapping based on its mode. The NetBird peer must already exist.
|
||||
func (s *Server) setupMappingRoutes(ctx context.Context, mapping *proto.ProxyMapping) error {
|
||||
// Management owns domain ownership validation; the proxy refuses the
|
||||
// mapping outright so a regression there cannot make this process serve a
|
||||
// hostname, or order a certificate for one, that nobody proved they own.
|
||||
if !mapping.GetDomainValidated() {
|
||||
return fmt.Errorf("domain %q is not validated", mapping.GetDomain())
|
||||
}
|
||||
|
||||
switch types.ServiceMode(mapping.GetMode()) {
|
||||
case types.ServiceModeTCP:
|
||||
return s.setupTCPMapping(ctx, mapping)
|
||||
@@ -1677,7 +1670,7 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi
|
||||
|
||||
var wildcardHit bool
|
||||
if s.acme != nil {
|
||||
wildcardHit = s.acme.AddDomain(d, accountID, svcID, mapping.GetDomainValidated())
|
||||
wildcardHit = s.acme.AddDomain(d, accountID, svcID)
|
||||
} else {
|
||||
wildcardHit = s.staticCertCovers(d)
|
||||
}
|
||||
@@ -2077,7 +2070,7 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
|
||||
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
|
||||
|
||||
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
|
||||
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetDomainValidated()); err != nil {
|
||||
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate()); err != nil {
|
||||
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
|
||||
}
|
||||
m := s.protoToMapping(ctx, mapping)
|
||||
|
||||
@@ -1084,10 +1084,6 @@ type ProxyMapping struct {
|
||||
AccessRestrictions *AccessRestrictions `protobuf:"bytes,12,opt,name=access_restrictions,json=accessRestrictions,proto3" json:"access_restrictions,omitempty"`
|
||||
// NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
|
||||
Private bool `protobuf:"varint,13,opt,name=private,proto3" json:"private,omitempty"`
|
||||
// Management asserts the account is allowed to serve this domain: a free
|
||||
// cluster domain, or a custom domain whose CNAME ownership check passed. The
|
||||
// proxy MUST NOT route traffic or order a certificate when this is false.
|
||||
DomainValidated bool `protobuf:"varint,14,opt,name=domain_validated,json=domainValidated,proto3" json:"domain_validated,omitempty"`
|
||||
}
|
||||
|
||||
func (x *ProxyMapping) Reset() {
|
||||
@@ -1213,13 +1209,6 @@ func (x *ProxyMapping) GetPrivate() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *ProxyMapping) GetDomainValidated() bool {
|
||||
if x != nil {
|
||||
return x.DomainValidated
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
|
||||
type SendAccessLogRequest struct {
|
||||
state protoimpl.MessageState
|
||||
@@ -3344,7 +3333,7 @@ var file_proxy_service_proto_rawDesc = []byte{
|
||||
0x63, 0x6b, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a,
|
||||
0x0d, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f,
|
||||
0x64, 0x65, 0x22, 0xab, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70,
|
||||
0x64, 0x65, 0x22, 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70,
|
||||
0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50,
|
||||
0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74,
|
||||
@@ -3376,364 +3365,361 @@ var file_proxy_service_proto_rawDesc = []byte{
|
||||
0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52,
|
||||
0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70,
|
||||
0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72,
|
||||
0x69, 0x76, 0x61, 0x74, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f,
|
||||
0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52,
|
||||
0x0f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x64,
|
||||
0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f,
|
||||
0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f,
|
||||
0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c,
|
||||
0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa9, 0x05, 0x0a, 0x09, 0x41,
|
||||
0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65,
|
||||
0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
|
||||
0x69, 0x76, 0x61, 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63,
|
||||
0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a,
|
||||
0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f,
|
||||
0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63,
|
||||
0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
|
||||
0xa9, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a,
|
||||
0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
|
||||
0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
|
||||
0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69,
|
||||
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d,
|
||||
0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04,
|
||||
0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74,
|
||||
0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||
0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18,
|
||||
0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a,
|
||||
0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09,
|
||||
0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f,
|
||||
0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18,
|
||||
0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12,
|
||||
0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73,
|
||||
0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63,
|
||||
0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12,
|
||||
0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18,
|
||||
0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65,
|
||||
0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f,
|
||||
0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55,
|
||||
0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64,
|
||||
0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62,
|
||||
0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f,
|
||||
0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52,
|
||||
0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b,
|
||||
0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12,
|
||||
0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13,
|
||||
0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75,
|
||||
0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a,
|
||||
0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61,
|
||||
0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64,
|
||||
0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
||||
0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72,
|
||||
0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68,
|
||||
0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f,
|
||||
0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22,
|
||||
0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e,
|
||||
0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03,
|
||||
0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55,
|
||||
0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73,
|
||||
0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12,
|
||||
0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32,
|
||||
0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f,
|
||||
0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
|
||||
0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f,
|
||||
0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65,
|
||||
0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12,
|
||||
0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62,
|
||||
0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20,
|
||||
0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
|
||||
0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73,
|
||||
0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64,
|
||||
0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f,
|
||||
0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a,
|
||||
0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e,
|
||||
0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75,
|
||||
0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75,
|
||||
0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74,
|
||||
0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73,
|
||||
0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74,
|
||||
0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70,
|
||||
0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50,
|
||||
0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75,
|
||||
0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
|
||||
0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50,
|
||||
0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65,
|
||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||
0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61,
|
||||
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30,
|
||||
0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c,
|
||||
0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69,
|
||||
0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72,
|
||||
0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12,
|
||||
0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47,
|
||||
0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12,
|
||||
0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18,
|
||||
0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55,
|
||||
0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18,
|
||||
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61,
|
||||
0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d,
|
||||
0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x22, 0xdc, 0x01, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
|
||||
0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a,
|
||||
0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61,
|
||||
0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64,
|
||||
0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69,
|
||||
0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72,
|
||||
0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67,
|
||||
0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09,
|
||||
0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73,
|
||||
0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e,
|
||||
0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f,
|
||||
0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61,
|
||||
0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54,
|
||||
0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64,
|
||||
0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12,
|
||||
0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65,
|
||||
0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65,
|
||||
0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28,
|
||||
0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12,
|
||||
0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61,
|
||||
0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47,
|
||||
0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e,
|
||||
0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52,
|
||||
0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48,
|
||||
0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01,
|
||||
0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e,
|
||||
0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74,
|
||||
0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
|
||||
0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
|
||||
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
|
||||
0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63,
|
||||
0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61,
|
||||
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76,
|
||||
0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65,
|
||||
0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70,
|
||||
0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12,
|
||||
0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07,
|
||||
0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73,
|
||||
0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52,
|
||||
0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75,
|
||||
0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73,
|
||||
0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75,
|
||||
0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08,
|
||||
0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a,
|
||||
0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64,
|
||||
0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f,
|
||||
0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x44,
|
||||
0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||
0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18,
|
||||
0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61,
|
||||
0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x65,
|
||||
0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x67, 0x65,
|
||||
0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74,
|
||||
0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65,
|
||||
0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65,
|
||||
0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e,
|
||||
0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d,
|
||||
0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a,
|
||||
0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
|
||||
0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73,
|
||||
0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08,
|
||||
0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52,
|
||||
0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61,
|
||||
0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74,
|
||||
0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64,
|
||||
0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72,
|
||||
0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x65,
|
||||
0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, 0x61,
|
||||
0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a,
|
||||
0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61,
|
||||
0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a,
|
||||
0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e,
|
||||
0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74,
|
||||
0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
|
||||
0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01,
|
||||
0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73,
|
||||
0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55,
|
||||
0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a,
|
||||
0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
|
||||
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74,
|
||||
0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61,
|
||||
0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63,
|
||||
0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65,
|
||||
0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69,
|
||||
0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f,
|
||||
0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78,
|
||||
0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72,
|
||||
0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65,
|
||||
0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72,
|
||||
0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x62,
|
||||
0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, 0x0a,
|
||||
0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73,
|
||||
0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f,
|
||||
0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c,
|
||||
0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, 0x74,
|
||||
0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, 0x72,
|
||||
0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1a,
|
||||
0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69,
|
||||
0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f,
|
||||
0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e,
|
||||
0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72,
|
||||
0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65,
|
||||
0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61,
|
||||
0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63,
|
||||
0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c,
|
||||
0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50,
|
||||
0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72,
|
||||
0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
|
||||
0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
|
||||
0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d,
|
||||
0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44,
|
||||
0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61,
|
||||
0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65,
|
||||
0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a,
|
||||
0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f,
|
||||
0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x55, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74,
|
||||
0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
|
||||
0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69,
|
||||
0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
|
||||
0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, 0x0a,
|
||||
0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
|
||||
0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69,
|
||||
0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17,
|
||||
0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f,
|
||||
0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65,
|
||||
0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64,
|
||||
0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64,
|
||||
0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70,
|
||||
0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20,
|
||||
0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64,
|
||||
0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f,
|
||||
0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65,
|
||||
0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, 0x56,
|
||||
0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65,
|
||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e,
|
||||
0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e,
|
||||
0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02,
|
||||
0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c,
|
||||
0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05,
|
||||
0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c,
|
||||
0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75,
|
||||
0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65,
|
||||
0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12,
|
||||
0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54,
|
||||
0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f,
|
||||
0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65,
|
||||
0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65,
|
||||
0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07,
|
||||
0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e,
|
||||
0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70,
|
||||
0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04,
|
||||
0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70,
|
||||
0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74,
|
||||
0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e,
|
||||
0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64,
|
||||
0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c,
|
||||
0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65,
|
||||
0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22,
|
||||
0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41,
|
||||
0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
|
||||
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61,
|
||||
0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61,
|
||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61,
|
||||
0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32,
|
||||
0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63,
|
||||
0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69,
|
||||
0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50,
|
||||
0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64,
|
||||
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49,
|
||||
0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72,
|
||||
0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67,
|
||||
0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69,
|
||||
0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72,
|
||||
0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65,
|
||||
0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff,
|
||||
0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63,
|
||||
0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73,
|
||||
0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65,
|
||||
0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74,
|
||||
0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75,
|
||||
0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77,
|
||||
0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20,
|
||||
0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e,
|
||||
0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12,
|
||||
0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e,
|
||||
0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73,
|
||||
0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63,
|
||||
0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
|
||||
0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65,
|
||||
0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72,
|
||||
0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03,
|
||||
0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a,
|
||||
0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18,
|
||||
0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63,
|
||||
0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69,
|
||||
0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65,
|
||||
0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c,
|
||||
0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08,
|
||||
0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07,
|
||||
0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70,
|
||||
0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75,
|
||||
0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c,
|
||||
0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64,
|
||||
0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70,
|
||||
0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41,
|
||||
0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10,
|
||||
0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45,
|
||||
0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55,
|
||||
0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56,
|
||||
0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72,
|
||||
0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f,
|
||||
0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10,
|
||||
0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54,
|
||||
0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a,
|
||||
0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12,
|
||||
0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c,
|
||||
0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
|
||||
0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53,
|
||||
0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01,
|
||||
0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53,
|
||||
0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10,
|
||||
0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f,
|
||||
0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a,
|
||||
0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12,
|
||||
0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f,
|
||||
0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f,
|
||||
0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45,
|
||||
0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
|
||||
0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52,
|
||||
0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59,
|
||||
0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43,
|
||||
0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a,
|
||||
0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45,
|
||||
0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44,
|
||||
0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
|
||||
0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50,
|
||||
0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47,
|
||||
0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12,
|
||||
0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61,
|
||||
0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c,
|
||||
0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d,
|
||||
0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61,
|
||||
0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d,
|
||||
0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63,
|
||||
0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e,
|
||||
0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a,
|
||||
0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69,
|
||||
0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
|
||||
0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
|
||||
0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a,
|
||||
0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
|
||||
0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62,
|
||||
0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79,
|
||||
0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61,
|
||||
0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79,
|
||||
0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a,
|
||||
0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
|
||||
0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
|
||||
0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69,
|
||||
0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61,
|
||||
0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, 0x01,
|
||||
0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79,
|
||||
0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
|
||||
0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
|
||||
0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07,
|
||||
0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75,
|
||||
0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69,
|
||||
0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49,
|
||||
0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69,
|
||||
0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65,
|
||||
0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, 0x68,
|
||||
0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69,
|
||||
0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x65,
|
||||
0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65,
|
||||
0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74,
|
||||
0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, 0x69,
|
||||
0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
|
||||
0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47,
|
||||
0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77,
|
||||
0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d,
|
||||
0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, 0x0a,
|
||||
0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
|
||||
0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65,
|
||||
0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, 0x15,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||
0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75,
|
||||
0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18,
|
||||
0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a,
|
||||
0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
|
||||
0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64,
|
||||
0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
|
||||
0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12,
|
||||
0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18,
|
||||
0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, 0x70,
|
||||
0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, 0x74,
|
||||
0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
|
||||
0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, 0x5f,
|
||||
0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, 0x55,
|
||||
0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18,
|
||||
0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22,
|
||||
0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f,
|
||||
0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54,
|
||||
0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59,
|
||||
0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14,
|
||||
0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49,
|
||||
0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45,
|
||||
0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a,
|
||||
0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f,
|
||||
0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49,
|
||||
0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15,
|
||||
0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45,
|
||||
0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64,
|
||||
0x6c, 0x65, 0x77, 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49,
|
||||
0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e,
|
||||
0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d,
|
||||
0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f,
|
||||
0x4e, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d,
|
||||
0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f,
|
||||
0x4e, 0x5f, 0x52, 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18,
|
||||
0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f,
|
||||
0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50,
|
||||
0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52,
|
||||
0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49,
|
||||
0x4e, 0x47, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54,
|
||||
0x41, 0x54, 0x55, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a,
|
||||
0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55,
|
||||
0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44,
|
||||
0x10, 0x02, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54,
|
||||
0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50,
|
||||
0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58,
|
||||
0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49,
|
||||
0x43, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a,
|
||||
0x12, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52,
|
||||
0x52, 0x4f, 0x52, 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53,
|
||||
0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70,
|
||||
0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69,
|
||||
0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74,
|
||||
0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d,
|
||||
0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67,
|
||||
0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e,
|
||||
0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54,
|
||||
0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12,
|
||||
0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e,
|
||||
0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53,
|
||||
0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69,
|
||||
0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53,
|
||||
0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61,
|
||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61,
|
||||
0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65,
|
||||
0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28,
|
||||
0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73,
|
||||
0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f,
|
||||
0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74,
|
||||
0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e,
|
||||
0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69,
|
||||
0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10,
|
||||
0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
|
||||
0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65,
|
||||
0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65,
|
||||
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65,
|
||||
0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61,
|
||||
0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f,
|
||||
0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74,
|
||||
0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
|
||||
0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c,
|
||||
0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65,
|
||||
0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
|
||||
0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74,
|
||||
0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
|
||||
0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69,
|
||||
0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73,
|
||||
0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56,
|
||||
0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65,
|
||||
0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56,
|
||||
0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65,
|
||||
0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75,
|
||||
0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69,
|
||||
0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f,
|
||||
0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
|
||||
0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43,
|
||||
0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d,
|
||||
0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52,
|
||||
0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72,
|
||||
0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
|
||||
0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65,
|
||||
0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06,
|
||||
0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64,
|
||||
0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43,
|
||||
0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22,
|
||||
0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61,
|
||||
0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
|
||||
0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52,
|
||||
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49,
|
||||
0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71,
|
||||
0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e,
|
||||
0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70,
|
||||
0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65,
|
||||
0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73,
|
||||
0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61,
|
||||
0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74,
|
||||
0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e,
|
||||
0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e,
|
||||
0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64,
|
||||
0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73,
|
||||
0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c,
|
||||
0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e,
|
||||
0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b,
|
||||
0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52,
|
||||
0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69,
|
||||
0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
|
||||
0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61,
|
||||
0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e,
|
||||
0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65,
|
||||
0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65,
|
||||
0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67,
|
||||
0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72,
|
||||
0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -226,10 +226,6 @@ message ProxyMapping {
|
||||
AccessRestrictions access_restrictions = 12;
|
||||
// NetBird-only: the proxy MUST call ValidateTunnelPeer and fail closed; operator auth schemes are bypassed.
|
||||
bool private = 13;
|
||||
// Management asserts the account is allowed to serve this domain: a free
|
||||
// cluster domain, or a custom domain whose CNAME ownership check passed. The
|
||||
// proxy MUST NOT route traffic or order a certificate when this is false.
|
||||
bool domain_validated = 14;
|
||||
}
|
||||
|
||||
// SendAccessLogRequest consists of one or more AccessLogs from a Proxy.
|
||||
|
||||
Reference in New Issue
Block a user