Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion docs/management-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ certificate; reach it with `--insecure`. It has no configured address, only the
one DHCP gave it, so its console shows that address under **Address** and the
certificate's **Mgmt SHA-256**.

With no DHCP server on the link (a laptop cabled straight to the node, for
example), the node assigns itself an IPv4 link-local address (RFC 3927) in
169.254.0.0/16 on the first interface with a cable in, and the console shows
that address instead. The address is derived from the interface's MAC, so the
node comes back on the same one every boot. A routable address always takes
priority: the link-local one is only assigned, and only shown, when the node
has no other IPv4 address. Give the laptop a 169.254.x.x address too (macOS
and Windows assign one on their own when DHCP gets no answer; on Linux set the
connection's IPv4 method to link-local, or add one with `ip addr add`) and
reach the node at the console's address:

```sh
cryptosctl --endpoint 169.254.x.x:443 --insecure config apply -f node.yaml
```

The maintenance certificate names only `localhost`, whatever the address, so
`--insecure` is what reaches it on a DHCP address and on a link-local one alike;
the check is the console's **Mgmt SHA-256**, as below.

> [!CAUTION]
> Compare the console's **Mgmt SHA-256** with the fingerprint the Fleet
> Manager's adoption preview (or `openssl s_client`) shows before you adopt the
Expand Down Expand Up @@ -120,7 +139,8 @@ same form as on the serving dashboard:
| Intermediate or issuing CA waiting for its parent | **Awaiting parent certificate** | Fetch trust, then get the CSR signed |

Only the maintenance screen also shows an **Address** line: the node's IPv4
addresses, one per line. An installed node uses the address in its config.
addresses, one per line, or its self-assigned 169.254.x.x address when it has
no other. An installed node uses the address in its config.

The two screens an adoption compares against say which check they are: under
the fingerprint, the maintenance screen shows **check 1/2: compare with the
Expand Down
5 changes: 3 additions & 2 deletions internal/console/dashboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ func fleetLabel(s FleetState) string {
// network or crypto identifiers, with two exceptions: MgmtFingerprint, the
// SHA-256 of the public management certificate, which exists so an operator
// can verify a pin against the node's own screen, and MgmtAddrs, which only
// the maintenance screen shows, because a node with no config has a DHCP
// address the operator cannot know in advance.
// the maintenance screen shows, because a node with no config has a DHCP (or,
// with no DHCP server, a self-assigned link-local) address the operator cannot
// know in advance.
type View struct {
RootCN string
Issuer string
Expand Down
28 changes: 22 additions & 6 deletions internal/console/mgmtcert.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,26 @@ func readManagementCert(path string) *x509.Certificate {
return cert
}

// ManagementAddrs returns, in order, the addresses in addrs an operator can
// reach the management listener on: IPv4 only, since the node configures no
// IPv6, and neither loopback nor link-local.
// ManagementAddrs returns, in order, the routable addresses in addrs an
// operator can reach the management listener on: IPv4 only, since the node
// configures no IPv6, and neither loopback nor link-local.
func ManagementAddrs(addrs []net.Addr) []string {
return ipv4Addrs(addrs, func(ip net.IP) bool { return !ip.IsLoopback() && !ip.IsLinkLocalUnicast() })
}

// ReachableAddrs returns ManagementAddrs when there is at least one, else the
// IPv4 link-local (169.254.0.0/16) addresses in addrs. A maintenance node on
// a network with no DHCP server has only the link-local address it assigned
// itself, and a laptop cabled straight to it reaches the listener there. IPv6
// link-local addresses are never returned: the node configures no IPv6.
func ReachableAddrs(addrs []net.Addr) []string {
if out := ManagementAddrs(addrs); len(out) > 0 {
return out
}
return ipv4Addrs(addrs, net.IP.IsLinkLocalUnicast)
}

func ipv4Addrs(addrs []net.Addr, keep func(net.IP) bool) []string {
var out []string
for _, a := range addrs {
var ip net.IP
Expand All @@ -106,20 +122,20 @@ func ManagementAddrs(addrs []net.Addr) []string {
ip = v.IP
}
ip4 := ip.To4()
if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() {
if ip4 == nil || !keep(ip4) {
continue
}
out = append(out, ip4.String())
}
return out
}

// LocalManagementAddrs returns ManagementAddrs for this host's interfaces, or
// LocalManagementAddrs returns ReachableAddrs for this host's interfaces, or
// nil when they cannot be read.
func LocalManagementAddrs() []string {
addrs, err := net.InterfaceAddrs()
if err != nil {
return nil
}
return ManagementAddrs(addrs)
return ReachableAddrs(addrs)
}
63 changes: 63 additions & 0 deletions internal/console/mgmtcert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,69 @@ func TestManagementAddrsKeepsReachableIPv4(t *testing.T) {
}
}

func ipNet(cidr string) net.Addr {
ip, n, err := net.ParseCIDR(cidr)
if err != nil {
panic(err)
}
return &net.IPNet{IP: ip, Mask: n.Mask}
}

// With no DHCP server the node's only IPv4 address is the link-local one it
// assigned itself, and that is what the console shows. A routable address
// always wins, loopback is never shown, and IPv6 (the node configures none,
// so any fe80:: address is the kernel's own) is ignored either way.
func TestReachableAddrsFallsBackToLinkLocal(t *testing.T) {
cases := []struct {
name string
addrs []net.Addr
want string
}{
{"routable present", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("192.0.2.10/24"), ipNet("fe80::1/64")}, "192.0.2.10"},
{"only link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("fe80::1/64")}, "169.254.10.1"},
{"two link-local in order", []net.Addr{ipNet("169.254.10.1/16"), &net.IPAddr{IP: net.ParseIP("169.254.20.2")}}, "169.254.10.1,169.254.20.2"},
{"only loopback", []net.Addr{ipNet("127.0.0.1/8")}, ""},
{"only IPv6 link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("fe80::1/64")}, ""},
{"none", nil, ""},
}
for _, tc := range cases {
if got := strings.Join(console.ReachableAddrs(tc.addrs), ","); got != tc.want {
t.Errorf("%s: ReachableAddrs = %q, want %q", tc.name, got, tc.want)
}
}
}

func TestRenderDashboardMaintenanceAddressLineForEachAddressCase(t *testing.T) {
fp := console.Fingerprint([]byte("maintenance cert"))
cases := []struct {
name string
addrs []net.Addr
want string
}{
{"routable present", []net.Addr{ipNet("169.254.10.1/16"), ipNet("192.0.2.10/24")}, "Address 192.0.2.10"},
{"only link-local", []net.Addr{ipNet("127.0.0.1/8"), ipNet("169.254.10.1/16"), ipNet("fe80::1/64")}, "Address 169.254.10.1"},
{"only loopback", []net.Addr{ipNet("127.0.0.1/8")}, ""},
{"only IPv6 link-local", []net.Addr{ipNet("fe80::1/64")}, ""},
}
for _, tc := range cases {
v := maintenanceView(fp, console.ReachableAddrs(tc.addrs)...)
var got string
for _, ln := range screenLines(console.RenderDashboard(v, 64, 24)) {
ln = strings.Join(strings.Fields(strings.Trim(ln, "| ")), " ")
if strings.HasPrefix(ln, "Address") {
got = ln
}
}
if got != tc.want {
t.Errorf("%s: Address line = %q, want %q", tc.name, got, tc.want)
}
compact := stripSGR(console.RenderDashboard(v, 30, 10))
if hasIP := strings.Contains(compact, "IP "); hasIP != (tc.want != "") {
t.Errorf("%s: compact IP line shown = %v, want %v:\n%s", tc.name, hasIP, tc.want != "", compact)
}
}
}

// awaitingCeremonyView is an installed Root node that has not run its
// ceremony yet.
func awaitingCeremonyView(fp string) console.View {
Expand Down
173 changes: 173 additions & 0 deletions internal/init/linklocal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package init

/*
Copyright The CryptOS Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"log"
"net"
"net/netip"
"time"

"github.com/CryptOS-PKI/cryptos-node/internal/console"
"github.com/CryptOS-PKI/cryptos-node/internal/init/netlink"
)

// linkLocalInterval is how often a maintenance node checks whether it needs a
// self-assigned address. A laptop can be cabled in at any time after boot.
const linkLocalInterval = 2 * time.Second

// nicState is the part of an interface the link-local fallback reads.
type nicState struct {
Name string
MAC net.HardwareAddr
Up bool
Carrier bool
Loopback bool
Addrs []net.Addr
}

// linkLocalDeps are the host operations the fallback needs, injectable so the
// selection logic is tested without touching the host's interfaces.
type linkLocalDeps struct {
nics func() ([]nicState, error)
linkUp func(name string) error
addAddr func(name string, p netip.Prefix) error
}

// linkLocalAddr returns the IPv4 link-local address (RFC 3927) a node with
// this MAC assigns itself: drawn from 169.254.1.0 to 169.254.254.255 and seeded
// from the MAC, as RFC 3927 section 2.1 recommends, so a node comes back on the
// same address every boot and the operator can keep using it.
func linkLocalAddr(mac net.HardwareAddr) netip.Addr {
sum := sha256.Sum256(mac)
n := binary.BigEndian.Uint16(sum[:2]) % (254 * 256)
return netip.AddrFrom4([4]byte{169, 254, byte(1 + n/256), byte(n % 256)})
}

// step makes one pass. When no interface has a reachable IPv4 address (the
// kernel's ip=dhcp got no answer, and nothing has been assigned yet), it brings
// down Ethernet links up so their carrier shows, and gives the first one with
// carrier its link-local address in 169.254.0.0/16. Only one interface gets one,
// so the node never holds two connected routes for the same prefix. It returns
// the prefix it assigned and the interface, or the zero prefix when it did
// nothing.
//
// No ARP probe is sent before claiming the address: the case this serves is a
// laptop cabled straight to the node, where the only other host on the link
// picks its own address the same way, and a clash is a 1 in 65024 chance that
// the operator sees at once and fixes by replugging.
func (d linkLocalDeps) step() (netip.Prefix, string, error) {
nics, err := d.nics()
if err != nil {
return netip.Prefix{}, "", err
}
var all []net.Addr
for _, n := range nics {
all = append(all, n.Addrs...)
}
if len(console.ReachableAddrs(all)) > 0 {
return netip.Prefix{}, "", nil
}
var errs []error
for _, n := range nics {
if n.Loopback || len(n.MAC) != 6 {
continue
}
if !n.Up {
if err := d.linkUp(n.Name); err != nil {
errs = append(errs, err)
}
continue
}
if !n.Carrier {
continue
}
p := netip.PrefixFrom(linkLocalAddr(n.MAC), 16)
if err := d.addAddr(n.Name, p); err != nil {
return netip.Prefix{}, "", errors.Join(append(errs, err)...)
}
return p, n.Name, errors.Join(errs...)
}
return netip.Prefix{}, "", errors.Join(errs...)
}

// runLinkLocalFallback runs step every interval until ctx ends, so a maintenance
// node with no DHCP server stays reachable on a self-assigned address. The
// console picks the address up on its next refresh (console.ReachableAddrs).
func runLinkLocalFallback(ctx context.Context, d linkLocalDeps, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
var lastErr string
for {
p, nic, err := d.step()
if p.IsValid() {
log.Printf("maintenance: no DHCP address; assigned link-local %s to %s", p, nic)
}
msg := ""
if err != nil {
msg = err.Error()
}
if msg != "" && msg != lastErr {
log.Printf("maintenance: link-local fallback: %v", err)
}
lastErr = msg
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}

// startLinkLocalFallback starts runLinkLocalFallback on this host's interfaces
// and returns the func that stops it.
func startLinkLocalFallback(ctx context.Context) func() {
ctx, cancel := context.WithCancel(ctx)
go runLinkLocalFallback(ctx, linkLocalDeps{
nics: localNICs,
linkUp: netlink.SetLinkUp,
addAddr: netlink.AddAddress,
}, linkLocalInterval)
return cancel
}

func localNICs() ([]nicState, error) {
ifs, err := net.Interfaces()
if err != nil {
return nil, err
}
out := make([]nicState, 0, len(ifs))
for _, ifc := range ifs {
addrs, err := ifc.Addrs()
if err != nil {
return nil, err
}
out = append(out, nicState{
Name: ifc.Name,
MAC: ifc.HardwareAddr,
Up: ifc.Flags&net.FlagUp != 0,
Carrier: ifc.Flags&net.FlagRunning != 0,
Loopback: ifc.Flags&net.FlagLoopback != 0,
Addrs: addrs,
})
}
return out, nil
}
Loading
Loading