Repository: canonical/microk8s

Issue:
join command with hostname misconfigures dqlite and puts it in error state
<!--
   Thank you for submitting an issue. Please fill in the template below
   information about the bug you encountered.
-->

#### Summary
<!-- Please explain the bug in a few short sentences -->

microk8s join command allows to use the hostname of the master node instead of its IP and this is causing issues. e.g. `microk8s join micro-ctrl-0.maas:25000/123abc456def789ghi012klm345nop678qr`

- With the hostname, worker nodes can join the cluster successfully
- On the other hand, control plane nodes are receiving a timeout while waiting to join the cluster

After investigation, I found that the problem is greater than the timeout. Being more specific, when a join is received with a hostname, dqlite will be tried to get configured to bind to this IP [link](https://github.com/canonical/microk8s-cluster-agent/blob/main/pkg/api/v2/api_util.go#L14). Since hostname is not an IP, it is getting misconfigured and the following logs can be found on master node:

```bash
Jan 30 08:53:49 micro-ctrl-0 microk8s.daemon-cluster-agent[2564]: 2024/01/30 08:53:49 [ERROR 500] "invalid token"
Jan 30 08:53:49 micro-ctrl-0 microk8s.daemon-cluster-agent[2564]: 2024/01/30 08:53:49 POST 500 "/cluster/api/v2.0/join" 25 bytes in 79.987271ms
Jan 30 09:01:12 micro-ctrl-0 microk8s.daemon-cluster-agent[2564]: 2024/01/30 09:01:12 [WARNING] failed to parse IP address micro-ctrl-0
Jan 30 09:01:12 micro-ctrl-0 microk8s.daemon-cluster-agent[2564]: 2024/01/30 09:01:12 [WARNING] will attempt to use micro-ctrl-0 as dqlite bind address
```

#### What Should Happen Instead?
<!-- Please explain what the expected behavior is -->

Join should succeed for control plane nodes as well. In addition, dqlite should not being put at an erroneous state.

#### Reproduction Steps
<!-- Are you able to consistently reproduce the issue? Please add a list of steps that lead to the bug. -->

1. Successful join on worker nodes
    ```bash
    ubuntu@micro-worker-2:~$ microk8s join micro-ctrl-0.maas:25000/3a79974e043e1649d3261596c63faae6/7eea3d3f54c2 --worker
    Contacting cluster at micro-ctrl-0.maas
    
    The node has joined the cluster and will appear in the nodes list in a few seconds.
    ```
3. Failed join on control plane nodes
    ```bash
    ubuntu@micro-worker-2:~$ microk8s join micro-ctrl-0:25000/899968961a9a7e92b916b9db033c4a06/7eea3d3f54c2
    Contacting cluster at micro-ctrl-0
    Waiting for this node to finish joining the cluster. .. .. .. .. .. .. .. .. .. ..
    ```

#### Introspection Report
<!-- Please run `microk8s inspect` and attach the generated tarball. -->

#### Can you suggest a fix?
<!-- (This section is optional). How do you propose that the issue be fixed? -->

microk8s join must do a validation on the connection string and accept only IP addresses rather than hostnames.

#### Are you interested in contributing with a fix?
<!-- yes/no, or @mention maintainers. Community contributions are welcome. -->
Yes and a PR will follow next.
<!-- Thank you for making MicroK8s better -->



Discussion:

Resolving patch:
diff --git a/scripts/wrappers/add_token.py b/scripts/wrappers/add_token.py
--- a/scripts/wrappers/add_token.py
+++ b/scripts/wrappers/add_token.py
@@ -6,7 +6,7 @@
 import argparse
 import subprocess
 
-from common.cluster.utils import is_node_running_dqlite
+from common.cluster.utils import is_node_running_dqlite, TOKEN_ΜΙΝ_LEN
 
 try:
     from secrets import token_hex
@@ -165,7 +165,7 @@ def print_short(token, check):
     else:
         token = token_hex(16)
 
-    if len(token) < 32:
+    if len(token) < TOKEN_ΜΙΝ_LEN:
         print("Invalid token size.  It must be 32 characters long.")
         exit(1)
 
diff --git a/scripts/wrappers/common/cluster/utils.py b/scripts/wrappers/common/cluster/utils.py
--- a/scripts/wrappers/common/cluster/utils.py
+++ b/scripts/wrappers/common/cluster/utils.py
@@ -1,5 +1,6 @@
 import base64
 import datetime
+import ipaddress
 import json
 import os
 import random
@@ -14,6 +15,13 @@
 
 import yaml
 
+FINGERPRINT_MIN_LEN = 12
+TOKEN_ΜΙΝ_LEN = 32
+
+
+class InvalidConnectionError(Exception):
+    pass
+
 
 def is_strict():
     snap_yaml = snap() / "meta/snap.yaml"
@@ -544,3 +552,45 @@ def rebuild_x509_auth_client_configs():
         stdout=subprocess.DEVNULL,
         stderr=subprocess.DEVNULL,
     )
+
+
+def get_valid_connection_parts(connection):
+    """
+    Ensure that connection has a valid format <master_ip>:<master_port>/<token>[/<fingerprint>]
+
+    :param connection: the connection string
+    :return: connection parts
+    :raise:
+        InvalidConnectionError: if connection string is not valid
+    """
+    connection_parts = connection.split("/")
+
+    if len(connection_parts) not in range(2, 3):
+        raise InvalidConnectionError(
+            "Expected format: <master_IP>:<master_PORT>/<token>[/<fingerprint>]"
+        )
+
+    master_ep = connection_parts[0].split(":")
+    if len(master_ep) != 2:
+        raise InvalidConnectionError(
+            "Expected format: <master_IP>:<master_PORT>/<token>[/<fingerprint>]"
+        )
+
+    try:
+        ipaddress.ip(master_ep[0])
+    except ValueError:
+        raise InvalidConnectionError("Invalid master IP")
+
+    try:
+        if int(master_ep[1]) not in range(1, 65535):
+            raise InvalidConnectionError("Master PORT not in range 1:65535")
+    except ValueError:
+        raise InvalidConnectionError("Master PORT not a number")
+
+    if len(connection_parts[1]) < TOKEN_ΜΙΝ_LEN:
+        raise InvalidConnectionError(f"Cluster token size should be at least {TOKEN_ΜΙΝ_LEN} bytes")
+
+    if len(connection_parts) == 3 and len(connection_parts[2]) < FINGERPRINT_MIN_LEN:
+        raise InvalidConnectionError(f"Fingerprint should be at least {FINGERPRINT_MIN_LEN} bytes")
+
+    return connection_parts
diff --git a/scripts/wrappers/join.py b/scripts/wrappers/join.py
--- a/scripts/wrappers/join.py
+++ b/scripts/wrappers/join.py
@@ -23,6 +23,7 @@
     get_cluster_agent_port,
     get_cluster_cidr,
     get_token,
+    get_valid_connection_parts,
     is_low_memory_guard_enabled,
     is_node_running_dqlite,
     is_token_auth_enabled,
@@ -34,6 +35,8 @@
     try_set_file_permissions,
     snap,
     snap_data,
+    FINGERPRINT_MIN_LEN,
+    InvalidConnectionError,
 )
 
 urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
@@ -53,8 +56,6 @@
 cluster_cert_file = "{}/cluster.crt".format(cluster_dir)
 cluster_key_file = "{}/cluster.key".format(cluster_dir)
 
-FINGERPRINT_MIN_LEN = 12
-
 
 def get_traefik_port():
     """
@@ -969,7 +970,12 @@ def join(connection, worker, skip_verify, disable_low_memory_guard):
 
     CONNECTION: the cluster connection endpoint in format <master>:<port>/<token>
     """
-    connection_parts = connection.split("/")
+    try:
+        connection_parts = get_valid_connection_parts(connection)
+    except InvalidConnectionError as err:
+        print("Invalid connection:", err)
+        sys.exit(1)
+
     verify = not skip_verify
 
     if is_low_memory_guard_enabled() and disable_low_memory_guard:


Test patch: