From 759bab3936cc9e407b5e7b0c6c53bd4c00d796b7 Mon Sep 17 00:00:00 2001 From: Wido den Hollander Date: Sun, 2 Aug 2026 09:17:43 +0200 Subject: [PATCH] KVM: packet capture of Instance NICs via operator-defined script Adds root-admin-only APIs to capture the traffic of an Instance NIC on the KVM host it is running on, for debugging and lawful interception: * enablePacketCapture nicid= * disablePacketCapture nicid= * getPacketCaptureStatus nicid= Packet capture is a flag on the NIC (stored as a nic detail). When enabled, the agent resolves the host-side tap device by MAC address, writes the NIC context (VM name/UUID, NIC UUID/MAC, IPv4/IPv6, bridge, network UUID) to /run/cloudstack/pcap-.env and starts the systemd template unit cloudstack-pcap@.service. The flag follows the Instance: a VM state listener restarts the capture after start and migration, and BindsTo= on the tap device stops the unit when the VM stops, migrates away or the NIC is unplugged. The unit executes pcap-capture.sh, shipped between the other KVM scripts in /usr/share/cloudstack-common as an example that runs tcpdump writing to /tmp with 256 MB rotation. What to capture and what to do with the data differs too much per environment for CloudStack to decide it, so operators run their own script by copying the unit, pointing its ExecStart at it and naming their unit in packet.capture.service. New agent.properties keys: packet.capture.service (systemd template unit name) and packet.capture.env.dir (environment file directory). --- .../agent/properties/AgentProperties.java | 21 ++ .../main/java/com/cloud/event/EventTypes.java | 4 + .../admin/nic/DisablePacketCaptureCmd.java | 91 ++++++++ .../admin/nic/EnablePacketCaptureCmd.java | 92 ++++++++ .../admin/nic/GetPacketCaptureStatusCmd.java | 81 +++++++ .../api/response/PacketCaptureResponse.java | 82 +++++++ .../packetcapture/PacketCaptureService.java | 45 ++++ .../packetcapture/PacketCaptureAnswer.java | 36 +++ .../packetcapture/PacketCaptureCommand.java | 92 ++++++++ debian/rules | 1 + packaging/el8/cloud.spec | 2 + packaging/suse15/cloud.spec | 2 + packaging/systemd/cloudstack-pcap@.service | 41 ++++ .../LibvirtPacketCaptureCommandWrapper.java | 166 ++++++++++++++ scripts/vm/hypervisor/kvm/pcap-capture.sh | 54 +++++ .../PacketCaptureServiceImpl.java | 213 ++++++++++++++++++ .../spring-server-core-managers-context.xml | 2 + .../PacketCaptureServiceImplTest.java | 197 ++++++++++++++++ 18 files changed, 1222 insertions(+) create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/nic/DisablePacketCaptureCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/nic/EnablePacketCaptureCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/command/admin/nic/GetPacketCaptureStatusCmd.java create mode 100644 api/src/main/java/org/apache/cloudstack/api/response/PacketCaptureResponse.java create mode 100644 api/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureService.java create mode 100644 core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureAnswer.java create mode 100644 core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureCommand.java create mode 100644 packaging/systemd/cloudstack-pcap@.service create mode 100644 plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPacketCaptureCommandWrapper.java create mode 100755 scripts/vm/hypervisor/kvm/pcap-capture.sh create mode 100644 server/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImpl.java create mode 100644 server/src/test/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImplTest.java diff --git a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java index e4775188d0ca..d5a7dfc6b15f 100644 --- a/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java +++ b/agent/src/main/java/com/cloud/agent/properties/AgentProperties.java @@ -660,6 +660,27 @@ public class AgentProperties{ */ public static final Property KVM_SCRIPTS_DIR = new Property<>("kvm.scripts.dir", "scripts/vm/hypervisor/kvm"); + /** + * Name of the systemd template unit used for packet capture of VM NICs.
+ * The agent starts and stops one instance of this unit per captured NIC, named + * <unit>@<interface>.service (e.g. cloudstack-pcap@vnet3.service).
+ * When pointing this to a custom unit, make sure the unit reads its environment from + * the file defined by {@link #PACKET_CAPTURE_ENV_DIR}.
+ * Data type: String.
+ * Default value: cloudstack-pcap + */ + public static final Property PACKET_CAPTURE_SERVICE = new Property<>("packet.capture.service", "cloudstack-pcap"); + + /** + * Directory in which the agent writes the environment file for a packet capture, + * named pcap-<interface>.env. The default matches the + * EnvironmentFile= of the shipped cloudstack-pcap@.service unit; + * change both together.
+ * Data type: String.
+ * Default value: /run/cloudstack + */ + public static final Property PACKET_CAPTURE_ENV_DIR = new Property<>("packet.capture.env.dir", "/run/cloudstack"); + /** * Specifies start MAC address for private IP range.
* Data type: String.
diff --git a/api/src/main/java/com/cloud/event/EventTypes.java b/api/src/main/java/com/cloud/event/EventTypes.java index f7d13343d469..3ca9ca4db583 100644 --- a/api/src/main/java/com/cloud/event/EventTypes.java +++ b/api/src/main/java/com/cloud/event/EventTypes.java @@ -228,6 +228,8 @@ public class EventTypes { public static final String EVENT_NIC_DETAIL_ADD = "NIC.DETAIL.ADD"; public static final String EVENT_NIC_DETAIL_UPDATE = "NIC.DETAIL.UPDATE"; public static final String EVENT_NIC_DETAIL_REMOVE = "NIC.DETAIL.REMOVE"; + public static final String EVENT_NIC_PACKET_CAPTURE_ENABLE = "NIC.PACKETCAPTURE.ENABLE"; + public static final String EVENT_NIC_PACKET_CAPTURE_DISABLE = "NIC.PACKETCAPTURE.DISABLE"; // Load Balancers public static final String EVENT_ASSIGN_TO_LOAD_BALANCER_RULE = "LB.ASSIGN.TO.RULE"; @@ -980,6 +982,8 @@ public class EventTypes { // Nic Events entityEventDetails.put(EVENT_NIC_CREATE, Nic.class); + entityEventDetails.put(EVENT_NIC_PACKET_CAPTURE_ENABLE, Nic.class); + entityEventDetails.put(EVENT_NIC_PACKET_CAPTURE_DISABLE, Nic.class); // Load Balancers entityEventDetails.put(EVENT_ASSIGN_TO_LOAD_BALANCER_RULE, FirewallRule.class); diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/DisablePacketCaptureCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/DisablePacketCaptureCmd.java new file mode 100644 index 000000000000..effbce3d8b8e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/DisablePacketCaptureCmd.java @@ -0,0 +1,91 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.nic; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NicResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.network.packetcapture.PacketCaptureService; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.Nic; + +@APICommand(name = "disablePacketCapture", responseObject = SuccessResponse.class, entityType = {Nic.class}, + responseHasSensitiveInfo = false, + requestHasSensitiveInfo = false, + description = "Disables packet capture on an Instance NIC and stops a running capture. Only supported on KVM.", + authorized = {RoleType.Admin}, + since = "4.23.0") +public class DisablePacketCaptureCmd extends BaseAsyncCmd { + + @Inject + private PacketCaptureService packetCaptureService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.NIC_ID, type = CommandType.UUID, entityType = NicResponse.class, required = true, + description = "The ID of the NIC to stop capturing packets on") + private Long nicId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + public Long getNicId() { + return nicId; + } + + ///////////////////////////////////////////////////// + /////////////////// Implementation ////////////////// + ///////////////////////////////////////////////////// + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_NIC_PACKET_CAPTURE_DISABLE; + } + + @Override + public String getEventDescription() { + return "Disabling packet capture on NIC with ID: " + getResourceUuid(ApiConstants.NIC_ID); + } + + @Override + public void execute() throws ResourceUnavailableException, ServerApiException { + try { + packetCaptureService.disablePacketCapture(getNicId()); + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/EnablePacketCaptureCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/EnablePacketCaptureCmd.java new file mode 100644 index 000000000000..a7e4d4c3e802 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/EnablePacketCaptureCmd.java @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.nic; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseAsyncCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NicResponse; +import org.apache.cloudstack.api.response.SuccessResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.network.packetcapture.PacketCaptureService; + +import com.cloud.event.EventTypes; +import com.cloud.exception.ResourceUnavailableException; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.Nic; + +@APICommand(name = "enablePacketCapture", responseObject = SuccessResponse.class, entityType = {Nic.class}, + responseHasSensitiveInfo = false, + requestHasSensitiveInfo = false, + description = "Enables packet capture on an Instance NIC. The capture is started on the host the Instance " + + "is running on and follows the Instance across stop/start and migration. Only supported on KVM.", + authorized = {RoleType.Admin}, + since = "4.23.0") +public class EnablePacketCaptureCmd extends BaseAsyncCmd { + + @Inject + private PacketCaptureService packetCaptureService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.NIC_ID, type = CommandType.UUID, entityType = NicResponse.class, required = true, + description = "The ID of the NIC to capture packets on") + private Long nicId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + public Long getNicId() { + return nicId; + } + + ///////////////////////////////////////////////////// + /////////////////// Implementation ////////////////// + ///////////////////////////////////////////////////// + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + @Override + public String getEventType() { + return EventTypes.EVENT_NIC_PACKET_CAPTURE_ENABLE; + } + + @Override + public String getEventDescription() { + return "Enabling packet capture on NIC with ID: " + getResourceUuid(ApiConstants.NIC_ID); + } + + @Override + public void execute() throws ResourceUnavailableException, ServerApiException { + try { + packetCaptureService.enablePacketCapture(getNicId()); + SuccessResponse response = new SuccessResponse(getCommandName()); + setResponseObject(response); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/GetPacketCaptureStatusCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/GetPacketCaptureStatusCmd.java new file mode 100644 index 000000000000..5c4412c4e209 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/nic/GetPacketCaptureStatusCmd.java @@ -0,0 +1,81 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.command.admin.nic; + +import javax.inject.Inject; + +import org.apache.cloudstack.acl.RoleType; +import org.apache.cloudstack.api.APICommand; +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.ApiErrorCode; +import org.apache.cloudstack.api.BaseCmd; +import org.apache.cloudstack.api.Parameter; +import org.apache.cloudstack.api.ServerApiException; +import org.apache.cloudstack.api.response.NicResponse; +import org.apache.cloudstack.api.response.PacketCaptureResponse; +import org.apache.cloudstack.context.CallContext; +import org.apache.cloudstack.network.packetcapture.PacketCaptureService; + +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.vm.Nic; + +@APICommand(name = "getPacketCaptureStatus", responseObject = PacketCaptureResponse.class, entityType = {Nic.class}, + responseHasSensitiveInfo = false, + requestHasSensitiveInfo = false, + description = "Returns whether packet capture is enabled on an Instance NIC and whether a capture is " + + "currently running on the host. Only supported on KVM.", + authorized = {RoleType.Admin}, + since = "4.23.0") +public class GetPacketCaptureStatusCmd extends BaseCmd { + + @Inject + private PacketCaptureService packetCaptureService; + + ///////////////////////////////////////////////////// + //////////////// API parameters ///////////////////// + ///////////////////////////////////////////////////// + @Parameter(name = ApiConstants.NIC_ID, type = CommandType.UUID, entityType = NicResponse.class, required = true, + description = "The ID of the NIC to get the packet capture status of") + private Long nicId; + + ///////////////////////////////////////////////////// + /////////////////// Accessors /////////////////////// + ///////////////////////////////////////////////////// + public Long getNicId() { + return nicId; + } + + ///////////////////////////////////////////////////// + /////////////////// Implementation ////////////////// + ///////////////////////////////////////////////////// + @Override + public long getEntityOwnerId() { + return CallContext.current().getCallingAccountId(); + } + + @Override + public void execute() throws ServerApiException { + try { + PacketCaptureResponse response = packetCaptureService.getPacketCaptureStatus(getNicId()); + response.setObjectName("packetcapture"); + response.setResponseName(getCommandName()); + setResponseObject(response); + } catch (CloudRuntimeException e) { + throw new ServerApiException(ApiErrorCode.INTERNAL_ERROR, e.getMessage()); + } + } +} diff --git a/api/src/main/java/org/apache/cloudstack/api/response/PacketCaptureResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/PacketCaptureResponse.java new file mode 100644 index 000000000000..00e92474257e --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/api/response/PacketCaptureResponse.java @@ -0,0 +1,82 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.api.response; + +import org.apache.cloudstack.api.ApiConstants; +import org.apache.cloudstack.api.BaseResponse; + +import com.cloud.serializer.Param; +import com.google.gson.annotations.SerializedName; + +public class PacketCaptureResponse extends BaseResponse { + + @SerializedName(ApiConstants.NIC_ID) + @Param(description = "the ID of the NIC") + private String nicId; + + @SerializedName(ApiConstants.VIRTUAL_MACHINE_ID) + @Param(description = "the ID of the Instance the NIC belongs to") + private String virtualMachineId; + + @SerializedName(ApiConstants.VIRTUAL_MACHINE_NAME) + @Param(description = "the internal name of the Instance the NIC belongs to") + private String virtualMachineName; + + @SerializedName(ApiConstants.MAC_ADDRESS) + @Param(description = "the MAC address of the NIC") + private String macAddress; + + @SerializedName(ApiConstants.ENABLED) + @Param(description = "true if packet capture is enabled on the NIC") + private Boolean enabled; + + @SerializedName("running") + @Param(description = "true if a packet capture is currently running on the host for the NIC") + private Boolean running; + + public void setNicId(String nicId) { + this.nicId = nicId; + } + + public void setVirtualMachineId(String virtualMachineId) { + this.virtualMachineId = virtualMachineId; + } + + public void setVirtualMachineName(String virtualMachineName) { + this.virtualMachineName = virtualMachineName; + } + + public void setMacAddress(String macAddress) { + this.macAddress = macAddress; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } + + public void setRunning(Boolean running) { + this.running = running; + } + + public Boolean getEnabled() { + return enabled; + } + + public Boolean getRunning() { + return running; + } +} diff --git a/api/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureService.java b/api/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureService.java new file mode 100644 index 000000000000..9edb00e88c94 --- /dev/null +++ b/api/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureService.java @@ -0,0 +1,45 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.network.packetcapture; + +import org.apache.cloudstack.api.response.PacketCaptureResponse; + +public interface PacketCaptureService { + + /** + * Name of the NIC detail that marks packet capture as enabled on a NIC. + */ + String PACKET_CAPTURE_NIC_DETAIL = "packetcapture"; + + /** + * Enables packet capture on the NIC. If the VM owning the NIC is running, + * the capture is started on its host immediately; otherwise it starts the + * next time the VM starts. + */ + void enablePacketCapture(long nicId); + + /** + * Disables packet capture on the NIC and stops a running capture. + */ + void disablePacketCapture(long nicId); + + /** + * Returns whether packet capture is enabled on the NIC and whether a + * capture is currently running on the host of the VM. + */ + PacketCaptureResponse getPacketCaptureStatus(long nicId); +} diff --git a/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureAnswer.java b/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureAnswer.java new file mode 100644 index 000000000000..cff8a9b0fc43 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureAnswer.java @@ -0,0 +1,36 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.network.packetcapture; + +import com.cloud.agent.api.Answer; + +public class PacketCaptureAnswer extends Answer { + + private boolean running; + + public PacketCaptureAnswer() { + } + + public PacketCaptureAnswer(PacketCaptureCommand command, boolean success, String details, boolean running) { + super(command, success, details); + this.running = running; + } + + public boolean isRunning() { + return running; + } +} diff --git a/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureCommand.java b/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureCommand.java new file mode 100644 index 000000000000..1680cb620a65 --- /dev/null +++ b/core/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureCommand.java @@ -0,0 +1,92 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.network.packetcapture; + +import com.cloud.agent.api.Command; + +/** + * Starts, stops or queries the packet capture of a VM NIC on the host the + * VM is running on. The NIC is identified by its MAC address on the running + * domain; the remaining fields are passed to the capture script as context. + */ +public class PacketCaptureCommand extends Command { + + public enum Action { + START, STOP, STATUS + } + + private Action action; + private String vmName; + private String vmUuid; + private String nicUuid; + private String macAddress; + private String ip4Address; + private String ip6Address; + private String networkUuid; + + public PacketCaptureCommand() { + } + + public PacketCaptureCommand(Action action, String vmName, String vmUuid, String nicUuid, String macAddress, + String ip4Address, String ip6Address, String networkUuid) { + this.action = action; + this.vmName = vmName; + this.vmUuid = vmUuid; + this.nicUuid = nicUuid; + this.macAddress = macAddress; + this.ip4Address = ip4Address; + this.ip6Address = ip6Address; + this.networkUuid = networkUuid; + } + + public Action getAction() { + return action; + } + + public String getVmName() { + return vmName; + } + + public String getVmUuid() { + return vmUuid; + } + + public String getNicUuid() { + return nicUuid; + } + + public String getMacAddress() { + return macAddress; + } + + public String getIp4Address() { + return ip4Address; + } + + public String getIp6Address() { + return ip6Address; + } + + public String getNetworkUuid() { + return networkUuid; + } + + @Override + public boolean executeInSequence() { + return false; + } +} diff --git a/debian/rules b/debian/rules index 327447823308..957665493f2a 100755 --- a/debian/rules +++ b/debian/rules @@ -48,6 +48,7 @@ override_dh_auto_install: install -m0644 packaging/systemd/$(PACKAGE)-agent.service debian/$(PACKAGE)-agent/lib/systemd/system/$(PACKAGE)-agent.service install -m0644 packaging/systemd/$(PACKAGE)-agent.default $(DESTDIR)/$(SYSCONFDIR)/default/$(PACKAGE)-agent install -m0644 packaging/systemd/$(PACKAGE)-rolling-maintenance@.service debian/$(PACKAGE)-agent/lib/systemd/system/$(PACKAGE)-rolling-maintenance@.service + install -m0644 packaging/systemd/$(PACKAGE)-pcap@.service debian/$(PACKAGE)-agent/lib/systemd/system/$(PACKAGE)-pcap@.service install -D -m0644 agent/target/transformed/cloudstack-agent.logrotate $(DESTDIR)/$(SYSCONFDIR)/logrotate.d/cloudstack-agent diff --git a/packaging/el8/cloud.spec b/packaging/el8/cloud.spec index 3ba2e4d5789e..cd5e2f392c86 100644 --- a/packaging/el8/cloud.spec +++ b/packaging/el8/cloud.spec @@ -359,6 +359,7 @@ mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-agent/lib mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-agent/plugins install -D packaging/systemd/cloudstack-agent.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-agent.service install -D packaging/systemd/cloudstack-rolling-maintenance@.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-rolling-maintenance@.service +install -D packaging/systemd/cloudstack-pcap@.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-pcap@.service install -D packaging/systemd/cloudstack-agent.default ${RPM_BUILD_ROOT}%{_sysconfdir}/default/%{name}-agent install -D agent/target/transformed/agent.properties ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/agent/agent.properties install -D agent/target/transformed/uefi.properties ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/agent/uefi.properties @@ -663,6 +664,7 @@ pip3 install --upgrade /usr/share/cloudstack-marvin/Marvin-*.tar.gz %attr(0755,root,root) %{_bindir}/%{name}-ssh %attr(0644,root,root) %{_unitdir}/%{name}-agent.service %attr(0644,root,root) %{_unitdir}/%{name}-rolling-maintenance@.service +%attr(0644,root,root) %{_unitdir}/%{name}-pcap@.service %config(noreplace) %{_sysconfdir}/default/%{name}-agent %attr(0644,root,root) %{_sysconfdir}/profile.d/%{name}-agent-profile.sh %config(noreplace) %attr(0644,root,root) %{_sysconfdir}/logrotate.d/%{name}-agent diff --git a/packaging/suse15/cloud.spec b/packaging/suse15/cloud.spec index cdfc5a72a34e..e5056ecbe11e 100644 --- a/packaging/suse15/cloud.spec +++ b/packaging/suse15/cloud.spec @@ -357,6 +357,7 @@ mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-agent/lib mkdir -p ${RPM_BUILD_ROOT}%{_datadir}/%{name}-agent/plugins install -D packaging/systemd/cloudstack-agent.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-agent.service install -D packaging/systemd/cloudstack-rolling-maintenance@.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-rolling-maintenance@.service +install -D packaging/systemd/cloudstack-pcap@.service ${RPM_BUILD_ROOT}%{_unitdir}/%{name}-pcap@.service install -D packaging/systemd/cloudstack-agent.default ${RPM_BUILD_ROOT}%{_sysconfdir}/default/%{name}-agent install -D agent/target/transformed/agent.properties ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/agent/agent.properties install -D agent/target/transformed/uefi.properties ${RPM_BUILD_ROOT}%{_sysconfdir}/%{name}/agent/uefi.properties @@ -661,6 +662,7 @@ pip3 install --upgrade /usr/share/cloudstack-marvin/Marvin-*.tar.gz %attr(0755,root,root) %{_bindir}/%{name}-ssh %attr(0644,root,root) %{_unitdir}/%{name}-agent.service %attr(0644,root,root) %{_unitdir}/%{name}-rolling-maintenance@.service +%attr(0644,root,root) %{_unitdir}/%{name}-pcap@.service %config(noreplace) %{_sysconfdir}/default/%{name}-agent %attr(0644,root,root) %{_sysconfdir}/profile.d/%{name}-agent-profile.sh %config(noreplace) %attr(0644,root,root) %{_sysconfdir}/logrotate.d/%{name}-agent diff --git a/packaging/systemd/cloudstack-pcap@.service b/packaging/systemd/cloudstack-pcap@.service new file mode 100644 index 000000000000..2f45bbfd5f11 --- /dev/null +++ b/packaging/systemd/cloudstack-pcap@.service @@ -0,0 +1,41 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# Systemd unit file for CloudStack packet capture on a single VM NIC. +# +# Started by the CloudStack KVM agent as cloudstack-pcap@.service +# when packet capture is enabled on a NIC. The agent writes the NIC context +# to /run/cloudstack/pcap-.env before starting this unit. +# +# BindsTo ties the unit to the tap device: when the VM is stopped, migrated +# away or the NIC is unplugged, the device disappears and systemd stops the +# capture automatically. +# +# The capture script shipped with CloudStack is an example. To run your own, +# copy this unit, point its ExecStart at your script and set the property +# packet.capture.service in agent.properties to the name of your unit. + +[Unit] +Description=CloudStack packet capture on %I +BindsTo=sys-subsystem-net-devices-%i.device +After=sys-subsystem-net-devices-%i.device + +[Service] +Type=simple +EnvironmentFile=/run/cloudstack/pcap-%i.env +ExecStart=/usr/share/cloudstack-common/scripts/vm/hypervisor/kvm/pcap-capture.sh +Restart=no diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPacketCaptureCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPacketCaptureCommandWrapper.java new file mode 100644 index 000000000000..3484847b936c --- /dev/null +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtPacketCaptureCommandWrapper.java @@ -0,0 +1,166 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package com.cloud.hypervisor.kvm.resource.wrapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; + +import org.apache.cloudstack.network.packetcapture.PacketCaptureAnswer; +import org.apache.cloudstack.network.packetcapture.PacketCaptureCommand; +import org.apache.commons.lang3.StringUtils; +import org.libvirt.Connect; +import org.libvirt.LibvirtException; + +import com.cloud.agent.api.Answer; +import com.cloud.agent.properties.AgentProperties; +import com.cloud.agent.properties.AgentPropertiesFileHandler; +import com.cloud.hypervisor.kvm.resource.LibvirtComputingResource; +import com.cloud.hypervisor.kvm.resource.LibvirtVMDef.InterfaceDef; +import com.cloud.resource.CommandWrapper; +import com.cloud.resource.ResourceWrapper; +import com.cloud.utils.script.Script; + +/** + * Starts, stops or queries the packet capture systemd unit + * (cloudstack-pcap@<interface>.service by default) for a NIC of a + * running VM. Before starting the unit, the NIC context is written to an + * environment file so the capture script can decide what and where to capture. + */ +@ResourceWrapper(handles = PacketCaptureCommand.class) +public class LibvirtPacketCaptureCommandWrapper extends CommandWrapper { + + @Override + public Answer execute(PacketCaptureCommand command, LibvirtComputingResource resource) { + if (StringUtils.isBlank(command.getVmName()) || StringUtils.isBlank(command.getMacAddress())) { + return new PacketCaptureAnswer(command, false, "VM name and NIC MAC address are required", false); + } + + InterfaceDef nicDevice = resolveNicDevice(command, resource); + String unit = getUnitName(nicDevice); + + switch (command.getAction()) { + case START: + if (nicDevice == null) { + return new PacketCaptureAnswer(command, false, String.format( + "no interface with MAC address %s found on a running domain %s", command.getMacAddress(), command.getVmName()), false); + } + return start(command, nicDevice, unit); + case STOP: + if (nicDevice == null) { + // The VM is not running (anymore) on this host; the unit died with the tap device. + return new PacketCaptureAnswer(command, true, "no running capture found", false); + } + return stop(command, nicDevice, unit); + case STATUS: + boolean running = nicDevice != null && systemctl("is-active", "--quiet", unit) == null; + return new PacketCaptureAnswer(command, true, null, running); + default: + return new PacketCaptureAnswer(command, false, "unknown action " + command.getAction(), false); + } + } + + private Answer start(PacketCaptureCommand command, InterfaceDef nicDevice, String unit) { + try { + writeEnvironmentFile(command, nicDevice); + } catch (IOException e) { + logger.error("Failed to write packet capture environment file for NIC {} of VM {}", nicDevice.getDevName(), command.getVmName(), e); + return new PacketCaptureAnswer(command, false, "failed to write environment file: " + e.getMessage(), false); + } + String result = systemctl("start", unit); + if (result != null) { + return new PacketCaptureAnswer(command, false, String.format("failed to start %s: %s", unit, result), false); + } + logger.info("Started packet capture unit {} for VM {}", unit, command.getVmName()); + return new PacketCaptureAnswer(command, true, null, true); + } + + private Answer stop(PacketCaptureCommand command, InterfaceDef nicDevice, String unit) { + String result = systemctl("stop", unit); + if (result != null) { + return new PacketCaptureAnswer(command, false, String.format("failed to stop %s: %s", unit, result), true); + } + try { + Files.deleteIfExists(getEnvironmentFile(nicDevice.getDevName())); + } catch (IOException e) { + logger.warn("Failed to delete packet capture environment file for {}", nicDevice.getDevName(), e); + } + logger.info("Stopped packet capture unit {} for VM {}", unit, command.getVmName()); + return new PacketCaptureAnswer(command, true, null, false); + } + + /** + * Finds the host-side interface of the NIC by matching the MAC address on + * the running domain. Returns null when the domain is not running on this + * host or has no interface with the MAC address. + */ + private InterfaceDef resolveNicDevice(PacketCaptureCommand command, LibvirtComputingResource resource) { + try { + Connect conn = resource.getLibvirtUtilitiesHelper().getConnectionByVmName(command.getVmName()); + for (InterfaceDef iface : resource.getInterfaces(conn, command.getVmName())) { + if (command.getMacAddress().equalsIgnoreCase(iface.getMacAddress())) { + return iface; + } + } + } catch (LibvirtException e) { + logger.debug("Unable to look up interfaces of VM {}: {}", command.getVmName(), e.getMessage()); + } + return null; + } + + private String getUnitName(InterfaceDef nicDevice) { + String service = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.PACKET_CAPTURE_SERVICE); + return String.format("%s@%s.service", service, nicDevice == null ? "" : nicDevice.getDevName()); + } + + private Path getEnvironmentFile(String deviceName) { + String envDir = AgentPropertiesFileHandler.getPropertyValue(AgentProperties.PACKET_CAPTURE_ENV_DIR); + return Paths.get(envDir, String.format("pcap-%s.env", deviceName)); + } + + private void writeEnvironmentFile(PacketCaptureCommand command, InterfaceDef nicDevice) throws IOException { + List lines = new ArrayList<>(); + lines.add("CS_VM_NAME=" + StringUtils.defaultString(command.getVmName())); + lines.add("CS_VM_UUID=" + StringUtils.defaultString(command.getVmUuid())); + lines.add("CS_NIC_UUID=" + StringUtils.defaultString(command.getNicUuid())); + lines.add("CS_NIC_MAC=" + StringUtils.defaultString(command.getMacAddress())); + lines.add("CS_NIC_DEV=" + StringUtils.defaultString(nicDevice.getDevName())); + lines.add("CS_NIC_BRIDGE=" + StringUtils.defaultString(nicDevice.getBrName())); + lines.add("CS_NIC_IP4=" + StringUtils.defaultString(command.getIp4Address())); + lines.add("CS_NIC_IP6=" + StringUtils.defaultString(command.getIp6Address())); + lines.add("CS_NETWORK_UUID=" + StringUtils.defaultString(command.getNetworkUuid())); + + Path file = getEnvironmentFile(nicDevice.getDevName()); + Files.createDirectories(file.getParent()); + Files.write(file, lines); + } + + /** + * Runs systemctl with the given arguments. Returns null on success, the + * error message otherwise (Script semantics). + */ + private String systemctl(String... args) { + Script script = new Script("/bin/systemctl", logger); + for (String arg : args) { + script.add(arg); + } + return script.execute(); + } +} diff --git a/scripts/vm/hypervisor/kvm/pcap-capture.sh b/scripts/vm/hypervisor/kvm/pcap-capture.sh new file mode 100755 index 000000000000..6a942b080345 --- /dev/null +++ b/scripts/vm/hypervisor/kvm/pcap-capture.sh @@ -0,0 +1,54 @@ +#!/bin/sh + +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +# CloudStack example packet capture script. +# +# Executed by cloudstack-pcap@.service when packet capture is +# enabled on a VM NIC through the CloudStack API. Every environment has +# different needs (capture filters, output location, shipping the data to a +# central collector, retention, etc.), so this script is only an example. +# +# This file is owned by the cloudstack-common package and is replaced on +# upgrade. To run your own capture, copy the unit file +# /usr/lib/systemd/system/cloudstack-pcap@.service, point its ExecStart at +# your own script and set the property packet.capture.service in +# agent.properties to the name of your unit. +# +# The CloudStack agent provides the NIC context in the environment: +# +# CS_VM_NAME VM instance name (e.g. i-2-15-VM) +# CS_VM_UUID VM UUID +# CS_NIC_UUID NIC UUID +# CS_NIC_MAC NIC MAC address +# CS_NIC_DEV host-side tap device (e.g. vnet3) +# CS_NIC_BRIDGE bridge the device is attached to +# CS_NIC_IP4 NIC IPv4 address (may be empty) +# CS_NIC_IP6 NIC IPv6 address (may be empty) +# CS_NETWORK_UUID UUID of the CloudStack network the NIC belongs to +# +# The capture must run in the foreground; systemd stops it with SIGTERM +# when capture is disabled or the tap device disappears. + +set -eu + +OUTPUT_DIR="/tmp" +OUTPUT_FILE="${OUTPUT_DIR}/${CS_VM_NAME}-${CS_NIC_MAC}.pcap" + +# Capture all traffic on the NIC, rotating over two files of 256 MB each. +exec tcpdump -i "${CS_NIC_DEV}" -w "${OUTPUT_FILE}" -C 256 -W 2 -Z root diff --git a/server/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImpl.java b/server/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImpl.java new file mode 100644 index 000000000000..a3cd1c26a69d --- /dev/null +++ b/server/src/main/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImpl.java @@ -0,0 +1,213 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.network.packetcapture; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import javax.inject.Inject; +import javax.naming.ConfigurationException; + +import org.apache.cloudstack.api.command.admin.nic.DisablePacketCaptureCmd; +import org.apache.cloudstack.api.command.admin.nic.EnablePacketCaptureCmd; +import org.apache.cloudstack.api.command.admin.nic.GetPacketCaptureStatusCmd; +import org.apache.cloudstack.api.response.PacketCaptureResponse; + +import com.cloud.agent.AgentManager; +import com.cloud.agent.api.Answer; +import com.cloud.event.ActionEvent; +import com.cloud.event.EventTypes; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.network.dao.NetworkDao; +import com.cloud.network.dao.NetworkVO; +import com.cloud.utils.component.ManagerBase; +import com.cloud.utils.component.PluggableService; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.StateListener; +import com.cloud.utils.fsm.StateMachine2; +import com.cloud.vm.NicDetailVO; +import com.cloud.vm.NicVO; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.VirtualMachine.Event; +import com.cloud.vm.VirtualMachine.State; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.NicDetailsDao; +import com.cloud.vm.dao.VMInstanceDao; + +public class PacketCaptureServiceImpl extends ManagerBase implements PacketCaptureService, PluggableService, + StateListener { + + @Inject + private NicDao nicDao; + @Inject + private NicDetailsDao nicDetailsDao; + @Inject + private VMInstanceDao vmInstanceDao; + @Inject + private NetworkDao networkDao; + @Inject + private AgentManager agentManager; + + @Override + public boolean configure(String name, Map params) throws ConfigurationException { + VirtualMachine.State.getStateMachine().registerListener(this); + return true; + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_NIC_PACKET_CAPTURE_ENABLE, eventDescription = "enabling packet capture", async = true) + public void enablePacketCapture(long nicId) { + NicVO nic = validateAndGetNic(nicId); + VMInstanceVO vm = validateAndGetVm(nic); + if (isVmRunningOnHost(vm)) { + sendCommand(PacketCaptureCommand.Action.START, vm, nic); + } + nicDetailsDao.removeDetail(nic.getId(), PACKET_CAPTURE_NIC_DETAIL); + nicDetailsDao.addDetail(nic.getId(), PACKET_CAPTURE_NIC_DETAIL, Boolean.TRUE.toString(), true); + logger.info("Enabled packet capture on NIC {} of VM {}", nic, vm); + } + + @Override + @ActionEvent(eventType = EventTypes.EVENT_NIC_PACKET_CAPTURE_DISABLE, eventDescription = "disabling packet capture", async = true) + public void disablePacketCapture(long nicId) { + NicVO nic = validateAndGetNic(nicId); + VMInstanceVO vm = validateAndGetVm(nic); + if (isVmRunningOnHost(vm)) { + sendCommand(PacketCaptureCommand.Action.STOP, vm, nic); + } + nicDetailsDao.removeDetail(nic.getId(), PACKET_CAPTURE_NIC_DETAIL); + logger.info("Disabled packet capture on NIC {} of VM {}", nic, vm); + } + + @Override + public PacketCaptureResponse getPacketCaptureStatus(long nicId) { + NicVO nic = validateAndGetNic(nicId); + VMInstanceVO vm = validateAndGetVm(nic); + + boolean running = false; + if (isPacketCaptureEnabled(nic.getId()) && isVmRunningOnHost(vm)) { + PacketCaptureAnswer answer = sendCommand(PacketCaptureCommand.Action.STATUS, vm, nic); + running = answer.isRunning(); + } + + PacketCaptureResponse response = new PacketCaptureResponse(); + response.setNicId(nic.getUuid()); + response.setVirtualMachineId(vm.getUuid()); + response.setVirtualMachineName(vm.getInstanceName()); + response.setMacAddress(nic.getMacAddress()); + response.setEnabled(isPacketCaptureEnabled(nic.getId())); + response.setRunning(running); + return response; + } + + @Override + public boolean preStateTransitionEvent(State oldState, Event event, State newState, VirtualMachine vo, boolean status, Object opaque) { + return true; + } + + @Override + public boolean postStateTransitionEvent(StateMachine2.Transition transition, VirtualMachine vm, boolean status, Object opaque) { + if (!status) { + return true; + } + State oldState = transition.getCurrentState(); + State newState = transition.getToState(); + Event event = transition.getEvent(); + if (State.isVmStarted(oldState, event, newState) || State.isVmMigrated(oldState, event, newState)) { + startEnabledCapturesForVm(vm); + } + return true; + } + + /** + * Starts the capture on the current host of the VM for every NIC that has + * packet capture enabled. Called after a VM started or migrated; failures + * are logged and do not fail the VM operation. + */ + private void startEnabledCapturesForVm(VirtualMachine vm) { + if (vm.getHypervisorType() != HypervisorType.KVM || vm.getHostId() == null) { + return; + } + for (NicVO nic : nicDao.listByVmId(vm.getId())) { + if (!isPacketCaptureEnabled(nic.getId())) { + continue; + } + try { + VMInstanceVO vmVo = vmInstanceDao.findById(vm.getId()); + sendCommand(PacketCaptureCommand.Action.START, vmVo, nic); + logger.info("Started packet capture on NIC {} of VM {} on host {}", nic, vm, vm.getHostId()); + } catch (Exception e) { + logger.warn("Failed to start packet capture on NIC {} of VM {} on host {}", nic, vm, vm.getHostId(), e); + } + } + } + + private boolean isPacketCaptureEnabled(long nicId) { + NicDetailVO detail = nicDetailsDao.findDetail(nicId, PACKET_CAPTURE_NIC_DETAIL); + return detail != null && Boolean.parseBoolean(detail.getValue()); + } + + private NicVO validateAndGetNic(long nicId) { + NicVO nic = nicDao.findById(nicId); + if (nic == null || nic.getRemoved() != null) { + throw new InvalidParameterValueException("Unable to find a NIC with the specified id"); + } + return nic; + } + + private VMInstanceVO validateAndGetVm(NicVO nic) { + Long vmId = nic.getInstanceId(); + VMInstanceVO vm = vmId == null ? null : vmInstanceDao.findById(vmId); + if (vm == null) { + throw new InvalidParameterValueException(String.format("NIC %s is not attached to an Instance", nic.getUuid())); + } + if (vm.getHypervisorType() != null && vm.getHypervisorType() != HypervisorType.KVM) { + throw new InvalidParameterValueException("Packet capture is only supported on KVM"); + } + return vm; + } + + private boolean isVmRunningOnHost(VMInstanceVO vm) { + return vm.getState() == State.Running && vm.getHostId() != null; + } + + private PacketCaptureAnswer sendCommand(PacketCaptureCommand.Action action, VMInstanceVO vm, NicVO nic) { + NetworkVO network = networkDao.findById(nic.getNetworkId()); + PacketCaptureCommand command = new PacketCaptureCommand(action, vm.getInstanceName(), vm.getUuid(), + nic.getUuid(), nic.getMacAddress(), nic.getIPv4Address(), nic.getIPv6Address(), + network == null ? null : network.getUuid()); + Answer answer = agentManager.easySend(vm.getHostId(), command); + if (answer == null || !answer.getResult()) { + throw new CloudRuntimeException(String.format("Failed to %s packet capture for NIC %s of VM %s on host %d: %s", + action.name().toLowerCase(), nic.getUuid(), vm.getInstanceName(), vm.getHostId(), + answer == null ? "no answer from host" : answer.getDetails())); + } + return (PacketCaptureAnswer) answer; + } + + @Override + public List> getCommands() { + List> cmdList = new ArrayList<>(); + cmdList.add(EnablePacketCaptureCmd.class); + cmdList.add(DisablePacketCaptureCmd.class); + cmdList.add(GetPacketCaptureStatusCmd.class); + return cmdList; + } +} diff --git a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml index c0bcba44c642..a61d0f2d1b57 100644 --- a/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml +++ b/server/src/main/resources/META-INF/cloudstack/core/spring-server-core-managers-context.xml @@ -347,6 +347,8 @@ + + diff --git a/server/src/test/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImplTest.java b/server/src/test/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImplTest.java new file mode 100644 index 000000000000..869a532e806a --- /dev/null +++ b/server/src/test/java/org/apache/cloudstack/network/packetcapture/PacketCaptureServiceImplTest.java @@ -0,0 +1,197 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. +package org.apache.cloudstack.network.packetcapture; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Collections; + +import org.apache.cloudstack.api.response.PacketCaptureResponse; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.MockitoJUnitRunner; + +import com.cloud.agent.AgentManager; +import com.cloud.exception.InvalidParameterValueException; +import com.cloud.hypervisor.Hypervisor.HypervisorType; +import com.cloud.network.dao.NetworkDao; +import com.cloud.utils.exception.CloudRuntimeException; +import com.cloud.utils.fsm.StateMachine2; +import com.cloud.vm.NicDetailVO; +import com.cloud.vm.NicVO; +import com.cloud.vm.VMInstanceVO; +import com.cloud.vm.VirtualMachine; +import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.NicDetailsDao; +import com.cloud.vm.dao.VMInstanceDao; + +@RunWith(MockitoJUnitRunner.class) +public class PacketCaptureServiceImplTest { + + private static final long NIC_ID = 10L; + private static final long VM_ID = 20L; + private static final long HOST_ID = 30L; + + @Mock + private NicDao nicDao; + @Mock + private NicDetailsDao nicDetailsDao; + @Mock + private VMInstanceDao vmInstanceDao; + @Mock + private NetworkDao networkDao; + @Mock + private AgentManager agentManager; + + @Mock + private NicVO nic; + @Mock + private VMInstanceVO vm; + + @InjectMocks + private PacketCaptureServiceImpl service = new PacketCaptureServiceImpl(); + + @Before + public void setUp() { + when(nicDao.findById(NIC_ID)).thenReturn(nic); + when(nic.getId()).thenReturn(NIC_ID); + when(nic.getInstanceId()).thenReturn(VM_ID); + when(vmInstanceDao.findById(VM_ID)).thenReturn(vm); + when(vm.getHypervisorType()).thenReturn(HypervisorType.KVM); + } + + private void mockRunningVm() { + when(vm.getState()).thenReturn(VirtualMachine.State.Running); + when(vm.getHostId()).thenReturn(HOST_ID); + } + + private void mockAnswer(boolean success, boolean running) { + PacketCaptureAnswer answer = Mockito.mock(PacketCaptureAnswer.class); + when(answer.getResult()).thenReturn(success); + if (success) { + Mockito.lenient().when(answer.isRunning()).thenReturn(running); + } else { + Mockito.lenient().when(answer.getDetails()).thenReturn("something failed"); + } + when(agentManager.easySend(eq(HOST_ID), any(PacketCaptureCommand.class))).thenReturn(answer); + } + + private void mockEnabledDetail() { + NicDetailVO detail = new NicDetailVO(NIC_ID, PacketCaptureService.PACKET_CAPTURE_NIC_DETAIL, "true", true); + when(nicDetailsDao.findDetail(NIC_ID, PacketCaptureService.PACKET_CAPTURE_NIC_DETAIL)).thenReturn(detail); + } + + @Test + public void testEnableOnRunningVmSendsStartAndAddsDetail() { + mockRunningVm(); + mockAnswer(true, true); + + service.enablePacketCapture(NIC_ID); + + verify(agentManager).easySend(eq(HOST_ID), any(PacketCaptureCommand.class)); + verify(nicDetailsDao).addDetail(NIC_ID, PacketCaptureService.PACKET_CAPTURE_NIC_DETAIL, "true", true); + } + + @Test + public void testEnableOnStoppedVmOnlyAddsDetail() { + when(vm.getState()).thenReturn(VirtualMachine.State.Stopped); + + service.enablePacketCapture(NIC_ID); + + verify(agentManager, never()).easySend(anyLong(), any()); + verify(nicDetailsDao).addDetail(NIC_ID, PacketCaptureService.PACKET_CAPTURE_NIC_DETAIL, "true", true); + } + + @Test + public void testEnableFailsWithoutAddingDetailWhenHostFails() { + mockRunningVm(); + mockAnswer(false, false); + + assertThrows(CloudRuntimeException.class, () -> service.enablePacketCapture(NIC_ID)); + + verify(nicDetailsDao, never()).addDetail(anyLong(), any(), any(), Mockito.anyBoolean()); + } + + @Test + public void testEnableRejectsNonKvm() { + when(vm.getHypervisorType()).thenReturn(HypervisorType.VMware); + + assertThrows(InvalidParameterValueException.class, () -> service.enablePacketCapture(NIC_ID)); + } + + @Test + public void testDisableOnRunningVmSendsStopAndRemovesDetail() { + mockRunningVm(); + mockAnswer(true, false); + + service.disablePacketCapture(NIC_ID); + + verify(agentManager).easySend(eq(HOST_ID), any(PacketCaptureCommand.class)); + verify(nicDetailsDao).removeDetail(NIC_ID, PacketCaptureService.PACKET_CAPTURE_NIC_DETAIL); + } + + @Test + public void testStatusReportsEnabledAndRunning() { + mockRunningVm(); + mockAnswer(true, true); + mockEnabledDetail(); + when(nic.getUuid()).thenReturn("nic-uuid"); + when(vm.getUuid()).thenReturn("vm-uuid"); + + PacketCaptureResponse response = service.getPacketCaptureStatus(NIC_ID); + + assertEquals(Boolean.TRUE, response.getEnabled()); + assertEquals(Boolean.TRUE, response.getRunning()); + } + + @Test + public void testStatusDoesNotQueryHostWhenDisabled() { + mockRunningVm(); + + PacketCaptureResponse response = service.getPacketCaptureStatus(NIC_ID); + + verify(agentManager, never()).easySend(anyLong(), any()); + assertEquals(Boolean.FALSE, response.getEnabled()); + assertEquals(Boolean.FALSE, response.getRunning()); + } + + @Test + public void testVmStartTransitionStartsEnabledCaptures() { + mockRunningVm(); + mockAnswer(true, true); + mockEnabledDetail(); + when(vm.getId()).thenReturn(VM_ID); + when(nicDao.listByVmId(VM_ID)).thenReturn(Collections.singletonList(nic)); + + StateMachine2.Transition transition = new StateMachine2.Transition<>( + VirtualMachine.State.Starting, VirtualMachine.Event.OperationSucceeded, VirtualMachine.State.Running, null); + service.postStateTransitionEvent(transition, vm, true, null); + + verify(agentManager).easySend(eq(HOST_ID), any(PacketCaptureCommand.class)); + } +}