netbox-bmc 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netbox_bmc/__init__.py ADDED
@@ -0,0 +1,78 @@
1
+ from netbox.plugins import PluginConfig
2
+
3
+ __version__ = "0.4.0"
4
+ __author__ = "tak-labo"
5
+ __email__ = ""
6
+
7
+
8
+ class NetBoxBMCConfig(PluginConfig):
9
+ name = "netbox_bmc"
10
+ verbose_name = "NetBox BMC (IPMI/Redfish)"
11
+ description = "Unified out-of-band management: Redfish & IPMI inventory sync, power control, console access"
12
+ version = __version__
13
+ author = __author__
14
+ author_email = __email__
15
+ base_url = "bmc"
16
+ min_version = "4.5"
17
+ max_version = "4.6.99"
18
+ default_settings = {
19
+ # 定期一括同期の間隔 (分)。0 で無効。
20
+ "sync_interval_minutes": 0,
21
+ "default_verify_ssl": False,
22
+ # netbox-secrets バックグラウンドジョブ用サービスアカウント設定
23
+ # (netbox-secrets 使用時のみ必要)
24
+ # "service_account": "bmc-sync",
25
+ # "service_private_key_path": "/opt/netbox/bmc-sync.pem",
26
+ }
27
+
28
+ def ready(self):
29
+ super().ready()
30
+ from . import jobs # noqa: F401
31
+
32
+ interval = (getattr(self, "settings", None) or {}).get("sync_interval_minutes") or 0
33
+ if interval:
34
+ self._enqueue_scheduled_sync(interval)
35
+
36
+ @staticmethod
37
+ def _enqueue_scheduled_sync(interval_minutes: int) -> None:
38
+ """
39
+ ScheduledInventorySyncJob の定期ジョブを登録する。
40
+
41
+ ready() は workers / web プロセスの起動ごとに呼ばれるため、
42
+ 既に pending / scheduled / running の同名ジョブがあればスキップして
43
+ 冪等にする。初回マイグレーション前など Job テーブル未作成の場合は黙って抜ける。
44
+ """
45
+ import logging
46
+
47
+ from django.db import DatabaseError
48
+
49
+ from .jobs import ScheduledInventorySyncJob
50
+
51
+ logger = logging.getLogger("netbox_bmc")
52
+
53
+ try:
54
+ from datetime import timedelta
55
+
56
+ from core.models import Job
57
+ from django.utils import timezone
58
+
59
+ active = Job.objects.filter(
60
+ name=ScheduledInventorySyncJob.Meta.name,
61
+ status__in=("pending", "scheduled", "running"),
62
+ ).exists()
63
+ if active:
64
+ return
65
+
66
+ ScheduledInventorySyncJob.enqueue(
67
+ instance=None,
68
+ schedule_at=timezone.now() + timedelta(minutes=interval_minutes),
69
+ interval=interval_minutes,
70
+ )
71
+ except DatabaseError:
72
+ # マイグレーション前: Job テーブルが存在しない
73
+ pass
74
+ except Exception as e:
75
+ logger.warning("Failed to schedule recurring inventory sync: %s", e)
76
+
77
+
78
+ config = NetBoxBMCConfig
File without changes
@@ -0,0 +1,23 @@
1
+ from netbox.api.serializers import NetBoxModelSerializer
2
+ from rest_framework import serializers
3
+
4
+ from ..models import BMCEndpoint
5
+
6
+
7
+ class BMCEndpointSerializer(NetBoxModelSerializer):
8
+ url = serializers.HyperlinkedIdentityField(
9
+ view_name="plugins-api:netbox_bmc-api:bmcendpoint-detail",
10
+ )
11
+ password = serializers.CharField(write_only=True, required=False, allow_blank=True)
12
+
13
+ class Meta:
14
+ model = BMCEndpoint
15
+ fields = (
16
+ "id", "url", "display",
17
+ "device", "address", "port", "protocol", "verify_ssl",
18
+ "username", "password",
19
+ "detected_vendor", "detected_protocol",
20
+ "last_sync", "last_sync_status",
21
+ "tags", "custom_fields", "created", "last_updated",
22
+ )
23
+ brief_fields = ("id", "url", "display", "device", "address")
netbox_bmc/api/urls.py ADDED
@@ -0,0 +1,7 @@
1
+ from netbox.api.routers import NetBoxRouter
2
+
3
+ from . import views
4
+
5
+ router = NetBoxRouter()
6
+ router.register("endpoints", views.BMCEndpointViewSet)
7
+ urlpatterns = router.urls
@@ -0,0 +1,9 @@
1
+ from netbox.api.viewsets import NetBoxModelViewSet
2
+
3
+ from ..models import BMCEndpoint
4
+ from .serializers import BMCEndpointSerializer
5
+
6
+
7
+ class BMCEndpointViewSet(NetBoxModelViewSet):
8
+ queryset = BMCEndpoint.objects.prefetch_related("tags")
9
+ serializer_class = BMCEndpointSerializer
@@ -0,0 +1,209 @@
1
+ """
2
+ netbox-secrets からBMC認証情報を取得するヘルパー。
3
+
4
+ 【復号の経路】
5
+ バックグラウンドジョブ (InventorySyncJob / ScheduledInventorySyncJob):
6
+ request なし → PLUGINS_CONFIG の service_private_key_path に
7
+ 置いたサービスアカウントの RSA 秘密鍵 (PEM) で UserKey を復号し
8
+ master_key を取得する。
9
+
10
+ request 引数経路は将来的な REST API 同期実行用に残してあるが、
11
+ ジョブ kwargs を介してセッションキーを渡すと Job レコードに平文で
12
+ 保存されてしまうため、現状の UI トリガーでは使用しない。
13
+
14
+ 【Secretのレイアウト規約】
15
+ - SecretRole slug : bmc-credentials
16
+ - Secret.name : BMC username (非暗号化フィールド)
17
+ - Secret.plaintext: BMC password (RSA暗号化)
18
+ - assigned_object: Device (当該 BMCEndpoint の device)
19
+
20
+ 【フォールバック動作】
21
+ netbox-secrets が未インストールの場合は BMCEndpoint.username/password の
22
+ 平文フィールドにフォールバックする (後方互換)。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import base64
27
+ import logging
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import TYPE_CHECKING
31
+
32
+ from django.conf import settings
33
+ from django.contrib.contenttypes.models import ContentType
34
+
35
+ if TYPE_CHECKING:
36
+ from django.http import HttpRequest
37
+
38
+ from .models import BMCEndpoint
39
+
40
+ logger = logging.getLogger("netbox_bmc.credentials")
41
+
42
+ SECRET_ROLE_SLUG = "bmc-credentials"
43
+
44
+
45
+ @dataclass
46
+ class Credential:
47
+ username: str
48
+ password: str
49
+ source: str # "netbox_secrets" | "plaintext_fallback"
50
+
51
+
52
+ def get_credential(endpoint: BMCEndpoint,
53
+ request: HttpRequest | None = None) -> Credential:
54
+ """
55
+ BMCEndpoint に紐づく認証情報を取得して返す。
56
+
57
+ netbox-secrets が使用可能な場合:
58
+ - request が渡された場合はセッションキーで復号
59
+ - request=None の場合はサービスアカウント秘密鍵で復号
60
+
61
+ どちらも失敗した場合 / netbox-secrets 未インストールの場合:
62
+ - endpoint.username / endpoint.password を返す
63
+ """
64
+ try:
65
+ return _get_from_secrets(endpoint, request)
66
+ except _SecretsUnavailable:
67
+ logger.debug("netbox-secrets unavailable, using plaintext fields")
68
+ except _SecretNotFound:
69
+ logger.debug("No bmc-credentials secret for device %s, using plaintext fields",
70
+ endpoint.device)
71
+ except Exception as e:
72
+ # netbox-secrets が利用可能なのに復号に失敗した場合は警告ではなく
73
+ # error レベルで残す (運用上、無音で平文に落ちると気付きにくい)。
74
+ logger.error("Failed to decrypt secret for %s: %s, using plaintext fields",
75
+ endpoint.device, e)
76
+
77
+ # フォールバック
78
+ return Credential(
79
+ username=endpoint.username,
80
+ password=endpoint.password,
81
+ source="plaintext_fallback",
82
+ )
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # 内部実装
87
+ # ---------------------------------------------------------------------------
88
+
89
+ class _SecretsUnavailable(Exception):
90
+ pass
91
+
92
+
93
+ class _SecretNotFound(Exception):
94
+ pass
95
+
96
+
97
+ def _get_from_secrets(endpoint: BMCEndpoint,
98
+ request: HttpRequest | None) -> Credential:
99
+ try:
100
+ from netbox_secrets.models import Secret, SecretRole
101
+ except ImportError:
102
+ raise _SecretsUnavailable from None
103
+
104
+ device = endpoint.device
105
+ device_ct = ContentType.objects.get_for_model(device)
106
+
107
+ role_qs = SecretRole.objects.filter(slug=SECRET_ROLE_SLUG)
108
+ if not role_qs.exists():
109
+ raise _SecretNotFound(f"SecretRole '{SECRET_ROLE_SLUG}' not found")
110
+
111
+ secret_qs = Secret.objects.filter(
112
+ role__in=role_qs,
113
+ assigned_object_type=device_ct,
114
+ assigned_object_id=device.pk,
115
+ )
116
+ if not secret_qs.exists():
117
+ raise _SecretNotFound
118
+
119
+ secret = secret_qs.first()
120
+ master_key = _resolve_master_key(request)
121
+ secret.decrypt(master_key)
122
+
123
+ if secret.plaintext is None:
124
+ raise Exception("decrypt returned None — wrong key?")
125
+
126
+ return Credential(
127
+ username=secret.name, # name フィールドがユーザー名
128
+ password=secret.plaintext,
129
+ source="netbox_secrets",
130
+ )
131
+
132
+
133
+ def _resolve_master_key(request: HttpRequest | None) -> bytes:
134
+ """
135
+ master_key (bytes) を返す。
136
+
137
+ request あり → Cookie または X-Session-Key ヘッダからセッションキーを取得
138
+ request なし → サービスアカウント秘密鍵で復号
139
+ """
140
+
141
+ if request is not None:
142
+ return _master_key_from_request(request)
143
+
144
+ return _master_key_from_service_account()
145
+
146
+
147
+ def _master_key_from_request(request) -> bytes:
148
+ from netbox_secrets.models import UserKey
149
+
150
+ # X-Session-Key ヘッダ (API) or session_key Cookie (ブラウザ)
151
+ session_key_b64 = (
152
+ request.META.get("HTTP_X_SESSION_KEY")
153
+ or request.COOKIES.get("session_key")
154
+ )
155
+ if not session_key_b64:
156
+ raise Exception("No X-Session-Key header or session_key cookie in request")
157
+
158
+ session_key = base64.b64decode(session_key_b64)
159
+ try:
160
+ uk = UserKey.objects.get(user=request.user)
161
+ except UserKey.DoesNotExist as e:
162
+ raise Exception(f"No UserKey found for user {request.user}") from e
163
+
164
+ master_key = uk.get_master_key(session_key)
165
+ if master_key is None:
166
+ raise Exception("get_master_key returned None — session key may be expired")
167
+ return master_key
168
+
169
+
170
+ def _master_key_from_service_account() -> bytes:
171
+ """
172
+ PLUGINS_CONFIG["netbox_bmc"]["service_account"] で指定された
173
+ サービスアカウントの UserKey と RSA 秘密鍵ファイルで master_key を取得する。
174
+
175
+ configuration.py での設定例:
176
+ PLUGINS_CONFIG = {
177
+ "netbox_bmc": {
178
+ "service_account": "bmc-sync", # NetBoxユーザー名
179
+ "service_private_key_path": "/opt/netbox/bmc-sync.pem",
180
+ }
181
+ }
182
+ """
183
+ from netbox_secrets.models import UserKey
184
+
185
+ plugin_cfg = settings.PLUGINS_CONFIG.get("netbox_bmc", {})
186
+ account = plugin_cfg.get("service_account")
187
+ key_path = plugin_cfg.get("service_private_key_path")
188
+
189
+ if not account or not key_path:
190
+ raise Exception(
191
+ "service_account and service_private_key_path must be set in "
192
+ "PLUGINS_CONFIG['netbox_bmc'] for background job decryption"
193
+ )
194
+
195
+ pem = Path(key_path).read_text()
196
+
197
+ try:
198
+ uk = UserKey.objects.get(user__username=account)
199
+ except UserKey.DoesNotExist as e:
200
+ raise Exception(f"No UserKey for service account '{account}'") from e
201
+
202
+ # UserKey.get_master_key は秘密鍵PEM文字列を受け付ける
203
+ master_key = uk.get_master_key(private_key=pem)
204
+ if master_key is None:
205
+ raise Exception(
206
+ f"master_key is None for service account '{account}' — "
207
+ "check that the private key matches the stored public key"
208
+ )
209
+ return master_key
@@ -0,0 +1,3 @@
1
+ from .base import BaseDriver, BMCError, detect_and_build
2
+
3
+ __all__ = ["BaseDriver", "BMCError", "detect_and_build"]
@@ -0,0 +1,71 @@
1
+ """
2
+ ドライバ基底クラスとプロトコル/ベンダー自動検出。
3
+
4
+ 設計方針:
5
+ - BaseDriver は最小限のインターフェイスのみ定義
6
+ - Redfish はパスをハードコードせず ServiceRoot からリンクを辿る
7
+ - ベンダー固有処理 (OEM 拡張) はベンダーサブクラスでオーバーライド
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from ..inventory import InventoryResult
12
+
13
+
14
+ class BMCError(Exception):
15
+ """ドライバ層の共通例外。"""
16
+
17
+
18
+ class BaseDriver:
19
+ protocol: str = ""
20
+
21
+ def __init__(self, address: str, username: str, password: str,
22
+ port: int | None = None, verify_ssl: bool = False,
23
+ timeout: int = 15):
24
+ self.address = address
25
+ self.username = username
26
+ self.password = password
27
+ self.port = port
28
+ self.verify_ssl = verify_ssl
29
+ self.timeout = timeout
30
+
31
+ # --- 必須インターフェイス -------------------------------------------
32
+ def get_inventory(self) -> InventoryResult:
33
+ raise NotImplementedError
34
+
35
+ def get_power_state(self) -> str:
36
+ raise NotImplementedError
37
+
38
+ def set_power(self, action: str) -> None:
39
+ """action: on | off | cycle | reset | soft"""
40
+ raise NotImplementedError
41
+
42
+ def close(self) -> None:
43
+ pass
44
+
45
+ def __enter__(self):
46
+ return self
47
+
48
+ def __exit__(self, *exc):
49
+ self.close()
50
+
51
+
52
+ def detect_and_build(address: str, username: str, password: str,
53
+ protocol: str = "auto", **kwargs) -> BaseDriver:
54
+ """
55
+ プロトコルを判別してドライバインスタンスを返す。
56
+
57
+ auto の場合: まず https://<addr>/redfish/v1 を叩き、応答があれば
58
+ Redfish、なければ IPMI へフォールバックする。
59
+ """
60
+ from .ipmi import IPMIDriver
61
+ from .redfish import build_redfish_driver, probe_redfish
62
+
63
+ if protocol == "redfish":
64
+ return build_redfish_driver(address, username, password, **kwargs)
65
+ if protocol == "ipmi":
66
+ return IPMIDriver(address, username, password, **kwargs)
67
+
68
+ # auto
69
+ if probe_redfish(address, timeout=5, verify_ssl=kwargs.get("verify_ssl", False)):
70
+ return build_redfish_driver(address, username, password, **kwargs)
71
+ return IPMIDriver(address, username, password, **kwargs)
@@ -0,0 +1,103 @@
1
+ """
2
+ IPMI ドライバ (レガシー BMC 向けフォールバック)。
3
+
4
+ pyghmi の get_inventory() で FRU 情報を取得する。
5
+ Redfish より取得できる情報は限定的 (CPU/DIMM の詳細は出ないことが多い)。
6
+ 既存 netbox-ipmi-plugin の電源操作・SOL 周りはここに段階的に移植する。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ from ..inventory import Component, InventoryResult, SystemInfo
13
+ from .base import BaseDriver, BMCError
14
+
15
+ logger = logging.getLogger("netbox_bmc.ipmi")
16
+
17
+ POWER_ACTION_MAP = {
18
+ "on": "on",
19
+ "off": "off",
20
+ "soft": "softoff",
21
+ "cycle": "boot", # pyghmi: off→on
22
+ "reset": "reset",
23
+ }
24
+
25
+
26
+ class IPMIDriver(BaseDriver):
27
+ protocol = "ipmi"
28
+
29
+ def __init__(self, *args, **kwargs):
30
+ super().__init__(*args, **kwargs)
31
+ try:
32
+ from pyghmi.ipmi import command
33
+ except ImportError as e:
34
+ raise BMCError("pyghmi is not installed") from e
35
+ try:
36
+ self.cmd = command.Command(
37
+ bmc=self.address,
38
+ userid=self.username,
39
+ password=self.password,
40
+ port=self.port or 623,
41
+ )
42
+ except Exception as e:
43
+ raise BMCError(f"IPMI connection to {self.address} failed: {e}") from e
44
+
45
+ def get_inventory(self) -> InventoryResult:
46
+ system = SystemInfo()
47
+ components: list[Component] = []
48
+ try:
49
+ for name, info in self.cmd.get_inventory():
50
+ if info is None:
51
+ continue
52
+ if name == "System":
53
+ system.manufacturer = info.get("Manufacturer", "") or ""
54
+ system.model = info.get("Product name", "") or ""
55
+ system.serial = info.get("Serial Number", "") or ""
56
+ system.uuid = str(info.get("UUID", "") or "")
57
+ else:
58
+ components.append(Component(
59
+ kind=_guess_kind(name),
60
+ name=name,
61
+ manufacturer=info.get("Manufacturer", "") or "",
62
+ part_id=info.get("Part Number", "")
63
+ or info.get("Product name", "") or "",
64
+ serial=info.get("Serial Number", "") or "",
65
+ ))
66
+ except Exception as e:
67
+ raise BMCError(f"IPMI FRU read failed: {e}") from e
68
+
69
+ return InventoryResult(system=system, components=components,
70
+ vendor=system.manufacturer or "Unknown",
71
+ protocol=self.protocol)
72
+
73
+ def get_power_state(self) -> str:
74
+ try:
75
+ return self.cmd.get_power().get("powerstate", "unknown")
76
+ except Exception as e:
77
+ raise BMCError(str(e)) from e
78
+
79
+ def set_power(self, action: str) -> None:
80
+ mapped = POWER_ACTION_MAP.get(action)
81
+ if not mapped:
82
+ raise BMCError(f"Unknown power action: {action}")
83
+ try:
84
+ self.cmd.set_power(mapped, wait=False)
85
+ except Exception as e:
86
+ raise BMCError(f"IPMI power action failed: {e}") from e
87
+
88
+ def close(self):
89
+ try:
90
+ self.cmd.ipmi_session.logout()
91
+ except Exception:
92
+ pass
93
+
94
+
95
+ def _guess_kind(fru_name: str) -> str:
96
+ n = fru_name.lower()
97
+ if "psu" in n or "power" in n:
98
+ return "psu"
99
+ if "fan" in n:
100
+ return "fan"
101
+ if "nic" in n or "net" in n:
102
+ return "nic"
103
+ return "other"