secret_fields.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. """Write-only secrets: notification provider configs, settings, smart plugs.
  2. Responses show a set secret as SECRET_PLACEHOLDER. A config sent back with
  3. the placeholder keeps the stored value, an empty value clears it, and anything
  4. else replaces it. A stored secret is only used for the destination it was
  5. entered for: a config that changes the destination (or the provider type) must
  6. carry the secret again, and a type change must bring a config of its own.
  7. """
  8. from __future__ import annotations
  9. from typing import Any
  10. SECRET_PLACEHOLDER = "********"
  11. # The config fields that hold a credential, per provider type. For Discord and
  12. # the generic webhook the URL itself is commonly the credential.
  13. SECRET_FIELDS: dict[str, tuple[str, ...]] = {
  14. "callmebot": ("apikey",),
  15. "ntfy": ("auth_token",),
  16. "pushover": ("user_key", "app_token"),
  17. "telegram": ("bot_token",),
  18. "email": ("password",),
  19. "discord": ("webhook_url",),
  20. "webhook": ("webhook_url", "auth_header"),
  21. "bark": ("device_key",),
  22. "gotify": ("app_token",),
  23. "notify": ("token",),
  24. "homeassistant": (),
  25. }
  26. # Every secret field name of every type. Responses mask these whatever the
  27. # provider's type, so a key left over from another type is never shown.
  28. ALL_SECRET_FIELDS = frozenset(field for fields in SECRET_FIELDS.values() for field in fields)
  29. # The config fields that decide where the secrets are sent. Types not listed
  30. # send to a fixed service.
  31. DESTINATION_FIELDS: dict[str, tuple[str, ...]] = {
  32. "email": ("smtp_server", "smtp_port", "security"),
  33. "ntfy": ("server",),
  34. "bark": ("server",),
  35. "gotify": ("server",),
  36. "webhook": ("webhook_url",),
  37. }
  38. class SecretConfigError(ValueError):
  39. """A config that would reuse a stored secret where it may not."""
  40. def mask_config(config: dict[str, Any]) -> dict[str, Any]:
  41. """The config as a response shows it: every set secret as the placeholder."""
  42. return {key: (SECRET_PLACEHOLDER if key in ALL_SECRET_FIELDS and value else value) for key, value in config.items()}
  43. def same_value(a: Any, b: Any) -> bool:
  44. """Equal as the edit dialog sends them back: as text, None as empty.
  45. Not stripped: a sender compares the exact value (" starttls" is not TLS).
  46. """
  47. return ("" if a is None else str(a)) == ("" if b is None else str(b))
  48. # What a sender uses for a destination field that is not in the config at all.
  49. _SENDER_DEFAULTS: dict[tuple[str, str], str] = {
  50. ("email", "security"): "starttls",
  51. }
  52. def _effective(provider_type: str, config: dict[str, Any], field: str) -> Any:
  53. if field in config:
  54. return config[field]
  55. return _SENDER_DEFAULTS.get((provider_type, field))
  56. def has_placeholder(config: dict[str, Any]) -> bool:
  57. return any(value == SECRET_PLACEHOLDER for value in config.values())
  58. def reject_placeholder(config: dict[str, Any]) -> None:
  59. """A config with nothing stored behind it (a new provider, a test of one)."""
  60. if has_placeholder(config):
  61. raise SecretConfigError("Enter the secret values; nothing is stored for this provider yet")
  62. def merge_config(
  63. stored_type: str,
  64. stored: dict[str, Any],
  65. new_type: str,
  66. incoming: dict[str, Any],
  67. ) -> dict[str, Any]:
  68. """The config to use: the placeholder replaced by the stored secret.
  69. Refused when a placeholder would carry a stored secret to another provider
  70. type or another destination.
  71. """
  72. kept = [key for key, value in incoming.items() if value == SECRET_PLACEHOLDER]
  73. if not kept:
  74. return dict(incoming)
  75. if new_type != stored_type:
  76. raise SecretConfigError(f"Enter {', '.join(kept)} again for the new provider type")
  77. unknown = [key for key in kept if key not in ALL_SECRET_FIELDS or not stored.get(key)]
  78. if unknown:
  79. raise SecretConfigError(f"Enter {', '.join(unknown)}; there is no stored value to keep")
  80. moved = [
  81. field
  82. for field in DESTINATION_FIELDS.get(stored_type, ())
  83. if incoming.get(field) != SECRET_PLACEHOLDER
  84. and not same_value(_effective(stored_type, incoming, field), _effective(stored_type, stored, field))
  85. ]
  86. if moved:
  87. raise SecretConfigError(f"Enter {', '.join(kept)} again: {', '.join(moved)} changed")
  88. return {key: (stored[key] if value == SECRET_PLACEHOLDER else value) for key, value in incoming.items()}
  89. # Settings secrets and the settings that decide where each is sent.
  90. SETTINGS_SECRETS: dict[str, tuple[str, ...]] = {
  91. "mqtt_password": ("mqtt_broker", "mqtt_port", "mqtt_use_tls"),
  92. "ha_token": ("ha_url",),
  93. "obico_ml_token": ("obico_ml_url",),
  94. "prometheus_token": (),
  95. "virtual_printer_access_code": (),
  96. "ldap_bind_password": ("ldap_server_url", "ldap_security"),
  97. }
  98. REST_PLUG_URLS = ("rest_on_url", "rest_off_url", "rest_status_url", "rest_power_url", "rest_energy_url")
  99. # Smart plug secrets and the fields that decide where each is sent.
  100. SMART_PLUG_SECRETS: dict[str, tuple[str, ...]] = {
  101. "password": ("plug_type", "ip_address"),
  102. "rest_headers": ("plug_type", *REST_PLUG_URLS),
  103. }
  104. def mask_fields(values: dict[str, Any], fields: Any) -> dict[str, Any]:
  105. """values with each set field of ``fields`` shown as the placeholder."""
  106. return {key: (SECRET_PLACEHOLDER if key in fields and value else value) for key, value in values.items()}
  107. def merge_partial(
  108. stored: dict[str, Any],
  109. update: dict[str, Any],
  110. secrets: dict[str, tuple[str, ...]],
  111. ) -> dict[str, Any]:
  112. """A partial update with the placeholder resolved: the secret is kept.
  113. A secret is kept when it is sent as the placeholder or not sent at all.
  114. Refused when a kept secret's destination changes, or the placeholder
  115. stands for a secret that is not stored.
  116. """
  117. result = dict(update)
  118. for secret, destinations in secrets.items():
  119. sent = secret in update
  120. if sent and update[secret] == SECRET_PLACEHOLDER:
  121. if not stored.get(secret):
  122. raise SecretConfigError(f"Enter {secret}; there is no stored value to keep")
  123. del result[secret]
  124. elif sent:
  125. continue
  126. if not stored.get(secret):
  127. continue
  128. moved = [
  129. field for field in destinations if field in update and not same_value(update[field], stored.get(field))
  130. ]
  131. if moved:
  132. raise SecretConfigError(f"Enter {secret} again: {', '.join(moved)} changed")
  133. return result
  134. # The SMTP settings that decide where the stored SMTP password is sent.
  135. SMTP_DESTINATIONS = ("smtp_host", "smtp_port", "smtp_security")