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)); + } +}