diff options
Diffstat (limited to 'control/controlclient')
| -rw-r--r-- | control/controlclient/sign.go | 84 | ||||
| -rw-r--r-- | control/controlclient/sign_supported_test.go | 472 | ||||
| -rw-r--r-- | control/controlclient/sign_unsupported.go | 32 | ||||
| -rw-r--r-- | control/controlclient/status.go | 250 |
4 files changed, 419 insertions, 419 deletions
diff --git a/control/controlclient/sign.go b/control/controlclient/sign.go index e3a479c28..5e72f1cf4 100644 --- a/control/controlclient/sign.go +++ b/control/controlclient/sign.go @@ -1,42 +1,42 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "crypto" - "errors" - "fmt" - "time" - - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -var ( - errNoCertStore = errors.New("no certificate store") - errCertificateNotConfigured = errors.New("no certificate subject configured") - errUnsupportedSignatureVersion = errors.New("unsupported signature version") -) - -// HashRegisterRequest generates the hash required sign or verify a -// tailcfg.RegisterRequest. -func HashRegisterRequest( - version tailcfg.SignatureType, ts time.Time, serverURL string, deviceCert []byte, - serverPubKey, machinePubKey key.MachinePublic) ([]byte, error) { - h := crypto.SHA256.New() - - // hash.Hash.Write never returns an error, so we don't check for one here. - switch version { - case tailcfg.SignatureV1: - fmt.Fprintf(h, "%s%s%s%s%s", - ts.UTC().Format(time.RFC3339), serverURL, deviceCert, serverPubKey.ShortString(), machinePubKey.ShortString()) - case tailcfg.SignatureV2: - fmt.Fprintf(h, "%s%s%s%s%s", - ts.UTC().Format(time.RFC3339), serverURL, deviceCert, serverPubKey, machinePubKey) - default: - return nil, errUnsupportedSignatureVersion - } - - return h.Sum(nil), nil -} +// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package controlclient
+
+import (
+ "crypto"
+ "errors"
+ "fmt"
+ "time"
+
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/key"
+)
+
+var (
+ errNoCertStore = errors.New("no certificate store")
+ errCertificateNotConfigured = errors.New("no certificate subject configured")
+ errUnsupportedSignatureVersion = errors.New("unsupported signature version")
+)
+
+// HashRegisterRequest generates the hash required sign or verify a
+// tailcfg.RegisterRequest.
+func HashRegisterRequest(
+ version tailcfg.SignatureType, ts time.Time, serverURL string, deviceCert []byte,
+ serverPubKey, machinePubKey key.MachinePublic) ([]byte, error) {
+ h := crypto.SHA256.New()
+
+ // hash.Hash.Write never returns an error, so we don't check for one here.
+ switch version {
+ case tailcfg.SignatureV1:
+ fmt.Fprintf(h, "%s%s%s%s%s",
+ ts.UTC().Format(time.RFC3339), serverURL, deviceCert, serverPubKey.ShortString(), machinePubKey.ShortString())
+ case tailcfg.SignatureV2:
+ fmt.Fprintf(h, "%s%s%s%s%s",
+ ts.UTC().Format(time.RFC3339), serverURL, deviceCert, serverPubKey, machinePubKey)
+ default:
+ return nil, errUnsupportedSignatureVersion
+ }
+
+ return h.Sum(nil), nil
+}
diff --git a/control/controlclient/sign_supported_test.go b/control/controlclient/sign_supported_test.go index e20349a4e..ca41794d1 100644 --- a/control/controlclient/sign_supported_test.go +++ b/control/controlclient/sign_supported_test.go @@ -1,236 +1,236 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build windows && cgo - -package controlclient - -import ( - "crypto" - "crypto/x509" - "crypto/x509/pkix" - "errors" - "reflect" - "testing" - "time" - - "github.com/tailscale/certstore" -) - -const ( - testRootCommonName = "testroot" - testRootSubject = "CN=testroot" -) - -type testIdentity struct { - chain []*x509.Certificate -} - -func makeChain(rootCommonName string, notBefore, notAfter time.Time) []*x509.Certificate { - return []*x509.Certificate{ - { - NotBefore: notBefore, - NotAfter: notAfter, - PublicKeyAlgorithm: x509.RSA, - }, - { - Subject: pkix.Name{ - CommonName: rootCommonName, - }, - PublicKeyAlgorithm: x509.RSA, - }, - } -} - -func (t *testIdentity) Certificate() (*x509.Certificate, error) { - return t.chain[0], nil -} - -func (t *testIdentity) CertificateChain() ([]*x509.Certificate, error) { - return t.chain, nil -} - -func (t *testIdentity) Signer() (crypto.Signer, error) { - return nil, errors.New("not implemented") -} - -func (t *testIdentity) Delete() error { - return errors.New("not implemented") -} - -func (t *testIdentity) Close() {} - -func TestSelectIdentityFromSlice(t *testing.T) { - var times []time.Time - for _, ts := range []string{ - "2000-01-01T00:00:00Z", - "2001-01-01T00:00:00Z", - "2002-01-01T00:00:00Z", - "2003-01-01T00:00:00Z", - } { - tm, err := time.Parse(time.RFC3339, ts) - if err != nil { - t.Fatal(err) - } - times = append(times, tm) - } - - tests := []struct { - name string - subject string - ids []certstore.Identity - now time.Time - // wantIndex is an index into ids, or -1 for nil. - wantIndex int - }{ - { - name: "single unexpired identity", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[2]), - }, - }, - now: times[1], - wantIndex: 0, - }, - { - name: "single expired identity", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - }, - now: times[2], - wantIndex: -1, - }, - { - name: "unrelated ids", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain("something", times[0], times[2]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[2]), - }, - &testIdentity{ - chain: makeChain("else", times[0], times[2]), - }, - }, - now: times[1], - wantIndex: 1, - }, - { - name: "expired with unrelated ids", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain("something", times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - &testIdentity{ - chain: makeChain("else", times[0], times[3]), - }, - }, - now: times[2], - wantIndex: -1, - }, - { - name: "one expired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - }, - now: times[2], - wantIndex: 1, - }, - { - name: "two certs both unexpired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - }, - now: times[2], - wantIndex: 1, - }, - { - name: "two unexpired one expired", - subject: testRootSubject, - ids: []certstore.Identity{ - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[1], times[3]), - }, - &testIdentity{ - chain: makeChain(testRootCommonName, times[0], times[1]), - }, - }, - now: times[2], - wantIndex: 1, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - gotId, gotChain := selectIdentityFromSlice(tt.subject, tt.ids, tt.now) - - if gotId == nil && gotChain != nil { - t.Error("id is nil: got non-nil chain, want nil chain") - return - } - if gotId != nil && gotChain == nil { - t.Error("id is not nil: got nil chain, want non-nil chain") - return - } - if tt.wantIndex == -1 { - if gotId != nil { - t.Error("got non-nil id, want nil id") - } - return - } - if gotId == nil { - t.Error("got nil id, want non-nil id") - return - } - if gotId != tt.ids[tt.wantIndex] { - found := -1 - for i := range tt.ids { - if tt.ids[i] == gotId { - found = i - break - } - } - if found == -1 { - t.Errorf("got unknown id, want id at index %v", tt.wantIndex) - } else { - t.Errorf("got id at index %v, want id at index %v", found, tt.wantIndex) - } - } - - tid, ok := tt.ids[tt.wantIndex].(*testIdentity) - if !ok { - t.Error("got non-testIdentity, want testIdentity") - return - } - - if !reflect.DeepEqual(tid.chain, gotChain) { - t.Errorf("got unknown chain, want chain from id at index %v", tt.wantIndex) - } - }) - } -} +// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build windows && cgo
+
+package controlclient
+
+import (
+ "crypto"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+
+ "github.com/tailscale/certstore"
+)
+
+const (
+ testRootCommonName = "testroot"
+ testRootSubject = "CN=testroot"
+)
+
+type testIdentity struct {
+ chain []*x509.Certificate
+}
+
+func makeChain(rootCommonName string, notBefore, notAfter time.Time) []*x509.Certificate {
+ return []*x509.Certificate{
+ {
+ NotBefore: notBefore,
+ NotAfter: notAfter,
+ PublicKeyAlgorithm: x509.RSA,
+ },
+ {
+ Subject: pkix.Name{
+ CommonName: rootCommonName,
+ },
+ PublicKeyAlgorithm: x509.RSA,
+ },
+ }
+}
+
+func (t *testIdentity) Certificate() (*x509.Certificate, error) {
+ return t.chain[0], nil
+}
+
+func (t *testIdentity) CertificateChain() ([]*x509.Certificate, error) {
+ return t.chain, nil
+}
+
+func (t *testIdentity) Signer() (crypto.Signer, error) {
+ return nil, errors.New("not implemented")
+}
+
+func (t *testIdentity) Delete() error {
+ return errors.New("not implemented")
+}
+
+func (t *testIdentity) Close() {}
+
+func TestSelectIdentityFromSlice(t *testing.T) {
+ var times []time.Time
+ for _, ts := range []string{
+ "2000-01-01T00:00:00Z",
+ "2001-01-01T00:00:00Z",
+ "2002-01-01T00:00:00Z",
+ "2003-01-01T00:00:00Z",
+ } {
+ tm, err := time.Parse(time.RFC3339, ts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ times = append(times, tm)
+ }
+
+ tests := []struct {
+ name string
+ subject string
+ ids []certstore.Identity
+ now time.Time
+ // wantIndex is an index into ids, or -1 for nil.
+ wantIndex int
+ }{
+ {
+ name: "single unexpired identity",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[2]),
+ },
+ },
+ now: times[1],
+ wantIndex: 0,
+ },
+ {
+ name: "single expired identity",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[1]),
+ },
+ },
+ now: times[2],
+ wantIndex: -1,
+ },
+ {
+ name: "unrelated ids",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain("something", times[0], times[2]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[2]),
+ },
+ &testIdentity{
+ chain: makeChain("else", times[0], times[2]),
+ },
+ },
+ now: times[1],
+ wantIndex: 1,
+ },
+ {
+ name: "expired with unrelated ids",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain("something", times[0], times[3]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[1]),
+ },
+ &testIdentity{
+ chain: makeChain("else", times[0], times[3]),
+ },
+ },
+ now: times[2],
+ wantIndex: -1,
+ },
+ {
+ name: "one expired",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[1]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[1], times[3]),
+ },
+ },
+ now: times[2],
+ wantIndex: 1,
+ },
+ {
+ name: "two certs both unexpired",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[3]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[1], times[3]),
+ },
+ },
+ now: times[2],
+ wantIndex: 1,
+ },
+ {
+ name: "two unexpired one expired",
+ subject: testRootSubject,
+ ids: []certstore.Identity{
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[3]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[1], times[3]),
+ },
+ &testIdentity{
+ chain: makeChain(testRootCommonName, times[0], times[1]),
+ },
+ },
+ now: times[2],
+ wantIndex: 1,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ gotId, gotChain := selectIdentityFromSlice(tt.subject, tt.ids, tt.now)
+
+ if gotId == nil && gotChain != nil {
+ t.Error("id is nil: got non-nil chain, want nil chain")
+ return
+ }
+ if gotId != nil && gotChain == nil {
+ t.Error("id is not nil: got nil chain, want non-nil chain")
+ return
+ }
+ if tt.wantIndex == -1 {
+ if gotId != nil {
+ t.Error("got non-nil id, want nil id")
+ }
+ return
+ }
+ if gotId == nil {
+ t.Error("got nil id, want non-nil id")
+ return
+ }
+ if gotId != tt.ids[tt.wantIndex] {
+ found := -1
+ for i := range tt.ids {
+ if tt.ids[i] == gotId {
+ found = i
+ break
+ }
+ }
+ if found == -1 {
+ t.Errorf("got unknown id, want id at index %v", tt.wantIndex)
+ } else {
+ t.Errorf("got id at index %v, want id at index %v", found, tt.wantIndex)
+ }
+ }
+
+ tid, ok := tt.ids[tt.wantIndex].(*testIdentity)
+ if !ok {
+ t.Error("got non-testIdentity, want testIdentity")
+ return
+ }
+
+ if !reflect.DeepEqual(tid.chain, gotChain) {
+ t.Errorf("got unknown chain, want chain from id at index %v", tt.wantIndex)
+ }
+ })
+ }
+}
diff --git a/control/controlclient/sign_unsupported.go b/control/controlclient/sign_unsupported.go index 5e161dcbc..4ec40d502 100644 --- a/control/controlclient/sign_unsupported.go +++ b/control/controlclient/sign_unsupported.go @@ -1,16 +1,16 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -//go:build !windows - -package controlclient - -import ( - "tailscale.com/tailcfg" - "tailscale.com/types/key" -) - -// signRegisterRequest on non-supported platforms always returns errNoCertStore. -func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) error { - return errNoCertStore -} +// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+//go:build !windows
+
+package controlclient
+
+import (
+ "tailscale.com/tailcfg"
+ "tailscale.com/types/key"
+)
+
+// signRegisterRequest on non-supported platforms always returns errNoCertStore.
+func signRegisterRequest(req *tailcfg.RegisterRequest, serverURL string, serverPubKey, machinePubKey key.MachinePublic) error {
+ return errNoCertStore
+}
diff --git a/control/controlclient/status.go b/control/controlclient/status.go index d0fdf80d7..7dba14d3f 100644 --- a/control/controlclient/status.go +++ b/control/controlclient/status.go @@ -1,125 +1,125 @@ -// Copyright (c) Tailscale Inc & AUTHORS -// SPDX-License-Identifier: BSD-3-Clause - -package controlclient - -import ( - "encoding/json" - "fmt" - "reflect" - - "tailscale.com/types/netmap" - "tailscale.com/types/persist" - "tailscale.com/types/structs" -) - -// State is the high-level state of the client. It is used only in -// unit tests for proper sequencing, don't depend on it anywhere else. -// -// TODO(apenwarr): eliminate the state, as it's now obsolete. -// -// apenwarr: Historical note: controlclient.Auto was originally -// intended to be the state machine for the whole tailscale client, but that -// turned out to not be the right abstraction layer, and it moved to -// ipn.Backend. Since ipn.Backend now has a state machine, it would be -// much better if controlclient could be a simple stateless API. But the -// current server-side API (two interlocking polling https calls) makes that -// very hard to implement. A server side API change could untangle this and -// remove all the statefulness. -type State int - -const ( - StateNew = State(iota) - StateNotAuthenticated - StateAuthenticating - StateURLVisitRequired - StateAuthenticated - StateSynchronized // connected and received map update -) - -func (s State) AppendText(b []byte) ([]byte, error) { - return append(b, s.String()...), nil -} - -func (s State) MarshalText() ([]byte, error) { - return []byte(s.String()), nil -} - -func (s State) String() string { - switch s { - case StateNew: - return "state:new" - case StateNotAuthenticated: - return "state:not-authenticated" - case StateAuthenticating: - return "state:authenticating" - case StateURLVisitRequired: - return "state:url-visit-required" - case StateAuthenticated: - return "state:authenticated" - case StateSynchronized: - return "state:synchronized" - default: - return fmt.Sprintf("state:unknown:%d", int(s)) - } -} - -type Status struct { - _ structs.Incomparable - - // Err, if non-nil, is an error that occurred while logging in. - // - // If it's of type UserVisibleError then it's meant to be shown to users in - // their Tailscale client. Otherwise it's just logged to tailscaled's logs. - Err error - - // URL, if non-empty, is the interactive URL to visit to finish logging in. - URL string - - // NetMap is the latest server-pushed state of the tailnet network. - NetMap *netmap.NetworkMap - - // Persist, when Valid, is the locally persisted configuration. - // - // TODO(bradfitz,maisem): clarify this. - Persist persist.PersistView - - // state is the internal state. It should not be exposed outside this - // package, but we have some automated tests elsewhere that need to - // use it via the StateForTest accessor. - // TODO(apenwarr): Unexport or remove these. - state State -} - -// LoginFinished reports whether the controlclient is in its "StateAuthenticated" -// state where it's in a happy register state but not yet in a map poll. -// -// TODO(bradfitz): delete this and everything around Status.state. -func (s *Status) LoginFinished() bool { return s.state == StateAuthenticated } - -// StateForTest returns the internal state of s for tests only. -func (s *Status) StateForTest() State { return s.state } - -// SetStateForTest sets the internal state of s for tests only. -func (s *Status) SetStateForTest(state State) { s.state = state } - -// Equal reports whether s and s2 are equal. -func (s *Status) Equal(s2 *Status) bool { - if s == nil && s2 == nil { - return true - } - return s != nil && s2 != nil && - s.Err == s2.Err && - s.URL == s2.URL && - s.state == s2.state && - reflect.DeepEqual(s.Persist, s2.Persist) && - reflect.DeepEqual(s.NetMap, s2.NetMap) -} - -func (s Status) String() string { - b, err := json.MarshalIndent(s, "", "\t") - if err != nil { - panic(err) - } - return s.state.String() + " " + string(b) -} +// Copyright (c) Tailscale Inc & AUTHORS
+// SPDX-License-Identifier: BSD-3-Clause
+
+package controlclient
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+
+ "tailscale.com/types/netmap"
+ "tailscale.com/types/persist"
+ "tailscale.com/types/structs"
+)
+
+// State is the high-level state of the client. It is used only in
+// unit tests for proper sequencing, don't depend on it anywhere else.
+//
+// TODO(apenwarr): eliminate the state, as it's now obsolete.
+//
+// apenwarr: Historical note: controlclient.Auto was originally
+// intended to be the state machine for the whole tailscale client, but that
+// turned out to not be the right abstraction layer, and it moved to
+// ipn.Backend. Since ipn.Backend now has a state machine, it would be
+// much better if controlclient could be a simple stateless API. But the
+// current server-side API (two interlocking polling https calls) makes that
+// very hard to implement. A server side API change could untangle this and
+// remove all the statefulness.
+type State int
+
+const (
+ StateNew = State(iota)
+ StateNotAuthenticated
+ StateAuthenticating
+ StateURLVisitRequired
+ StateAuthenticated
+ StateSynchronized // connected and received map update
+)
+
+func (s State) AppendText(b []byte) ([]byte, error) {
+ return append(b, s.String()...), nil
+}
+
+func (s State) MarshalText() ([]byte, error) {
+ return []byte(s.String()), nil
+}
+
+func (s State) String() string {
+ switch s {
+ case StateNew:
+ return "state:new"
+ case StateNotAuthenticated:
+ return "state:not-authenticated"
+ case StateAuthenticating:
+ return "state:authenticating"
+ case StateURLVisitRequired:
+ return "state:url-visit-required"
+ case StateAuthenticated:
+ return "state:authenticated"
+ case StateSynchronized:
+ return "state:synchronized"
+ default:
+ return fmt.Sprintf("state:unknown:%d", int(s))
+ }
+}
+
+type Status struct {
+ _ structs.Incomparable
+
+ // Err, if non-nil, is an error that occurred while logging in.
+ //
+ // If it's of type UserVisibleError then it's meant to be shown to users in
+ // their Tailscale client. Otherwise it's just logged to tailscaled's logs.
+ Err error
+
+ // URL, if non-empty, is the interactive URL to visit to finish logging in.
+ URL string
+
+ // NetMap is the latest server-pushed state of the tailnet network.
+ NetMap *netmap.NetworkMap
+
+ // Persist, when Valid, is the locally persisted configuration.
+ //
+ // TODO(bradfitz,maisem): clarify this.
+ Persist persist.PersistView
+
+ // state is the internal state. It should not be exposed outside this
+ // package, but we have some automated tests elsewhere that need to
+ // use it via the StateForTest accessor.
+ // TODO(apenwarr): Unexport or remove these.
+ state State
+}
+
+// LoginFinished reports whether the controlclient is in its "StateAuthenticated"
+// state where it's in a happy register state but not yet in a map poll.
+//
+// TODO(bradfitz): delete this and everything around Status.state.
+func (s *Status) LoginFinished() bool { return s.state == StateAuthenticated }
+
+// StateForTest returns the internal state of s for tests only.
+func (s *Status) StateForTest() State { return s.state }
+
+// SetStateForTest sets the internal state of s for tests only.
+func (s *Status) SetStateForTest(state State) { s.state = state }
+
+// Equal reports whether s and s2 are equal.
+func (s *Status) Equal(s2 *Status) bool {
+ if s == nil && s2 == nil {
+ return true
+ }
+ return s != nil && s2 != nil &&
+ s.Err == s2.Err &&
+ s.URL == s2.URL &&
+ s.state == s2.state &&
+ reflect.DeepEqual(s.Persist, s2.Persist) &&
+ reflect.DeepEqual(s.NetMap, s2.NetMap)
+}
+
+func (s Status) String() string {
+ b, err := json.MarshalIndent(s, "", "\t")
+ if err != nil {
+ panic(err)
+ }
+ return s.state.String() + " " + string(b)
+}
|
