Skip to content
Open
12 changes: 12 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,18 @@ options:
description: |
A cookie encryption key used for API authentication flows. The value should be
32 url-safe, base64 encoded bytes. If not set, one will be generated securely.
gpg_secret_id:
type: string
default:
description: |
Juju secret URI (e.g. secret:xxxx) containing the GPG credentials used by
Landscape Server to sign repository metadata. The secret must contain two fields:
- gpg-passphrase: the GPG key passphrase
- gpg-private-key: the ASCII-armored GPG private key
The secret must be granted to the landscape-server application before setting
this option. When set, the charm writes /etc/landscape/gpg-passphrase and
imports the private key into /etc/landscape/gpg on install and whenever the
secret is rotated.
min_install:
type: boolean
default: False
Expand Down
100 changes: 98 additions & 2 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
MaintenanceStatus,
ModelError,
Relation,
SecretNotFoundError,
WaitingStatus,
)
from pydantic import ValidationError
Expand Down Expand Up @@ -100,6 +101,9 @@
HASH_ID_DATABASES = "/opt/canonical/landscape/hash-id-databases-ignore-maintenance"
UPDATE_WSL_DISTRIBUTIONS_SCRIPT = "/opt/canonical/landscape/update-wsl-distributions"

GPG_HOME_DIR = "/etc/landscape-server/gpg"
GPG_PASSPHRASE_FILE = "/etc/landscape-server/gpg-passphrase.txt"

LANDSCAPE_SERVER = "landscape-server"
LANDSCAPE_PACKAGES = (
LANDSCAPE_SERVER,
Expand Down Expand Up @@ -274,6 +278,8 @@ def __init__(self, *args):
self.on.get_service_conf_action, self._on_get_service_conf_action
)

# Secrets
self.framework.observe(self.on.secret_changed, self._on_secret_changed)
# SMTP
self.smtp = SmtpRequires(self)
self.framework.observe(
Expand Down Expand Up @@ -536,6 +542,8 @@ def _on_config_changed(self, _) -> None:
self._write_cookie_encryption_key(cookie_encryption_key)
self._stored.cookie_encryption_key = cookie_encryption_key

self._configure_gpg()

self._update_ready_status(restart_services=True)
self._provide_all_haproxy_route_requirements()

Expand Down Expand Up @@ -600,6 +608,93 @@ def _write_cookie_encryption_key(self, cookie_encryption_key):
logger.info("Writing cookie encryption key")
update_service_conf({"api": {"cookie-encryption-key": cookie_encryption_key}})

def _configure_gpg(self) -> bool:
"""Write GPG credentials from the configured Juju secret.

Writes the passphrase to GPG_PASSPHRASE_FILE and imports the private
key into GPG_HOME_DIR. Returns True when GPG was configured
successfully, False when no secret is configured. Sets a BlockedStatus
and returns False on error.
"""
secret_id = self.charm_config.gpg_secret_id
if not secret_id:
logger.debug("gpg_secret_id not configured, skipping GPG setup")
return False
Comment thread
jansdhillon marked this conversation as resolved.

self.unit.status = MaintenanceStatus("Configuring GPG credentials")

try:
secret = self.model.get_secret(id=secret_id)
content = secret.get_content(refresh=True)
except (SecretNotFoundError, ModelError):
logger.warning("GPG secret '%s' not found or not accessible", secret_id)
self.unit.status = BlockedStatus("GPG secret not found or not accessible")
return False

passphrase = content.get("gpg-passphrase")
private_key = content.get("gpg-private-key")

if not passphrase or not private_key:
logger.warning(
"GPG secret is missing required fields: "
"'gpg-passphrase' and/or 'gpg-private-key'"
)
self.unit.status = BlockedStatus("GPG secret missing required fields")
return False

landscape_user = user_exists("landscape")
landscape_uid = landscape_user.pw_uid
landscape_gid = landscape_user.pw_gid

os.makedirs(GPG_HOME_DIR, mode=0o700, exist_ok=True)
Comment thread
jansdhillon marked this conversation as resolved.
# Enforce 0700 explicitly — makedirs is subject to umask
os.chmod(GPG_HOME_DIR, 0o700)
os.chown(GPG_HOME_DIR, landscape_uid, landscape_gid)

# Set umask to 0o177 so open() creates the file with 0o600 immediately,
# avoiding a window where it could be world-readable
old_umask = os.umask(0o177)
try:
with open(GPG_PASSPHRASE_FILE, "w") as fp:
fp.write(passphrase)
finally:
os.umask(old_umask)
os.chown(GPG_PASSPHRASE_FILE, landscape_uid, landscape_gid)

try:
subprocess.run(
[
"gpg",
"--homedir",
GPG_HOME_DIR,
"--batch",
"--passphrase-file",
GPG_PASSPHRASE_FILE,
"--import",
],
input=private_key,
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as e:
logger.error("Failed to import GPG private key: %s", e.stderr)
self.unit.status = BlockedStatus("Failed to import GPG key")
return False

logger.info("GPG credentials configured successfully")
self.unit.status = WaitingStatus("Waiting on relations")
return True

def _on_secret_changed(self, event) -> None:
"""Re-configure GPG credentials when the GPG secret is rotated."""
secret_id = self.charm_config.gpg_secret_id
if not secret_id or event.secret.id != secret_id:
return

if self._configure_gpg():
self._update_ready_status(restart_services=True)

def _on_upgrade_charm(self, _: UpgradeCharmEvent) -> None:
self._provide_all_haproxy_route_requirements()

Expand Down Expand Up @@ -661,12 +756,13 @@ def _on_install(self, event: InstallEvent) -> None:
write_license_file(
license_file, user_exists("landscape").pw_uid, self.root_gid
)

self.unit.status = ActiveStatus("Unit is ready")
self.unit.status = WaitingStatus("Waiting on relations")

# Indicate that this install is a charm install.
prepend_default_settings({"DEPLOYED_FROM": "charm"})

self._configure_gpg()

self._update_ready_status()

def _update_status(self, event: UpdateStatusEvent) -> None:
Expand Down
1 change: 1 addition & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def landscape_ppas(self) -> list[str]:
additional_service_config: str | None = None
secret_token: str | None = None
cookie_encryption_key: str | None = None
gpg_secret_id: str | None = None
min_install: bool
prometheus_scrape_interval: str
autoregistration: bool
Expand Down
Loading
Loading