Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
602e5f5
feat: Add Tailscale support for tunneling in Commander Service
mnaqvi-ks Sep 9, 2026
b461f77
feat: Update Windows Tailscale installation to use official MSI insta…
mnaqvi-ks Sep 9, 2026
c7eea46
feat: Enhance Windows Tailscale installation to update process PATH i…
mnaqvi-ks Sep 10, 2026
9a07448
feat: Implement elevated installation for Tailscale on Windows using …
mnaqvi-ks Sep 10, 2026
032efa1
Add support for Tailscale advertise tags in service configuration
mnaqvi-ks Sep 16, 2026
26f6b45
feat: Add Tailscale Funnel support to service configuration and commands
mnaqvi-ks Sep 17, 2026
c214042
KC-1453: Fix Gateway Name not displayed in pam rotation info output (…
sshrushanth-ks Sep 18, 2026
98d24ef
handle root node visibility flag correctly
IliaTheKeeper Sep 18, 2026
20b43e9
KC-1457: Block Service Mode from ever accessing its own config record…
amangalampalli-ks Sep 18, 2026
3ebacec
Fix PAM tunnel diagnose proxy support
craiglurey Sep 18, 2026
1a2ef26
KC-1470: Protect Integration config records from Service Mode API acc…
amangalampalli-ks Sep 21, 2026
1ce067c
feat: Add Tailscale support for tunneling in Commander Service
mnaqvi-ks Sep 9, 2026
4d0fda8
feat: Update Windows Tailscale installation to use official MSI insta…
mnaqvi-ks Sep 9, 2026
11569cb
feat: Enhance Windows Tailscale installation to update process PATH i…
mnaqvi-ks Sep 10, 2026
5cfa878
feat: Implement elevated installation for Tailscale on Windows using …
mnaqvi-ks Sep 10, 2026
cd17a00
Add support for Tailscale advertise tags in service configuration
mnaqvi-ks Sep 16, 2026
d5768ee
feat: Add Tailscale Funnel support to service configuration and commands
mnaqvi-ks Sep 17, 2026
b24df09
chore: Remove obsolete Tailscale subprocess log file
mnaqvi-ks Sep 22, 2026
b7fbe33
Merge branch 'feature/tailscale-funnel-service-mode-int' of https://g…
mnaqvi-ks Sep 22, 2026
161bd09
feat: Validate Tailscale Funnel port in start_tailscale_funnel function
mnaqvi-ks Sep 23, 2026
f2c5d28
feat: Enhance Tailscale Funnel management during service startup and …
mnaqvi-ks Sep 23, 2026
1a2da6f
feat: Implement Tailscale Funnel activation verification and rollback…
mnaqvi-ks Sep 23, 2026
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
29 changes: 28 additions & 1 deletion keepercommander/commands/discoveryrotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -3294,7 +3294,34 @@ def execute(self, params, **kwargs):
if rri_status_name == 'RRS_ONLINE':

configuration_uid = utils.base64_url_encode(rri.configurationUid)
gateway_name = rri.controllerName if rri.controllerName else '-'
gateway_name = rri.controllerName
if not gateway_name and rri.controllerUid:
def _normalize_uid(uid):
if uid is None:
return None
if isinstance(uid, (bytes, bytearray)):
return utils.base64_url_encode(uid)
return str(uid)

target_uid = _normalize_uid(rri.controllerUid)
if target_uid is None:
gateway_name = None
else:
try:
all_gateways = gateway_helper.get_all_gateways(params) or []
except (Exception,) as ex:
logging.debug(f"Failed to retrieve gateway list for name resolution: {ex}")
all_gateways = []

if all_gateways:
matched = next((g for g in all_gateways
if _normalize_uid(getattr(g, 'controllerUid', None)) == target_uid), None)
if matched:
gateway_name = getattr(matched, 'controllerName', None)
if gateway_name:
logging.debug(f"Resolved gateway name from controllerUid {target_uid} -> {gateway_name}")

gateway_name = gateway_name if gateway_name else '-'
gateway_uid = utils.base64_url_encode(rri.controllerUid) if rri.controllerUid else '-'

def is_resource_ok(resource_id, params, configuration_uid):
Expand Down
35 changes: 30 additions & 5 deletions keepercommander/commands/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -1244,19 +1244,44 @@ def execute(self, params, **kwargs):
if not matched_nodes:
raise CommandError('enterprise-node', 'No nodes to toggle.')

toggled_nodes = []
for mn in matched_nodes:
node_id = mn['node_id']
data = mn['data']
displayname = data['displayname']
displayname = data.get('displayname') or str(node_id)
was_isolated = bool(mn.get('restrict_visibility'))
is_root = not mn.get('parent_id')
request = enterprise_pb2.SetRestrictVisibilityRequest()
request.nodeId = node_id
# Root isolation is an enterprise-level flag returned in
# GeneralDataEntity rather than on the root Node entity.
request.nodeId = 0 if is_root else node_id
try:
api.communicate_rest(params, request, 'enterprise/set_restrict_visibility')
mn['restrict_visibility'] = not (mn.get('restrict_visibility') or False)
logging.warning('good result: {}'.format(displayname))
toggled_nodes.append((node_id, displayname, was_isolated))
except Exception as e:
logging.warning('node \"%s\": toggle isolation failed: %s', displayname, e)
api.query_enterprise(params)
if toggled_nodes:
api.query_enterprise(params, force=True)
refreshed_nodes = {
x['node_id']: x for x in (params.enterprise or {}).get('nodes', [])
}
for node_id, displayname, was_isolated in toggled_nodes:
refreshed_node = refreshed_nodes.get(node_id)
if not refreshed_node:
logging.warning(
'node \"%s\": isolation toggle could not be verified after refresh',
displayname)
continue
is_isolated = bool(refreshed_node.get('restrict_visibility'))
if is_isolated == was_isolated:
logging.warning(
'node \"%s\": server accepted the isolation toggle, '
'but the state did not change',
displayname)
else:
logging.info(
'node \"%s\": isolation is now %s',
displayname, 'enabled' if is_isolated else 'disabled')
else:
for node_name in unmatched_nodes:
logging.warning('Node \'%s\' is not found: Skipping', node_name)
Expand Down
73 changes: 43 additions & 30 deletions keepercommander/commands/tunnel_and_connections.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,13 @@

import argparse
import datetime
import http.client
import json
import logging
import os
import platform
import requests
import signal
import socket
import ssl
import struct
import subprocess
import sys
Expand Down Expand Up @@ -1732,48 +1731,60 @@ def _parse_stun(cls, data: bytes) -> dict:

# ── individual Python-side tests ──────────────────────────────────────────
@classmethod
def _test_https(cls, hostname: str, port: int = 443) -> Tuple[bool, str, int]:
def _test_https(cls, hostname: str, port: int = 443, proxies=None, verify=True) -> Tuple[bool, str, int]:
"""Returns (passed, detail, ms)."""
t0 = time.monotonic()
conn = None
resp = None
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(hostname, port=port, context=ctx, timeout=10)
conn.request('GET', '/', headers={'User-Agent': 'keeper-pam-diagnose/1.0'})
resp = conn.getresponse()
resp = requests.get(
f'https://{hostname}:{port}/',
headers={'User-Agent': 'keeper-pam-diagnose/1.0'},
proxies=proxies,
verify=verify,
timeout=10,
stream=True,
)
ms = int((time.monotonic() - t0) * 1000)
return 100 <= resp.status < 400, f'HTTP {resp.status} (reachable)', ms
return 100 <= resp.status_code < 400, f'HTTP {resp.status_code} (reachable)', ms
except Exception as exc:
return False, str(exc)[:60], int((time.monotonic() - t0) * 1000)
finally:
if conn:
try: conn.close()
except Exception: pass
if resp is not None:
try:
resp.close()
except Exception:
pass

@classmethod
def _test_websocket(cls, hostname: str, port: int = 443) -> Tuple[bool, str, int]:
def _test_websocket(cls, hostname: str, port: int = 443, proxies=None, verify=True) -> Tuple[bool, str, int]:
"""HTTP Upgrade probe — any 4xx means the server is reachable."""
t0 = time.monotonic()
conn = None
resp = None
try:
ctx = ssl.create_default_context()
conn = http.client.HTTPSConnection(hostname, port=port, context=ctx, timeout=10)
conn.request('GET', '/', headers={
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version': '13',
'User-Agent': 'keeper-pam-diagnose/1.0',
})
resp = conn.getresponse()
resp = requests.get(
f'https://{hostname}:{port}/',
headers={
'Upgrade': 'websocket',
'Connection': 'Upgrade',
'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version': '13',
'User-Agent': 'keeper-pam-diagnose/1.0',
},
proxies=proxies,
verify=verify,
timeout=10,
stream=True,
)
ms = int((time.monotonic() - t0) * 1000)
return 100 <= resp.status < 400, f'HTTP {resp.status}', ms
return 100 <= resp.status_code < 400, f'HTTP {resp.status_code}', ms
except Exception as exc:
return False, str(exc)[:60], int((time.monotonic() - t0) * 1000)
finally:
if conn:
try: conn.close()
except Exception: pass
if resp is not None:
try:
resp.close()
except Exception:
pass

@classmethod
def _test_tcp_stun(cls, hostname: str) -> Tuple[bool, str, int, Optional[str]]:
Expand Down Expand Up @@ -1963,10 +1974,12 @@ def _record(name: str, passed: bool, detail: str, ms: int):
except Exception as exc:
_record(f'DNS {server_host}', False, str(exc)[:60], int((time.monotonic() - t0) * 1000))

passed, detail, ms = self._test_https(server_host)
passed, detail, ms = self._test_https(
server_host, proxies=params.rest_context.proxies, verify=params.ssl_verify)
_record(f'HTTPS {server_host}:443', passed, detail, ms)

passed, detail, ms = self._test_websocket(connect_host)
passed, detail, ms = self._test_websocket(
connect_host, proxies=params.rest_context.proxies, verify=params.ssl_verify)
_record(f'WebSocket {connect_host}:443', passed, detail, ms)

print()
Expand Down
13 changes: 11 additions & 2 deletions keepercommander/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ def load(self, params): # type: (KeeperParams) -> None

params.enterprise['keys'] = keys
entities = set()
root_restrict_visibility = None
while True:
rq = proto.EnterpriseDataRequest()
if self._continuationToken:
Expand All @@ -202,6 +203,8 @@ def load(self, params): # type: (KeeperParams) -> None
params.enterprise['enterprise_name'] = self._enterprise.enterprise_name
if rs.generalData.distributor:
params.enterprise['distributor'] = True
if rs.HasField('generalData'):
root_restrict_visibility = rs.generalData.restrictVisibility

for ed in rs.data:
entities.add(ed.entity)
Expand All @@ -212,6 +215,11 @@ def load(self, params): # type: (KeeperParams) -> None
self._continuationToken = rs.continuationToken
if not rs.hasMore:
break
if root_restrict_visibility is not None:
root_node = next((x for x in params.enterprise.get('nodes', []) if not x.get('parent_id')), None)
if root_node:
_set_or_remove(root_node, 'restrict_visibility',
True if root_restrict_visibility else None)
if proto.MANAGED_NODES in entities:
try:
self.load_missing_role_keys(params)
Expand Down Expand Up @@ -456,8 +464,9 @@ def to_keeper_entity(self, proto_entity, keeper_entity): # type: (proto.Node, d
_set_or_remove(keeper_entity, 'rsa_enabled', True if proto_entity.rsaEnabled else None)
_set_or_remove(keeper_entity, 'sso_service_provider_id',
proto_entity.ssoServiceProviderId if proto_entity.ssoServiceProviderId > 0 else None)
_set_or_remove(keeper_entity, 'restrict_visibility',
proto_entity.restrictVisibility if proto_entity.restrictVisibility else None)
if keeper_entity.get('parent_id'):
_set_or_remove(keeper_entity, 'restrict_visibility',
proto_entity.restrictVisibility if proto_entity.restrictVisibility else None)

data = {}
if 'encrypted_data' in keeper_entity:
Expand Down
7 changes: 7 additions & 0 deletions keepercommander/resources/service_config.ini
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ ngrok_custom_domain_prompt = Enter Ngrok Custom Domain:
cloudflare_prompt = Enable Cloudflare Tunneling? (y/n):
cloudflare_token_prompt = Enter Cloudflare tunnel token:
cloudflare_custom_domain_prompt = Enter Cloudflare custom domain:
tailscale_prompt = Enable Tailscale Funnel? (y/n):
tailscale_install_prompt = Tailscale CLI is not installed. Attempt automatic installation now? (y/n):
tailscale_daemon_start_prompt = Tailscale daemon is not running. Attempt to start it now? (y/n):
tailscale_auth_key_prompt = Enter Tailscale auth key:
tailscale_advertise_tags_prompt = Enter Tailscale ACL tags to advertise, comma-separated (optional, required for OAuth-derived auth keys):
run_mode_prompt = Select run mode (foreground/background):
queue_enabled_prompt = Enable Request Queue? (y/n):
tls_certificate = Enable TLS Certificate? (y/n):
Expand All @@ -27,6 +32,8 @@ invalid_cloudflare_token = Invalid Cloudflare token:
invalid_cloudflare_domain = Invalid Cloudflare domain:
cloudflare_token_required = Cloudflare tunnel token is required when using Cloudflare tunnel.
cloudflare_domain_required = Cloudflare custom domain is required when using Cloudflare tunnel.
invalid_tailscale_auth_key = Invalid Tailscale auth key:
tailscale_auth_key_required = Tailscale auth key is required when using Tailscale Funnel.
invalid_run_mode = Invalid run mode:
invalid_certificate = Invalid Certificate:
invalid_rate_limit = Invalid rate limit:
Expand Down
43 changes: 42 additions & 1 deletion keepercommander/service/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ You'll be prompted to configure:
- Cloudflare tunneling (y/n) - *if ngrok is disabled*
- Cloudflare tunnel token (required)
- Cloudflare custom domain (required)
- Enable TLS Certificate (y/n) - *if both ngrok and cloudflare are disabled*
- Tailscale Funnel (y/n) - *if ngrok and cloudflare are disabled*
- Tailscale auth key (required)
- Tailscale ACL tags to advertise (optional, required only for OAuth-issued auth keys)
- Enable TLS Certificate (y/n) - *if ngrok, cloudflare, and tailscale are all disabled*
- TLS Certificate path
- TLS Certificate password
- Enable Request Queue (y/n)
Expand Down Expand Up @@ -80,13 +83,29 @@ Configure the service streamlined with Cloudflare:
My Vault> service-create -p <port> -f <json-or-yaml> -c 'tree,record-add,audit-report' -cf <cloudflare-tunnel-token> -cfd <cloudflare-custom-domain> -rm <foreground-or-background> -q <y-or-n> -aip <allowed-ip-list> -dip <denied-ip-list>
```

Configure the service streamlined with Tailscale:

```bash
My Vault> service-create -p <port> -f <json-or-yaml> -c 'tree,record-add,audit-report' -ts <tailscale-auth-key> -rm <foreground-or-background> -q <y-or-n> -aip <allowed-ip-list> -dip <denied-ip-list>
```

If the auth key was generated by an OAuth client, also pass the ACL tag(s) it's scoped to:

```bash
My Vault> service-create -p <port> -f <json-or-yaml> -c 'tree,record-add,audit-report' -ts <tailscale-auth-key> -tst tag:commander-service -rm <foreground-or-background> -q <y-or-n> -aip <allowed-ip-list> -dip <denied-ip-list>
```

**Note:** Commander always forces a fresh re-authentication (`tailscale up --force-reauth`) on every `service-create`/`service-start`, to guarantee the provided auth key is actually validated rather than silently reused from an existing session. A practical consequence: **a single-use auth key will only work for one successful start** — use a reusable key if you expect to restart the service more than once.

Parameters:
- `-p, --port`: Port number for the service
- `-c, --commands`: Comma-separated list of allowed commands
- `-ng, --ngrok`: Ngrok authentication token for public URL access
- `-cd, --ngrok_custom_domain`: Ngrok custom domain name
- `-cf, --cloudflare`: Cloudflare tunnel token (required when using cloudflare)
- `-cfd, --cloudflare_custom_domain`: Cloudflare custom domain name (required when using cloudflare)
- `-ts, --tailscale`: Tailscale auth key to authenticate and generate public URL via Funnel (required when using tailscale)
- `-tst, --tailscale_advertise_tags`: Comma-separated ACL tags to advertise (required only when the auth key is OAuth-client-issued, e.g. `tag:commander-service`)
- `-f, --fileformat`: File format (json/yaml)
- `-crtf, --certfile`: Certificate file path
- `-crtp, --certpassword`: Certificate password
Expand Down Expand Up @@ -320,6 +339,11 @@ The service configuration is stored as an attachment to a vault record in JSON/Y
- Cloudflare tunnel token
- Cloudflare custom domain
- Generated public URL
- **Tailscale Configuration** (optional):
- Tailscale Funnel enabled/disabled
- Tailscale auth key
- Tailscale ACL tags to advertise
- Generated public URL
- **TLS Certificate Configuration** (optional):
- TLS certificate enabled/disabled
- Certificate file path
Expand Down Expand Up @@ -373,6 +397,13 @@ When Cloudflare tunneling is enabled, additional logs are maintained:
- **Includes**: Tunnel establishment, connection timeout detection, and firewall blocking diagnostics
- **Auto-created**: Created automatically when Cloudflare tunneling is configured and service starts

### Tailscale Logging
When Tailscale Funnel is enabled, additional logs are maintained:
- **Location**: `keepercommander/service/core/logs/tailscale_subprocess.log`
- **Content**: Tailscale CLI authentication attempts, Funnel enable/disable events, and status-check output
- **Includes**: `tailscale up`/`tailscale funnel` command output (the auth key value is never written to this log)
- **Auto-created**: Created automatically when Tailscale Funnel is configured and service starts

### General Logging Configuration
- **Configuration file**: `~/.keeper/logging_config.yaml` (auto-generated)
- **Default level**: `INFO`
Expand Down Expand Up @@ -502,6 +533,16 @@ This automates the complete setup for Slack App integration:

The command generates a complete `docker-compose.yml` with both Commander service and Slack App service configured.

**Generated compose environment for the Slack service:**

| Env var | Value |
|---------|-------|
| `KSM_CONFIG` | Base64 KSM config |
| `COMMANDER_RECORD` | Commander Docker config record UID |
| `SLACK_RECORD` | Slack config record UID |

Image name used in compose: `keeper/slack-app:latest`.

### Google Chat App Integration Setup

For integrating Commander Service Mode with Google Chat, use the `gchat-app-setup` command:
Expand Down
Loading